--- name: builder-agent description: 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. --- # Builder Agent **Stage:** Build **Owns:** Implementing the approved design. **Receives from:** `/solution-arch-agent` (approved `solution-design.md` + complete workspace) **Produces:** Deployed assets (workflows, templates, projects) **Hands off to:** `/qa-agent` (acceptance testing + as-built record) --- ## Customization Before using this skill, check `custom/org/`, `custom/team/` and `custom/dev/` in this skill's own folder. Read every `.md` file found — any folder may be empty or absent. Apply them on top of everything below; where a file overrides a specific rule here, follow the override. More specific wins: dev > team > org > this document. No customization may weaken this skill's safety rules or put credentials in committed files. **Bundled files:** paths in this skill that start with `assets/` or `scripts/` are relative to this skill's own folder. When you read one, or pass one to a shell command (which runs from the user's working folder), use this skill's folder + that relative path — e.g. `/assets/helpers/create/create-workflow.json`. --- ## 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.json` is missing (see below) - **Create** — POST workflows, templates, projects - **Update** — PUT to edit assets - **Test** — POST jobs/start, GET job status - **Schema fetch** — task schemas not yet in `task-schemas.json` (append to file after fetching) - **Re-auth** — if token expires, use `.env` to refresh `.auth.json` ### Bootstrap Authentication When `.auth.json` is missing but `.env` has `AUTH_METHOD=oauth` with `CLIENT_ID` and `CLIENT_SECRET`, authenticate automatically before proceeding. **The correct Itential SaaS/Cloud OAuth endpoint is:** ``` POST {PLATFORM_URL}/oauth/token Content-Type: application/x-www-form-urlencoded ``` **Body (form-encoded, NOT JSON — JSON returns 415):** ``` grant_type=client_credentials&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET} ``` **Critical:** - Content-Type MUST be `application/x-www-form-urlencoded` — NOT `application/json`. Sending JSON returns HTTP 415. - The `/login` endpoint does NOT support OAuth client credentials on SaaS instances — always use `/oauth/token`. - On success, write `.auth.json` with the token so all subsequent API calls just work. --- ## Build Lifecycle ``` 0. Memory file → create or read {use-case}/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-case}/use-case-memory.md` (`{use-case}` = `use-cases//` in the working folder): - **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 `assets/helpers/use-case-memory.md` template. Fill in Platform URL, `Stage: build`, `Status: active` immediately. **Step 13 — update memory file after every session:** Before closing out any build session, update `use-case-memory.md` with: - Any new asset IDs (project ID, workflow UUIDs, transformation IDs, adapter names) - Any architectural decisions made and **why** - Any gotchas hit and how they were fixed - Test results (date, what was tested, outcome) - Updated open items list - `Stage` and `Status` if they changed — mid-build, `Stage` stays `build`; only update it to `test` at the Step 15 handoff The memory file is what makes it possible to pick up a use-case after weeks without re-discovering everything from scratch. **Step 14 — how to update this skill:** - New platform behavior (error shape, field constraint, task gotcha) → add detail to the relevant body section (`### query`, `### childJob`, `### Projects`, etc.), then add a one-liner to the Gotchas pre-flight list under the right category. - New pattern or workflow recipe → add to `## Workflow Patterns` and, if the pattern is reusable, export the project from the platform and, in a clone of the builder-skills repo, save it to the shared library at `assets/helpers/assets/` (CI bundles it into this skill as `assets/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. --- ## Pending Integration Pattern Use this pattern when the solution design includes `⚠ Stub` integrations — adapters that are required but not yet installed on the platform, or whose connection details (hostname, auth method, credentials) are still being confirmed by the customer. **Goal:** deliver a fully buildable, testable project now. The customer sees real workflows and real progress. When the adapter arrives, swapping in the real task is a single targeted update. ### Stub Workflow (`stub-{integration-name}`) Build one stub workflow per pending integration. It exercises one core connectivity action — enough to prove the integration wires up correctly end-to-end when the adapter is provisioned. **Structure:** ``` workflow_start → buildPayload (newVariable — assembles minimum input for the core action) → callIntegration (newVariable placeholder — sets {integrationName}Status = "pending_adapter") ↓ error → workflow_end workflow_end ``` **Rules:** - `buildPayload` — `newVariable` task. Assembles the minimum required input per the `integration-model-{name}.json` (e.g., for Slack: `{channel, text}`). No adapter dependency — always runnable without a real adapter. - `callIntegration` — `newVariable` placeholder. Sets `{integrationName}Status = "pending_adapter"`. Use the hex task ID that the real adapter task will occupy. Error transition pre-wired to `workflow_end`. - One input variable: `dryRun` (boolean, default `true`). Stub ignores it; activated workflow can use it to skip side effects during testing. - Add all stub workflows to the same project as the main delivery workflows. **Choosing the core action:** - Prefer the simplest write/action that validates auth end-to-end (post a message, launch a job, write a secret) - If the integration is read-only in this use case, use a lightweight read (get current user, health check) - Derive the request payload from `integration-model-{name}.json` — that file is the contract ### Placeholder Task (in any workflow) When a workflow needs to call an adapter that isn't installed, replace the real adapter task with a `newVariable` placeholder — same position, same task ID, same transitions: 1. Use a `newVariable` task in the exact slot the real adapter task will occupy 2. Assign the same hex task ID the real task will use when activated 3. Set variable: `{integrationName}Status = "pending_adapter"` — machine-readable pending state 4. Wire all transitions identically (including the error transition) to how the real task will be wired ### As-Built: Activate Integration Section For every pending integration, include a dedicated section in `as-built.md`: ```markdown ## Activate: {Integration Name} When the {AdapterType} adapter is provisioned: 1. Confirm adapter instance name: jq '.results[] | select(.package_id | test("{name}";"i")) | {id,state}' adapters.json 2. Replace task `{taskId}` in workflow `{workflowId}` with: (complete replacement task JSON — all fields pre-filled from integration-model-{name}.json, app and locationType set to the adapter type name, incoming variables wired from the workflow) Only field left blank: `adapter_id`. Fill from step 1. 3. Verify exact field names from the live schema before activating: POST /automation-studio/multipleTaskDetails?dereferenceSchemas=true body: {"tasks": [{"name": "{taskName}", "app": "{appType}"}]} ``` This pattern works for both standard Itential adapters (EmailOpensource, Slack) and custom OpenAPI virtual integrations. The `integration-model-{name}.json` file is the contract in both cases. --- ## Guides **Building a Gateway4 → Gateway5 migration?** Follow `assets/helpers/gateway-migration/conversion-guide.md` for each item's pattern (inventory with broker actions, services imported through Gateway Manager, task-by-task rewire, re-pointing every consumer of a Gateway4 output), and import the migrated workflows as a new project — never edit the originals. ### Guide 1: Build a workflow end-to-end Follow these steps in order. Do not skip any step. --- > **Before writing task JSON, extract a real example from `assets/helpers/assets/` — do not invent task structure from memory.** (`assets/helpers/create/` is for API wrappers only — project/workflow creation endpoints, not task bodies.) > > ```bash > # 1. Find which asset project matches your use case > ls assets/helpers/assets/ > > # 2. Extract the workflow most similar to what you're building > jq '[.components[] | select(.type=="workflow")] | .[].document.name' \ > assets/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}' \ > assets/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' \ > assets/helpers/assets/vendor-servicenow.json > ``` > > Replace `assets/helpers/assets/vendor-servicenow.json` with whichever asset file best matches your use case: > - Adapter tasks (ServiceNow, Infoblox) → `assets/helpers/assets/vendor-servicenow.json`, `assets/helpers/assets/vendor-infoblox-nios-ddi.json` > - Network device tasks (CLI, MOP) → `assets/helpers/assets/vendor-cisco-ios.json`, `assets/helpers/assets/vendor-arista-eos.json`, `assets/helpers/assets/vendor-juniper-junos.json` > - IPAM/inventory → `assets/helpers/assets/vendor-netbox.json` > - Data transformations → `assets/helpers/assets/itential-platform-data-manipulation.json` > - Config management (RunCommandTemplate, itential_cli) → `assets/helpers/assets/itential-platform-configuration-management.json` > - LCM action workflows → `assets/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 **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. See the `### runCode` section under Utility Tasks below for the collapse story, field reference, and the exact `jq` commands to extract a verified real example before building any transform loop. **Read a full workflow from asset projects before building any multi-workflow solution:** ```bash # Parent → childJob → evaluation pattern jq '[.components[] | select(.type=="workflow") | select(.document.name | test("Upgrade|Runner"))] | first | .document | {name,tasks,transitions}' \ assets/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}' \ assets/helpers/assets/itential-platform-configuration-management.json ``` **Step 1: Find tasks.** Search `tasks.json` for the tasks you need: ```bash 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: ```bash jq '.[] | select(.name | test("keyword"; "i")) | {name, type}' {use-case}/apps.json ``` Also get the adapter instance name: ```bash 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 ``` ```json { "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: ```json { "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' assets/helpers/assets/vendor-servicenow.json`): ```json { "a1b2": { "name": "createChangeRequest", "canvasName": "createChangeRequest", "summary": "Create Change Ticket", "description": "Creates a ServiceNow change request", "location": "Adapter", "locationType": "Servicenow", "app": "Servicenow", "type": "automatic", "displayName": "ServiceNow", "variables": { "incoming": { "body": "$var.e1a1.merged_object", "adapter_id": "$var.job.adapter_id" }, "outgoing": { "result": null }, "error": "", "decorators": [] }, "groups": [], "actor": "Pronghorn", "scheduled": false, "nodeLocation": {"x": 700, "y": 600} } } ``` **Mapping rules:** - `name`, `canvasName` → from tasks.json - `app`, `locationType` → from apps.json (NOT tasks.json) - `displayName` → from tasks.json - `location` → `"Adapter"` or `"Application"` (from tasks.json) - `type` → from tasks.json directly — do not guess. It is per-task, not per-app. Read it alongside name, app, location, and canvasName: `jq '.[] | select(.name == "taskName") | {name, app, type, canvasName, location}' tasks.json` - `actor` → `"Pronghorn"` for all tasks except childJob (which uses `"job"`) - `incoming` → each schema key becomes a variable. Wire with `$var` for top-level values - `outgoing` → set to `null` (capture later with `$var.taskId.outVar`) - **Add `adapter_id`** to incoming for adapter tasks (not in schema, always required) - **Add `error` and `decorators`** to variables block **Step 5: Handle object inputs.** If a task's incoming variable is `type: "object"` (like `body`), you CANNOT put `$var` references inside it — they won't resolve. Use a `merge` task before it: ```json { "e1a1": { "name": "merge", "canvasName": "merge", "summary": "Build Request Body", "app": "WorkFlowEngine", "type": "operation", "variables": { "incoming": { "data_to_merge": [ {"key": "short_description", "value": {"task": "job", "variable": "short_description"}}, {"key": "description", "value": {"task": "job", "variable": "description"}} ] }, "outgoing": {"merged_object": null} }, "actor": "Pronghorn" } } ``` Then wire the adapter task's `body` to `"$var.e1a1.merged_object"`. **Step 6: Handle opaque schemas.** Some task schemas show `body: {type: "object"}` with no inner field details. The adapter validates internally. To discover required fields: 1. Try creating with minimal fields — the error message lists what's missing (e.g., `"must have required property 'summary'"`) 2. Check `openapi.json` for the adapter's endpoint schema 3. Call the adapter directly: `POST /{adapter_id}/{method}` with `{}` body — read the validation error **Step 7: Wire transitions.** Every adapter task needs BOTH success and error transitions: ```json "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.** - [ ] Every task has a non-empty `description` field — schema-required on every task, easy to forget, and `POST /workflow_engine/workflows/validate` will reject a document missing it - [ ] Task IDs are hex-only (`[0-9a-f]{1,4}`) — verify with `python3 -c "import json,re; d=json.load(open('workflow.json')); bad=[k for k in d['tasks'] if k not in ('workflow_start','workflow_end') and not re.match(r'^[0-9a-f]{1,4}$', k)]; print(bad or 'OK')"` rather than eyeballing the task list. A non-hex ID (e.g. `g007`, using a letter outside `a`–`f`) doesn't error at creation time — it causes intermittent, hard-to-diagnose `$var` resolution failures later (evaluation operands silently drilling into the wrong value, string references passing through as unresolved literal text) that look like unrelated wiring bugs. - [ ] `app` and `locationType` values come from apps.json `.name`, NOT tasks.json and NOT the adapter instance name (e.g., `EmailOpensource` not `email`) - [ ] `adapter_id` is the adapter **instance** name (e.g., `email`), NOT the type name - [ ] `adapter_id` values come from `adapters.json` `.results[].id` — NEVER from the spec's adapter identity table. The spec is a design document; `adapters.json` is the source of truth for the target environment. - [ ] `canvasName` values come from tasks.json `canvasName` field - [ ] Every adapter task has `adapter_id` in incoming - [ ] Every adapter task has an error transition — `/workflow_engine/workflows/validate` does not check for this; missing error transitions pass validation and hang the job at runtime - [ ] `evaluation` tasks have both success AND failure transitions — `/workflow_engine/workflows/validate` does not check for this either - [ ] `evaluation` operators are from the closed enum (`contains, !contains, <, <=, >, >=, ==, !=`) — no others exist. `/workflow_engine/workflows/validate` does not enforce this enum on a workflow's embedded `evaluation` tasks; an invalid operator passes validation and fails silently at runtime (`finish_state: failure`, no error message) - [ ] `evaluation` `operand_2` literal values containing regex metacharacters (`.`, `(`, `)`, `[`, `]`, `?`, `+`, `*`, `|`) are properly escaped, OR stored in a `newVariable` constant-holder task to avoid `incomingRefs` cache issues after API PUT - [ ] No `$var..` references inside nested forEach bodies — use `$var.job.` instead - [ ] Incoming variable types match task schema exactly (arrays for `to`/`cc`/`bcc`, numbers for `page`/`pageSize`, etc.) - [ ] No `$var` references inside nested objects (use merge/makeData) - [ ] merge uses `"variable"`, childJob uses `"value"` - [ ] No `{task:"job", variable:"x"}` in merge/childJob for workflow-internal variables — `{task:"job"}` refs add `x` to `inputSchema.required`, prompting operators for values that should be internal. Use the producing task ref instead (query→`return_data`, newVariable→`value`, makeData→`output`, merge→`merged_object`) - [ ] If a `query` downstream of a `childJob` returns null despite the child succeeding: `"obj": "$var..job_details"` is a stale `incomingRefs` entry (not a platform-version issue). Fix: insert a `merge` task between childJob and query using `{"task": "", "variable": "job_details"}` in `data_to_merge`, then point `obj` to `$var..merged_object` (see Guide 4) - [ ] childJob has `actor: "job"`, all others have `actor: "Pronghorn"` - [ ] `workflow_end` transition is empty `{}` - [ ] Canvas layout follows the vertical spacing convention — non-forked sequences on a constant-x spine, fork branches offset to `spine±264` and stay in their own column until convergence - [ ] No transition lines cross task nodes (the spine column is empty between a fork and its convergence point) - [ ] Sequential y-delta ~108px (tight grid) - [ ] **LCM Create actions only:** the instance-write merge task's `data_to_merge` covers every field in the resource model's `schema.required` array — missing even one field causes an instance write failure after provisioning (resources are orphaned from LCM). Read the model's `schema.required` before building the merge task: `jq '.schema.required' assets/helpers/assets/lcm/.json` - [ ] **ViewData manual tasks:** `type` MUST be `"manual"` — `view` is a top-level field; `incoming.variables` is present (even if `{}`); `displayName: "Tools"`, no `actor` field (manual tasks never take one). A wrong `type` on a manual-view task can crash `POST /workflow_engine/workflows/validate` (HTTP 500) or produce a contradictory `actor`-required error on a task that never needs one — fix `type` first, don't chase either symptom. - [ ] **restCall downstream query:** path targets body field directly (e.g., `"access_token"`) — NOT `"response.access_token"` (restCall has no wrapper, unlike adapter tasks) - [ ] **childJob loop:** if child workflow has `inputSchema.required` fields beyond what each `data_array` element contains, use the forEach enrichment pattern (forEach → merge → arrayPush) to add shared fields into each element before the childJob loop; set `variables: {}` on the childJob - [ ] **forEach body:** `incoming` contains ONLY `data_array` (no `job_id`); loop body tasks have no external error transitions; last body task has an empty `{}` transition; `$var.job.` inside loop body instead of `$var..` - [ ] **makeData with childJob-sourced merge:** if a merge task references a childJob variable, do NOT wire that merge's `merged_object` into `makeData.incoming.variables` — use `query` to extract individual values first - [ ] **`objectToString`:** omit `replacer`/`space` from `incoming` if unused — do NOT pass `replacer: []` (silently whitelists zero properties, output is always `{}`) or `null` (validate-time type warning) **Complete working example:** Read the ServiceNow "Create Change Request" workflow before building — it demonstrates merge → adapter create → query → adapter update with error transitions: ```bash jq '[.components[] | select(.type=="workflow") | select(.document.name | test("Create Change"))] | first | .document' \ assets/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 `GET /operations-manager/jobs/{jobId}` → check `data.status`. If `"error"`, read `data.error[]` (`.task` = failing task ID, `.message.IAPerror.displayString` = human-readable error). See the full symptom → cause → fix table under **Debug Failed Jobs** (Testing & Debugging section below) — it's the canonical, most complete version. ### Guide 2b: Work with any unfamiliar adapter task Follow Guide 1 Steps 1-6 for discovery. Quick reference for the lookup commands: ```bash # 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": "", "method": ""}]} ``` 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": "user@example.com"` → WRONG. Use `"to": ["user@example.com"]` - `"pageSize": "100"` → WRONG if schema says number. Use `"pageSize": 100` - `"cc": ""` → OK only if schema allows string; if array, use `"cc": []` Always check `task-schemas.json` for the exact type of each incoming field before wiring. **Inspect the actual response before wiring a query path.** Adapter responses are transformed — they do not match the native API's structure. After a successful test run: 1. `GET /operations-manager/jobs/{jobId}` — find the task in `data.tasks` by its task ID 2. Read the task's outgoing variables — that is the real response object 3. Use `jq` to explore: `jq '.data.tasks["a1b2"]' job.json` 4. Wire the `query` path from what you see — not from the upstream API docs **End-to-end sequence:** ``` 1. tasks.json search → found "getDevice", app "networkAdapter" 2. apps.json lookup → correct app name is "NetworkAdapter" (capital N) 3. adapters.json → adapter_id is "network-prod-1" 4. multipleTaskDetails → incoming: {deviceId: string}, outgoing: {result: object} 5. Build + test → job completes 6. Inspect job → result is {"response": {"hostname": "...", "model": "..."}} 7. Wire query path → "response.hostname" (NOT "result.hostname" or "data.hostname") ``` ### Guide 3: Add a task to an existing workflow **Step 1:** Extract the task structure from an asset project that uses the same task type: ```bash # Adapter task (e.g., ServiceNow) jq '[.components[].document.tasks // {} | to_entries[] | select(.value.location == "Adapter")] | first | .value' \ assets/helpers/assets/vendor-servicenow.json # Application task (WorkFlowEngine) jq '[.components[].document.tasks // {} | to_entries[] | select(.value.app == "WorkFlowEngine" and .value.name != "childJob")] | first | .value' \ assets/helpers/assets/itential-platform-configuration-management.json # childJob jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "childJob")] | first | .value' \ assets/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:** ```json { "a1a1": { "name": "childJob", "canvasName": "childJob", "summary": "Run Single Child", "location": "Application", "locationType": null, "app": "WorkFlowEngine", "type": "operation", "displayName": "WorkFlowEngine", "variables": { "incoming": { "task": "", "workflow": "My Child Workflow", "variables": { "deviceName": {"task": "job", "value": "targetDevice"}, "action": {"task": "static", "value": "validate"} }, "data_array": "", "transformation": "", "loopType": "" }, "outgoing": {"job_details": null} }, "actor": "job" } } ``` **Variable passing rules (uses `"value"`, NOT `"variable"`):** - `{"task": "job", "value": "targetDevice"}` → passes the parent's `targetDevice` job variable to the child as `deviceName` - `{"task": "static", "value": "validate"}` → passes the literal string `"validate"` - `{"task": "b2c3", "value": "return_data"}` → passes a previous task's output (preferred for runtime data) > **WARNING — `{task:"job"}` refs in childJob variables add fields to `inputSchema.required`** — same behavior as merge (see `### merge` section). Only use `{task:"job", value:"x"}` for genuine workflow inputs. For runtime data produced by earlier tasks, use `{task:"", value:""}` to reference the producing task directly. > **WRONG for task output refs in childJob:** > `{"task": "b2c3", "variable": "return_data"}` — `"variable"` is for merge/evaluation only. > In childJob, ALL refs (job, static, AND task output) use `"value"`. Using `"variable"` causes `undefined.indexOf()` at job start time (P6.4.0+) — the workflow fails before any task runs. **Extracting single child output:** ```json { "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` is a stale `incomingRefs` entry (see the `incomingRefs` table under [$var Resolution Rules](#var-resolution-rules) — this is a caching issue, not a platform-version issue). Use the merge+taskRef workaround: ``` a1a1 (childJob) → m1m1 (merge: captures job_details via taskRef) → b2b2 (query: reads merged_object) ``` ```json { "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:** ```json { "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: ```json { "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: ```json { "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: {}) ``` ```json // 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): ```json [ {"status": "complete", "childJobLoopIndex": 0, "deviceName": "IOS-CAT8KV-1", "action": "backup", "taskStatus": "success"}, {"status": "complete", "childJobLoopIndex": 1, "deviceName": "IOS-CAT8KV-2", "action": "check", "taskStatus": "success"}, {"status": "complete", "childJobLoopIndex": 2, "deviceName": "EOS-AWS-1", "action": "backup", "taskStatus": "success"} ] ``` Use `"[**].taskStatus"` in a query to extract one field from all iterations. #### childJob checklist - [ ] `actor` is `"job"` (NOT `"Pronghorn"`) - [ ] `task` is `""` (empty string) - [ ] `job_details` outgoing is `null` - [ ] All incoming fields present — even unused ones: `"data_array": ""`, `"transformation": ""`, `"loopType": ""` - [ ] Variables use `{"task","value"}` NOT `$var` (single mode) - [ ] `variables` is `{}` when using `data_array` (loop mode) - [ ] Child workflow's `inputSchema.required` matches what you're passing - [ ] `loopType`: `""` (single), `"parallel"` (simultaneous), `"sequential"` (one at a time) - [ ] If a downstream `query` of a childJob returns null: `"obj": "$var..job_details"` is a stale `incomingRefs` entry, not a platform-version issue — use merge+taskRef workaround (see "Extracting single child output" above) #### Building the child workflow The child workflow must: 1. Accept inputs via `inputSchema` that match what the parent passes 2. Set output variables via `newVariable` or task outgoing → `$var.job.x` 3. Handle errors internally (try-catch pattern) so it always completes: ``` task --success--> newVariable("taskStatus" = "success") -> workflow_end task --error--> newVariable("taskStatus" = "error") -> workflow_end ``` The parent can then check `taskStatus` from `job_details` to decide what to do. --- ## Projects | 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) **Before writing the first line of a project/workflow import payload, open `assets/helpers/create/import-project.json` and start from that scaffold.** Do not build the `{project: {components: [...]}}` shape from scratch in memory, even if you're confident in it — the wrapper shape (project-level vs. component-level metadata, which fields need `_id` vs. which don't, the exact `created_by` shape difference between project and workflow) is easy to get subtly wrong in ways that produce contradictory-looking "additional properties" / "required property" errors that seem like they're about unrelated fields (e.g., `groups`). **If the platform's import mechanism you're using is a file-upload UI dialog (not a REST call you make directly), the payload shape may differ from the `POST /automation-studio/projects/import` API documented below.** Always call the documented API endpoint yourself via `curl`/HTTP client rather than asking a human to paste/upload a file through a UI — you have the credentials and the ability to make the call directly; routing through a human as a manual courier for a schema you haven't verified multiplies the round-trip cost of every guess. If a UI-only import path is genuinely the only option available, treat its error messages with the same Repeat-Failure Circuit Breaker discipline as any other validation error — don't iterate blindly. **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. **⚠️ Data-loss warning: importing against an EXISTING project `_id` replaces that project's entire components array — it does not merge or add to it.** If you're reusing a helper asset (e.g., cloning `assets/helpers/assets/itential-platform-email.json`) to add a NEW capability to a project that already has other components in it, and you set the payload's `_id` to that existing project's ID, every component not listed in your payload will be silently deleted — including workflows built and verified in a prior session. **Before every import against an existing project `_id`: run `GET /automation-studio/projects/{id}` first, and merge its current `components` array into your import payload** alongside whatever you're adding — do not assume the call is additive just because you're only trying to add one new thing. ``` POST /automation-studio/projects/import ``` **Build all assets locally first, then import everything at once:** ```json { "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 | > **NEVER feed a `GET /automation-studio/workflows/detailed/{name}` read-back document into `projects/import` unmodified — it crashes the import and, against an EXISTING project `_id`, wipes its components.** The platform mutates workflow documents after import/save: `created_by`/`last_updated_by` become plain account-ID strings (import requires the object shape above), `outputSchema` is expanded with platform-generated fields, and `encodingVersion`/`_id`/`errors`/`warnings`/`namespace` appear. Importing such a document fails every component with `"WorkflowBuilder stopped during execution"` — and because a re-import is a destructive full-replace, the project is left with `components: []` (confirmed data loss, recovered only by re-importing the ORIGINAL known-good documents). To re-import over an existing project: reuse the original import documents, or normalize the read-back first (restore object-shaped `created_by`/`last_updated_by`, strip `encodingVersion`, `_id`, `errors`, `warnings`, `namespace`, and strip the `@projectId: ` name prefix). Response: ```json { "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 | **If a project reference you were relying on turns out to be missing or stale** (e.g., `GET /automation-studio/projects/{id}` returns `"Project not found"` for a project you thought you'd already created), **do not respond by creating a brand-new project and moving a pre-existing standalone workflow into it via `components/add`.** That's the create-then-move pattern above, just arrived at while recovering from an unrelated error instead of choosing it deliberately — it's still the discouraged pattern. Rebuild the intended end state atomically via `projects/import` instead, even if that means recreating a workflow that technically already exists standalone elsewhere. ### 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 ``` ```json { "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. **A bare `{type, reference}` component does NOT embed the workflow's tasks/transitions — it stores a pointer to a separately-existing workflow document.** If you fetch the project afterward and the component has no `document` field (or an empty one), this is why — not a save failure. This is the most common reason `components/add` looks like it silently failed, and it's the root cause of the "create-then-move" anti-pattern above: reaching for `components/add` expecting it to carry the full document, discovering it didn't, deleting and retrying. If you need the full document inside the project, use atomic `projects/import` with the document nested under each component from the start instead — don't retry `components/add` variations expecting different behavior. **Component types:** `workflow`, `template`, `transformation`, `jsonForm`, `mopCommandTemplate`, `mopAnalyticTemplate` > **`components/add` called more than once on the same project can corrupt `iid` tracking.** Confirmed on a project created via empty `POST /automation-studio/projects` (not `import`): a first `components/add` call (e.g. 5 components) gets assigned sequential `iid`s, but the project's own `componentIidIndex` can come back reporting a value *lower* than the actual max `iid` just used. A second `components/add` call trusts that stale index as its starting point and assigns new `iid`s that collide with ones from the first call — two different components end up sharing the same `iid`, silently corrupting anything that references components by `iid` (notably `folders[].children[].iid` groupings). > > **Diagnostic sign:** after adding components in more than one call, `GET` the project and check `components[].iid` for duplicates across different `type`s. > > **`PATCH` does not fix this.** Sending a corrected `components`/`folders` array with de-duplicated, hand-assigned `iid`s via `PATCH /automation-studio/projects/{projectId}` returns `200`, but the platform recalculates/discards your custom `iid` values server-side — a `GET` right after still shows the same collision. Don't try to patch your way out of it. > > **Fix — pick one:** (a) batch every component you're adding into a **single** `components/add` call (confirmed reliable — produces clean sequential `iid`s), or (b) rebuild the project from scratch via `projects/import`, embedding each component's full document directly rather than moving in pre-existing standalone assets. ### Update membership (full replacement) **Before patching, always ask the engineer:** *"Who else should have access to this project? (usernames or group names)"* Do not auto-discover or assume groups. Wait for the answer, resolve each name to a reference ID by scanning existing projects, then PATCH. ``` PATCH /automation-studio/projects/{projectId} ``` Use the helper: `assets/helpers/update/update-project-members.json` Include ALL members in every PATCH — this is a full replacement. Omitting an existing member removes them. **If regular `PATCH /automation-studio/projects/{projectId}` returns 403 for the service account (no GBAC role on the project yet), the bypass endpoint `PATCH /automation-studio/admin/projects/{projectId}` will succeed — but it silently drops any component not listed in the SAME request body**, even when the request only intends to change `members`. Confirmed live: PATCHing `{"members": [...]}` alone (no `components` key at all) removed a real `mopCommandTemplate` component from the project — the underlying asset was deleted outright, not just unlinked, and had to be recreated from a backup. Always `GET` the project immediately after any admin-endpoint PATCH and diff the `components` array against what it was before; if anything is missing, recreate it (via the asset's own create endpoint, e.g. `POST /mop/createTemplate`) and re-link with `components/add`. **To resolve a username or group name to a reference ID**, scan existing projects: ```bash 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 > **Your very next tool call after a successful `projects/import` must be the membership PATCH below — before verifying the import, before building the next component, before anything else.** Import sets the OAuth service account as sole project owner, not the UI user from the spec. If your next action isn't this PATCH, you've skipped it — reading this warning is not the same as having acted on it. 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., `joksan.flores@itential.com`) 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: ```bash # 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`: ```bash grep "joksan.flores@itential.com" {use-case}/membership-lookup.txt # → account 699a67bb... joksan.flores@itential.com CloudAAA ``` **Step 3: Cache the result — don't re-scan for the same person next time.** Append resolved `{username: accountId}` pairs to `use-case-memory.md` (or a shared `membership-lookup.txt` if working across multiple use-cases). Re-scanning every project from scratch each time the same engineer needs to be added as owner on a new use-case is a wasted 50-100+ API calls when the answer was already resolved once. If `use-case-memory.md` doesn't exist yet for this engagement (e.g., in freestyle/explore-mode work with no formal spec), create it anyway — see the Directory Layout section's "living context" note. **Step 3: PATCH membership immediately after import.** ``` PATCH /automation-studio/projects/{projectId} ``` ```json { "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](https://github.com/itential/builder-skills/issues/63) ### Project Thumbnail | Operation | Endpoint | |-----------|----------| | Set | `PUT /automation-studio/projects/{id}/thumbnail` — body: `{"imageData": "", "backgroundColor": ""}` | | Get | `GET /automation-studio/projects/{id}/thumbnail` — returns `{"data": {"image": "", "backgroundColor": ""}}` | **`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: ```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 `assets/helpers/`: - `assets/helpers/create/create-json-form.json` — static-enum dropdowns - `assets/helpers/create/create-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: `assets/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: ```bash 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. ```bash # Does any asset project use this task? Find it by task name: grep -rl '"name": "TASK_NAME"' assets/helpers/assets/ # Extract the wired task from the matching project: jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "TASK_NAME")] | first | .value' \ assets/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}' \ assets/helpers/assets/MATCHING_FILE.json ``` Asset project → best match by task type: | Task | Best asset to check | |------|-------------------| | ServiceNow adapter tasks | `assets/helpers/assets/vendor-servicenow.json` | | Infoblox / DNS / IPAM tasks | `assets/helpers/assets/vendor-infoblox-nios-ddi.json` | | NetBox tasks | `assets/helpers/assets/vendor-netbox.json` | | itential_cli, RunCommandTemplate, MOP tasks | `assets/helpers/assets/itential-platform-configuration-management.json`, `assets/helpers/assets/vendor-cisco-ios.json` | | transformation (JST) | `assets/helpers/assets/vendor-netbox.json`, `assets/helpers/assets/itential-platform-data-manipulation.json` | | childJob, evaluation, query, newVariable | any vendor project | | LCM action workflow tasks | `assets/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 ``` ```json { "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:** 1. Search asset projects (above) — if found, use the wired example directly 2. Check if `{use-case}/task-schemas.json` exists — search it next 3. Only call `multipleTaskDetails` for tasks not found in either place 4. After fetching, append to `{use-case}/task-schemas.json` **Adapter list/search tasks (e.g. `ipam_prefixes_list`, `ipam_ip_addresses_list`) require every declared query parameter present in `incoming`, even the ones you don't care about — as an empty string.** Wiring only the parameters you actually want to set (e.g. just `adapter_id` and one filter) is enough to pass `workflows/validate`, but crashes `jobs/start` with the same opaque, task-unattributed `"Cannot convert undefined or null to object"` covered above. Copy the FULL `incoming` block from a real wired example in the asset projects rather than hand-picking fields, and only overwrite the specific ones you need. **Check each parameter's declared `type` before assigning it a plain string — some are `array`-typed even when sibling parameters with similar names are plain strings.** Confirmed with NetBox's `ipam_ip_addresses_list`: nearly every filter parameter is declared `type: array` in the dereferenced schema (via `multipleTaskDetails?dereferenceSchemas=true`), including ones like `parent` that read like a single-value filter. Passing a plain string (`"parent": "100.64.1.0/24"`) for an array-typed parameter fails at `jobs/start` with a swagger-client error, `"Could not parse parameter value string as JSON Object or JSON Array"` — pass a real JSON array instead (`"parent": ["100.64.1.0/24"]`). **A `_bulk_destroy`-style adapter task that issues `DELETE` with a request body will fail at the gateway/proxy layer, not the adapter or the target API** — confirmed with NetBox's `ipam_ip_addresses_bulk_destroy` (`requestBodyPayload` on a `DELETE`): the call never reaches NetBox, failing instead with `"error receiving task event: rpc error: ... invalid spec: HTTP method HTTP_METHOD_DELETE does not support request body"`. This is a hard gateway-proxy limitation, not something fixable via task configuration — for gateway-routed adapters, avoid any `DELETE`-with-body bulk operation and loop single-item `_destroy` calls instead (see `### forEach` above for the loop pattern; `state: "loop"`, not `childJob`, since the target of the loop body here is an adapter task rather than another workflow). ### 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 - 264` and the other at `spine + 264`. The spine column stays empty between the fork and the convergence point so transition lines don't cross task nodes. Direction (which branch goes left vs. right) is the engineer's call — pick whatever keeps the picture clean. - **Branches stay in their own column** until they converge. - **Convergence tasks** (workflow_end, merges, error sinks) return to the spine `x`. - **Tight y-spacing** — the canvas grid is dense; ~108px between sequential rows reads well. Don't pad to +250 or +360. - **Preserve Studio-arranged positions** — if an engineer has arranged a workflow in Automation Studio, treat its `nodeLocation` values as authoritative. Always read from the live export before reimporting. Never recalculate positions from scratch on a workflow that has already been arranged. Example — fork with a shared error handler (same pattern as ServiceNow "Create Change Request" in `assets/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. #### Verifying Layout After Build — Don't Just Eyeball the Checklist The spine/fork/convergence convention above is easy to violate without noticing, because each task's `nodeLocation` is usually set incrementally as it's created — relative to whatever was placed immediately before it, not to the graph as a whole. A workflow can be 100% correctly wired (every transition right, every `$var` resolving) and still render with failure/error-branch tasks scattered at inconsistent x-offsets and heights, each needing a long diagonal line back to wherever the workflow actually ends. That's a real, observed failure mode — not hypothetical — and the checklist bullets above only catch it if you deliberately re-read every `nodeLocation` against the convention, which is easy to skip once the workflow otherwise looks "done." Verify mechanically instead of by eye. After wiring all tasks and transitions, before calling Build complete, run something like this against the built workflow's `tasks`/`transitions`: ```python import json wf = json.load(open("workflow.json")) # or wf["items"][0] from a GET tasks, transitions = wf["tasks"], wf["transitions"] xs = [t["nodeLocation"]["x"] for tid, t in tasks.items() if tid not in ("workflow_start", "workflow_end")] spine = max(set(xs), key=xs.count) # most common x = the spine violations = [] for tid, t in tasks.items(): if tid in ("workflow_start", "workflow_end"): continue x = t["nodeLocation"]["x"] if x != spine and abs(x - spine) != 264: violations.append(f"{tid} ({t.get('summary') or t.get('name')}): x={x}, not spine ({spine}) or spine±264") # Flag any task whose incoming transitions come from tasks with wildly different y — # a large y-gap into a task usually means an earlier removal/reorder left a stale position. # Skip revert transitions entirely: they're supposed to go backward (retry loops), so a # negative y-delta there is correct wiring, not a layout violation. for src, dsts in transitions.items(): if src not in tasks: continue for dst, edge in dsts.items(): if dst not in tasks or edge.get("type") == "revert": continue dy = tasks[dst]["nodeLocation"]["y"] - tasks[src]["nodeLocation"]["y"] if dy < 0 or dy > 200: violations.append(f"{src} -> {dst}: y-delta={dy} (expect ~108, or a deliberate fork/convergence jump)") if violations: print("Layout violations found — fix nodeLocation before considering Build done:") for v in violations: print(" -", v) else: print("Layout OK: spine =", spine) ``` Re-run this after every PUT that adds, removes, or rewires a task — not just once at the end. Removing a task (e.g. an error-handling branch that got redesigned out) is a common way to leave a gap that stretches every task after it, which this catches immediately instead of leaving it for the engineer to notice on the canvas later. --- ## Workflows ### Workflow Structure ``` POST /automation-studio/automations ``` Body wraps the workflow in `{"automation": {...}}`: ```json { "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} ``` ```json {"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 `@: ` prefix — even if the workflow was created without it: ```json {"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](https://github.com/itential/builder-skills/issues/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`. | ```json { "name": "ViewData", "type": "manual", "app": "WorkFlowEngine", "view": "/workflow_engine/task/ViewData", ... "groups": ["69e65b4189b39131a9b8cce1"] } ``` **Look up group IDs:** - `GET /authorization/groups` — list groups (each has `_id` and `name`) - `GET /authorization/groups/` — resolve a single group **Two GBAC scopes** — both use the same `string[]` shape (group `_id`s) but apply at different levels: - **Per-task `groups`** (on the task definition, sibling of `name`/`app`/`type`) — gates access to a single manual task. - **Top-level workflow `groups`** (sibling of `tasks`/`transitions` at the workflow level) — gates access to the workflow as a whole. **Tasks of any type can carry `groups`**, but only `type: "manual"` tasks surface in the Job Inbox where GBAC actually gates user access. Leave it as `[]` on automatic tasks unless platform-specific docs say otherwise. > **Edge cases not yet documented** — verify on your platform before relying on: > - Semantics with **multiple group IDs** in the array (likely OR — any-of — but unverified) > - Interaction between **task-level and workflow-level** `groups` (additive vs. override) > - Whether `groups` accepts a **`$var` job-variable** for dynamic group resolution (almost certainly no — design-time only — but worth confirming) ### Task IDs Task IDs must be **hex-only**: `[0-9a-f]{1,4}`, plus the two reserved names `workflow_start` and `workflow_end`. Non-hex IDs (e.g., `apush`) cause `$var` references to silently fail. **This includes the start/end tasks themselves** — do not key them as `"start"`/`"end"` (a natural-sounding but invalid shortcut); the reserved keys are the literal strings `workflow_start` and `workflow_end`, not any descriptive substitute. Using `"start"`/`"end"` (or any other non-hex, non-reserved key) produces a generic `"must NOT have additional properties"` import error that looks unrelated to task naming — if you see that error and your task keys include anything other than hex IDs or the two reserved names, fix the keys first before investigating any other field. ### Transitions ```json "transitions": { "workflow_start": { "a1b2": {"type": "standard", "state": "success"} }, "a1b2": { "c3d4": {"type": "standard", "state": "success"}, "err1": {"type": "standard", "state": "error"} }, "c3d4": { "workflow_end": {"type": "standard", "state": "success"} }, "err1": { "workflow_end": {"type": "standard", "state": "success"} }, "workflow_end": {} } ``` **Transition states:** - `success` — task completed without error (all tasks) - `error` — task encountered errors (all tasks) - `failure` — evaluation didn't match or query returned undefined (evaluation/query only) - `loop` — forEach loop iteration (forEach only) **Transition types:** - `standard` — moves forward - `revert` — moves backward to a previous task (retry loops) **MANDATORY: Every adapter/external task needs an error transition.** Without one, errors cause "Job has no available transitions" and the job gets stuck forever. `POST /workflow_engine/workflows/validate` does not check for this — it only flags a task missing a *success* path, never a missing *error* path. A workflow with no error transitions anywhere can still return `isValid: true`. Do not skip this check because validate passed. **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`: ```json "transitions": { "a1b2": { "c3d4": {"type": "standard", "state": "success"}, "err1": {"type": "standard", "state": "error"} }, "err1": { "workflow_end": {"type": "standard", "state": "success"} } } ``` ### Create Response Shape Both workflow and template creation return `{created, edit}` — NOT `{message, data, metadata}`: ```json { "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..` 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 | **`POST /workflow_builder/workflows/save`-created workflows are missing structural fields that `jobs/start` requires but `workflows/validate` doesn't check.** A workflow document built with only the fields shown in the `### Workflow Structure` example above (tasks, transitions, inputSchema, etc.) — omitting `scenarios`, `errors`, `warnings`, and `canvasVersion` — validates cleanly (`errors: []`) and displays correctly in Studio, but `jobs/start` crashes with an opaque, task-unattributed `"Cannot convert undefined or null to object"` (confirmed root cause: `Object.keys(undefined)` on one of these fields server-side). Always include `"scenarios": []`, `"errors": []`, `"warnings": []`, and `"canvasVersion": 3` explicitly when building a workflow document for this endpoint from scratch — the officially-documented `POST /automation-studio/automations` creation flow already includes `canvasVersion` in its example body, so prefer that endpoint when creating a new workflow; reach for `workflow_builder/workflows/save` only for updates to an existing (already-complete) document. **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 /workflow_engine/workflows/validate`** (6.5.2+) — deep validation before create or update; body `{"asset": {...}}`, returns `{isValid, errors: [], warnings: []}`. `isValid` is `errors.length === 0` — warnings don't count against it, so always check both. See the pre-flight section under "Updating Assets" below for exactly what this endpoint does and doesn't catch. Use this instead of the older `POST /automation-studio/workflows/validate` (`{"workflow": {...}}` → `{errors, warnings}`, no `isValid`), which misses errors this one catches. **Nested `$var` references do not resolve, except for one narrow, named exception list.** The engine only substitutes a reference when an incoming value is *entirely* a `$var...` string — it does not walk into nested objects/arrays looking for one to substitute (Key Rule 8 in `AGENTS.md`). The deep validator (above) warns when it finds one. Exceptions — these methods resolve a reference sitting directly on one specific nested key's values (nothing deeper than one level): | App | Method | Nested key that's resolved | |---|---|---| | `GatewayManager` | `runService` | `params` | | `GatewayManager` | `runServiceStatic` | `params` | | `GatewayManager` | `runCode` | `data` | | `AgentSessionManager` | `runAgent` | `inputs` | | `WorkFlowEngine` | `transformation` | `variableMap` | Everywhere else — including plain adapter `body` fields — a `$var` inside an object or array is passed through as a literal string and never substituted; build the object with `merge`/`makeData`/`query` first instead. --- ## Utility Tasks (WorkFlowEngine) These are built-in tasks that require no adapter. They handle data manipulation and control flow. **Default for any per-item transform, lookup, or branching loop: `runCode` + Enable Query below — not a chain of the individual utility tasks that follow.** The individual tasks (`query`, `merge`, `evaluation`, `forEach`, `newVariable`, `makeData`, `push`/`pop`/`shift`, `deepmerge`, `transformation`, `decision`, and the rest) are secondary: use one of them alone for a single non-looping operation, or when the per-item work must call another platform task (adapter, app method, childJob) per item — `runCode` can't do that, it only runs Python against the `data` it's given. **Reshaping data: `runCode`, not a JST transformation.** Put the logic in a `runCode` task on the canvas — readable Python, testable on its own with JSON on stdin — and have it return everything downstream needs in one result; consumers read its fields with Enable Query (`$var..result#/stdout_json/`). The cost: `runCode` runs on a Gateway5 cluster, so the workflow needs a `clusterId` input; a transformation runs on the platform. Worked examples: "Process Push Configuration Data" in the push-config assets and the "Standard Output" tasks in `vendor-arista-eos.json`'s Command Template Runner_v2. ### runCode (GatewayManager) — real Python instead of chaining WorkFlowEngine utility tasks **Before wiring a `runCode` task, read this section and `assets/helpers/assets/runcode-taskquery-reference.json` in full.** Do not construct the task shape from a live job's error trace or from memory — `runCode` is a `GatewayManager` **automatic** task (not a `WorkFlowEngine` operation task, an easy but costly mix-up), and its exact field names (`clusterId`, `language`, `code`, `data`, `safety.timeout`, `packages`) are documented in the table below. Getting this wrong produces confusing "additional properties"/"required property" validation errors that look like a project-import problem when the real cause is simply the wrong `app`/`type` on this one task. `runCode` ships arbitrary Python to a Gateway5 cluster for execution — no pre-configured IAG service needed, unlike `runService`. It is the single biggest lever for collapsing a `forEach` + `query`×N + `evaluation` + `merge`×N + `push`/`objectToString`/`join` chain (the kind of per-item transform loop that's the source of most WorkFlowEngine utility-task gotchas in this file) into 1-2 tasks. **Prerequisites (per official docs):** - Install Gateway Manager 1.0.10 or later with Platform 6.4.0 or later. - Register an active Gateway 5.4.0 or later cluster with Itential Platform using Gateway Manager. - Verify that the Python version on the Gateway 5 host is compatible with your scripts and packages. The task uses the default Python interpreter on the host, which is the version invoked by `python` or `python3` on the `iagctl` command line. Gateway does not enforce a minimum version — run `python --version` on the Gateway 5 host to check. - Confirm that the `gateway:code` role from GatewayManager is assigned to your group. **When to reach for it:** any per-item data transform/lookup/branching loop over a list — the exact shape that otherwise needs `forEach` (with its `job_id`-omission and empty-last-transition rules), `query` per field, `evaluation` for branching, `merge` for reassembly, and (for arrays of objects) the `objectToString`/`push`/`join` dance. One `runCode` task does the whole loop in real Python in a single execution, then one `query` pulls the result back into a job variable. **Real-world example:** a NetBox-devices-to-inventory-nodes mapping (per-device manufacturer lookup, per-vendor secret-path branching, record reshaping) that originally took `forEach` + 5 `query` + 1 `evaluation` + 4 `newVariable` + 2 `merge` + `push`/`objectToString`/`join`/`makeData` (17 tasks) collapsed to 2 tasks (`runCode` + `query`), then to 1 task once the trailing `query` was replaced by an Enable Query decorator too (see below) — with identical, verified output at every stage. Full before/after: `assets/helpers/assets/netbox-inventory-sync-runcode-enablequery.json`. **Read the real, verified example before building any transform loop:** ```bash # 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' \ assets/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' \ assets/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}' \ assets/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 `assets/helpers/assets/vendor-cisco-ios.json` and `assets/helpers/assets/vendor-juniper-junos.json`):** ```python 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. **This exact boilerplate (`import sys, json` / `data = json.load(sys.stdin)`) is required in every `runCode` script — the incoming `data` field is NOT injected as a pre-parsed variable, it must be read from stdin.** A script that references `data.get(...)` without first loading it from stdin fails at runtime with `NameError: name 'data' is not defined`. If you've already written one correct `runCode` script in this session, copy its stdin-reading boilerplate verbatim into every subsequent `runCode` task rather than re-deriving the input-handling logic from assumptions each time — a model that gets this right once and then writes a different, incorrect version for the next `runCode` task has not actually learned the contract. **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"`). **This is exactly why a job showing `workflow_end: complete` is not proof the workflow worked (see `assets/AGENTS.md` Rule 28) — an in-script exception in `runCode` does NOT trigger the task's error transition,** so the workflow sails through to a "successful" completion while `result.status` is `"error"` and `result.stderr` holds a traceback. Always check `result.status`/`result.stderr` on every `runCode` task in a job before reporting the run as successful. **Execution status vs. exit code (per official docs) — three genuinely different failure modes, don't conflate them:** - `status: "completed"` + `return_code: 0` — the script ran and exited cleanly. - `status: "completed"` + a non-zero `return_code` — the script ran but exited with a failure code; your code executed, the script itself signaled failure. - `status: "error"` — a platform-level failure (e.g. a timeout). The script may not have run, or it stopped mid-execution; check `result.error`. This is distinct from a Gateway connectivity failure (see above), where the task itself errors and no `result` fields are populated at all. **`stdout_json` absent after a successful run (per official docs):** if `result.stdout_json` doesn't appear after `return_code: 0`, the most common causes are (a) `stdout` contained non-JSON content, a mix of JSON and other output, or no output, or (b) a value in the output object wasn't JSON-serializable, so `json.dumps()` raised a `TypeError` and wrote a traceback to `stderr` instead of JSON to `stdout`. Inspect `result.stdout` and `result.stderr` directly to tell which applies. **Gotcha:** secret-vault placeholder strings (`$SECRET_.../$KEY_...`) written as literal Python string constants inside `code` are NOT resolved by `runCode` or by the workflow engine — they pass through as plain text, identical to writing the same literal into a `newVariable` task. That's expected; the vault resolves them later, at actual device-connection time, not during this task's execution. **This is not the only option, though** — per official docs, `runCode` has its own external-secret mechanism: reference `$GATEWAYSECRET_(alias-name)` as a literal string in your script, and Gateway resolves the alias at execution time. The secret value is never sent back to Itential Platform. This requires a secret provider/alias already set up on the Gateway side — not yet confirmed live in this repo, but documented here since it directly answers "is there any safe way to use secrets in a `runCode` script." ### "Enable Query" — inline query decorator on any incoming field (aka "in-task-query", called "task query" in official docs) A per-field Studio toggle, not a task or endpoint — invisible to `tasks.json`/`openapi.json`/`multipleTaskDetails` searches. Appears under any incoming field with `Variable Source: Job` or `Task` selected (a checkbox labeled "Enable Query"). Lets you query a nested path out of that field's resolved value inline, eliminating a separate `query` task that would otherwise sit between the producing task and the field that needs one piece of its output. **Confirmed shape (via a live before/after diff of a Studio edit — not guessed):** ```diff "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.` or `$var..` reference gets a `#/` suffix appended directly onto the string (URL-fragment style, `/`-delimited). - The task's `decorators` array (always present, usually `[]`) gets one entry: `pointer` is JSON-Pointer-style pointing at the field (`/incoming/`); `displayPath` is the dot-notation the Studio UI shows — its `.`-separated segments map 1:1 to the `#/...` suffix's `/`-separated segments (`.result.stdout` → `#/result/stdout`). - Doesn't create a task, doesn't touch `outgoing` — purely a decorator on an existing field's existing value. **Verified working at runtime, not just in Studio:** deleted a `query` task whose only job was extracting one field from another task's object output, and instead pointed the downstream consumer's own field at the source directly with the `#/...` suffix + decorator. The job variable the deleted task used to populate was confirmed absent from the job's final variable dump (proving the task really was gone), yet the consuming task still received the correct nested value and the job completed successfully with real data flowing through. **Confirmed scope:** works on both a top-level plain-string incoming field AND a field nested inside an object (e.g. `runCode`'s `data.devices`) — confirmed by deleting the upstream `query` task entirely and verifying the job variable it used to write was absent from the job's final state while the downstream task still received the correct value. For a nested field, the `#/` suffix goes on the nested value itself (`"data": {"devices": "$var.job.someVar#/nested/path"}`) and `pointer` uses the full nested path (`/incoming/data/devices`). #### Where `pointer` goes — verified against Studio saves, per field shape > Everything in this subsection was verified on Itential Platform 6.5.2 (Automation Studio 4.69.69), 2026-10: Studio saves for the stored shapes, live jobs for the runtime behavior. Re-check on other versions before relying on an edge case. The rule is the same everywhere: `pointer` is `/incoming/` plus the path to the field that **holds the reference string**; the value gets `#/`; `displayPath` is the same path with dots (`#/body/name` → `.body.name`). The holder moves for nested shapes, so check the table instead of assuming `/incoming/`: | Field shape | Examples | Value stored | `pointer` | |---|---|---|---| | Top-level field (string, array, object or "any" typed) | `toLowerCase.str`, `newVariable.value`, `stub.response`, `join.arr`, `forEach.data_array`, `setObjectKey.obj`, `ViewData.body`, `restCall.uri`/`body`, `ShowJsonForm.instance_data`, `eventListenerJob.topic`/`schema`, adapter `reference` | `$var.job.x#/body/name` | `/incoming/` | | A whole object field | `makeData` / `ViewData` / `ViewHTML` / `renderJinja2TemplateWithCast` `variables`; adapter `requestBodyPayload` | `$var.job.x#/body` | `/incoming/variables` | | One key inside `runCode` `data` (the documented one-level exception) | `runCode.data.x` | `$var.job.x#/body` | `/incoming/data/x` | | One input inside `transformation` `variableMap` (also one level deep) | `variableMap.` | `$var.job.x#/body/list` | `/incoming/variableMap/` | | `merge` item | `data_to_merge[i].value` | `{"task":"job","variable":"x#/body/name"}` | `/incoming/data_to_merge//value/variable` | | `childJob` variable | `variables.` | `{"task":"job","value":"x#/body/name"}` | `/incoming/variables/` | | `childJob` loop input (`loopType` set) | `data_array` | `$var.job.x#/body/devices` | `/incoming/data_array` | | `evaluation` operand | `operand_1`, `operand_2` | no decorator — use `query` / `rightQuery` on the evaluation object | — | **Array indexes:** the value uses the index as a path segment and `displayPath` uses brackets — `$var.job.x#/body/items/0/name` with `displayPath` `.body.items[0].name` (whole element: `#/body/items/1` → `.body.items[1]`). **Variadic inputs** (`stringConcat` `stringN`, `arrayPush` `elementN`, `assign` `sourceN`) are stored under that literal key, so the pointer is `/incoming/stringN` etc. **A `merge` item reading a `childJob` output** keeps `"value": "job_details"` and gets the query on an added `variable` key — `{"task": "", "value": "job_details", "variable": "job_details#/received"}`, pointer `/incoming/data_to_merge//value/variable`. Putting the `#/path` on `value` instead resolves to `null` with no error. **Keys with special characters:** `/` and `~` in a key use JSON Pointer escapes in the value (`#/a~1b`, `#/a~0b`) and appear unescaped in `displayPath` (`.a/b`, `.a~b`). A key containing `.` uses bracket form: the value keeps the dot inside one segment (`#/a.b`, nested `#/body/mgmt.ip`) and `displayPath` brackets that key — `.["a.b"]` at the top level, `.body["mgmt.ip"]` when nested (no dot before the bracket) — which is what Studio writes when you enter those paths; it resolves the dotted key. Other forms are accepted but produce a different path: `."a.b"` → `#/"a/b"` and `.a\.b` → `#/a\/b` both fail at runtime, and `.a.b` → `#/a/b` reads the nested `a` → `b` with no warning. If you build one by API, use the bracket `displayPath` so Studio shows it correctly. A reference to another task's output uses the same forms with the task id in place of `job` — `{"task":"a001","variable":"value#/body/ip"}` for a `merge` item, `{"task":"a001","value":"value#/body/ip"}` for a `childJob` variable, `$var.a001.value#/body/ip` for a plain or `runCode` field — with the same `pointer`. The path goes after the output name (`value#/body/ip`). Note the `merge` and `childJob` references carry **no `$var.` prefix** (`x#/body/name`, same as their plain refs), and the childJob pointer stops at the variable name while the merge pointer includes `/value/variable`. **Both halves are required, and they are read by different things.** The job runs from the `#/path` suffix; Studio displays from the `decorators` entry. Writing only the suffix (or a decorator whose `pointer` is one level off) still runs correctly, so it passes every job test — but Studio shows the field with no query text, and **opening then saving the workflow silently strips the `#/path`**. The task then receives the whole object instead of the extracted value and the job still completes with no error (a downstream `query` or `evaluation` on it is what fails). A correct decorator survives saves unchanged. **A path that doesn't exist at runtime fails loudly — except on `evaluation`.** On plain fields, `merge` items, `childJob` variables (the child job is never started) and `runCode` `data` keys, the task takes its **error** transition with `"Query failed for task incoming variable: ."` — wire an error transition. On `evaluation`, a missing `query` path takes the **failure** transition, which looks exactly like a comparison that was false; check the path when an evaluation unexpectedly fails. **Do not guess a `merge` or `childJob` form.** Adding a `query` key beside `variable`/`value` (the way an `evaluation` operand looks) is ignored at runtime and invisible in Studio — the whole object is passed through with no error. Use the `#/path` forms in the table. **Build objects upstream; never put a `$var` inside a static object** to give a field a query. Reference the whole field (`variables`, `requestBodyPayload`) and let the query shape it, or build the object with `merge`/`makeData` first. `runCode` `data` is the one place a `$var` resolves one key deep. **Generate and verify decorators with the helper instead of writing them by hand:** `python3 assets/helpers/enable_query.py check workflow.json` lists every task whose decorators are missing, dangling or off by a level, and flags the ignored `query`-key forms above and any `$var` placed inside a static object (which is sent as literal text); `fix workflow.json` rewrites the query decorators from the references, keeps any other decorators (e.g. `encryption`), and lists what it can't repair — those need a change to the reference itself. `fix` writes the file back as 2-space JSON, so on a hand-formatted file such as one in `assets/helpers/assets/`, apply `check`'s findings by hand. In code: `apply_decorators(task)`. Run `check` before every create/update. **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. **Validator vs runtime:** on some platform builds, `POST /workflow_engine/workflows/validate` reports a query decorator as a schema error (`decorators/0/type: must be equal to one of the allowed values` — that build's schema only allows `encryption`) and warns that the field gets an object. Jobs still resolve the decorated reference at runtime — verified by a `sendConfig` whose `config` and `inventory` came through decorators and pushed the right line to a live device. Don't "fix" those errors by removing the decorators. **`evaluation` does not use a decorator.** Its operands are structured references (`{"task":"job","variable":"x"}`), not `$var` strings, so there is no `#/path` suffix and no `decorators` entry. The query lives on the evaluation object itself: `query` (applied to `operand_1`) and `rightQuery`, exactly as Studio writes them — see `### evaluation` below. Do **not** put a `query` key inside the operand object: it is ignored at runtime and Studio shows nothing for it. ### 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): `assets/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: ```json "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 `#/` to the reference string, and add a matching entry to the task's `decorators` array — both parts are required and must agree: ```json "data": { "devicelist": "$var.8850.response#/body" }, "decorators": [ { "type": "query", "pointer": "/incoming/data/devicelist", "displayPath": ".body" } ] ``` - The `#/` suffix goes on the value string itself, `/`-delimited (JSON-Pointer style). - The decorator's `pointer` is `/incoming/data/` (locates *which* incoming field has a query), and `displayPath` is the Studio-UI-facing dot-path (`.`-delimited, 1:1 with the `#/...` segments). - **This query is evaluated once, before the script ever runs.** If the path doesn't exist on the real resolved object, the task takes its **error** transition before the script runs, with `"Query failed for task incoming variable: ."` (the job stops there if the task has no error transition). The script never gets a chance to handle a missing/wrong path defensively; get the path right up front. **Don't guess the path — verify the real shape first.** The platform's own job-detail API does not help here: `GET /operations-manager/jobs/{id}` always returns the static workflow definition (empty-string outgoing placeholders) regardless of `include`/`dereference` params or job completion status — confirmed directly, including via the browser UI's own network call using session-cookie auth, not just a scripted API token. The two ways that do work: (a) open the job in Operations Manager / Studio and read the task's real Output/Response value directly in the UI, or (b) temporarily pass the reference through *without* a query, and have the script itself print the shape it received (`type()`, keys, a sample item) so you can see the real structure before committing to a query path. **Confirmed real-world case:** a REST/HTTP-style adapter task whose outgoing field is named `response` returns the full HTTP response object, not the API payload directly — the payload is at `response.body` (`response` itself is `{body, headers, statusCode, ...}`-shaped). This is a different wrapping than the existing documented `result.response` convention elsewhere in this file (different outgoing field name, different nesting depth) — don't conflate the two. Confirmed on NetBox's `dcim_devices_list`/`dcim_interfaces_list`: querying `#/results` directly failed (that path doesn't exist at the top level); the real path is `#/body`, landing on `{count, next, previous, results: [...]}}`. **The query must be the field's entire value — it does not work embedded inside a larger static object.** Confirmed by feeding `runCode`'s output into a second, non-`runCode` task (`extras_tags_create`) requiring a `requestBodyPayload` object with `name`/`slug`/`description`: - **Failed silently:** `requestBodyPayload` set to a static object literal with the query on one of its own keys — `{"name": "my-tag", "slug": "my-tag", "description": "$var.3c0b.result#/stdout_json/devices/0/name"}` + a decorator pointing at `/incoming/requestBodyPayload/description`. The query never resolved. Worse, this didn't fail loudly: the literal, unresolved `$var...#/...` string was sent through as-is, and NetBox — whose `description` field accepts any free text — happily stored the garbage literal string as if it were real data. The failure only became visible when the same pattern was tried on a strictly-typed field (`weight`, an integer): NetBox rejected the literal string with `"A valid integer is required"`, which looked like a type/casting problem but was actually a masked query-resolution failure. **A task query that "succeeds" into a loosely-typed field is not proof the query itself worked — check a strict field, or inspect the literal value sent, before trusting it.** - **Confirmed working:** shape the upstream task's output to already match the downstream field's whole expected structure, then query the *entire* field in one shot — `"requestBodyPayload": "$var.3c0b.result#/stdout_json/tags_payload"` (where `tags_payload` in `runCode`'s own JSON output is already `{name, slug, description}`-shaped), decorator `{"pointer": "/incoming/requestBodyPayload", "displayPath": ".stdout_json.tags_payload"}`. Verified end-to-end: real HTTP POST to NetBox, real object received (confirmed via a genuine `"tag with this name already exists"` conflict on a repeat run — proof the resolved payload's `name`/`slug` were real values, not placeholders). - This also confirms task query is not `runCode`-specific — it works the same way on a plain adapter task, as long as the whole-field rule above is followed. **3. Access the variables inside the script.** Everything in `data` arrives as one JSON object on stdin, keyed exactly as configured: ```python 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). ### Individual utility tasks (secondary — single operations, or when `runCode` doesn't apply) ### 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`) ```json { "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. **`pass_on_null: false` does not reliably trigger the `failure` transition for an out-of-bounds array index.** Live-tested: `query: "response.results[0].status.value"` against an adapter response where `results` resolved to an empty array (`[]` — e.g. a NetBox lookup for a device name with zero matches) produced `return_data: null` but the task still took the `success` transition, not `failure`. This is different from a missing/undefined *key* partway through the path, which does trigger `failure` as documented. If a downstream task (e.g. `evaluation`) needs to distinguish "found but null" from "not found" for an array-index path, don't rely on the `query` task's own success/failure transition — check the extracted value explicitly (e.g. an `evaluation` testing the job variable for null/empty) rather than assuming a `failure` transition will catch it. ### merge Build an object from multiple resolved values. Primary workaround for `$var` not resolving inside nested objects. **Incoming:** `data_to_merge` (array, min 2 items) **Outgoing:** `merged_object` (object) **IMPORTANT: The field is `"variable"` NOT `"value"`** in the reference objects inside `data_to_merge`. **Reference format in `data_to_merge`:** - `{"task": "job", "variable": "varName"}` — pull from a **user-supplied** job variable (input to the workflow) - `{"task": "static", "variable": "literalValue"}` — literal value - `{"task": "taskId", "variable": "outVar"}` — pull from a previous task's output > **WARNING — `{task:"job"}` references add fields to `inputSchema.required`.** > The platform scans every `data_to_merge` entry in merge tasks (and every `variables` entry in childJob) for `{task:"job"}` references and automatically adds that variable name to `inputSchema.required`. This means using `{task:"job", variable:"changeId"}` for a variable that was produced internally by a query task will prompt operators to supply `changeId` as a workflow input — even though it should never come from the user. > > **Rule:** only use `{task:"job"}` for variables that are genuine workflow inputs. For anything produced by an earlier task, use the producing task's ref directly: > > | Value source | Correct ref form | > |---|---| > | User workflow input | `{"task": "job", "variable": "x"}` | > | `query` output | `{"task": "queryTaskId", "variable": "return_data"}` | > | `merge` output | `{"task": "mergeTaskId", "variable": "merged_object"}` | > | `newVariable` output | `{"task": "newVarTaskId", "variable": "value"}` | > | `makeData` output | `{"task": "makeDataTaskId", "variable": "output"}` | > | `parse` output | `{"task": "parseTaskId", "variable": "return_data"}` | > | adapter task output | `{"task": "adapterTaskId", "variable": "result"}` | ```json { "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) ```json { "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":"","operator":"contains","operand_2":""}]}]} ``` 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](#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"}` ```json { "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} } ``` **Drill into a nested field with `query` on the evaluation object — no separate `query` task needed.** Put `query` (applies to `operand_1`) and `rightQuery` (applies to `operand_2`) next to `operator` **on every evaluation item** — each item in each group carries its own pair, in any position, using the dot-path syntax Studio writes (`.body.name`): ```json { "query": ".body.name", "operand_1": {"task": "job", "variable": "src"}, "operator": "==", "operand_2": {"task": "static", "variable": "poc-a"}, "rightQuery": "" } ``` Always include `rightQuery` (empty string when unused), as Studio does. At runtime the leading dot is optional and single- or multi-segment paths both work; write the Studio form so a Studio save changes nothing. This is the form Studio shows under Enable Query, and it survives a save. No decorator is involved. > **Do not put `query` inside the operand** (`"operand_1": {"task": "job", "variable": "src", "query": "body.name"}`). Tested with the operand pointing at a job variable and at a task output, single and multi-segment, with and without a leading dot: the query was ignored every time — `operand_1` resolved to the whole object, so the comparison silently failed — and Studio shows no Enable Query for it. If you inherit a workflow that uses it, move the query to the evaluation object. If a query still has no effect, fall back to a standalone `query` task writing a job variable, then compare that. ### childJob Run another workflow as a sub-job. **Read a live childJob example first:** ```bash jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "childJob")] | first | .value' \ assets/helpers/assets/vendor-cisco-ios.json ``` **Critical differences from normal tasks:** - **`actor` MUST be `"job"`** — not `"Pronghorn"` - **`task` MUST be `""`** (empty string) - **`outgoing.job_details` MUST be `null`** — do NOT override with `$var.job.X` - **All incoming fields required** — even unused ones: `"data_array": ""`, `"transformation": ""`, `"loopType": ""` **`childJob` cannot resolve project-scoped workflow names.** `{"workflow": "@: "}` fails at runtime with `"Error starting child job: Cannot find workflow ..."` — and so does the bare unprefixed name (`{"workflow": ""}`), 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` item reading a `childJob`'s output uses `"variable"`** — `{"task": "", "variable": "job_details"}`, the same key as any other merge item. With `"value"` instead, the workflow creates, updates and runs without error but the item resolves to `null`. (When you enable a query on this item in Studio it keeps a `"value": "job_details"` key and adds `"variable": "job_details#/"`; the runtime reads `variable`.) Tested on Itential Platform 6.5.2. **Variables use `{"task", "value"}` syntax — NOT `$var`:** ```json { "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"`, not `"variable"`** — see Variable Syntax Reference below for the full merge/evaluation contrast. **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:** ```json { "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 for new builds** — prefer `childJob` with `loopType` for calling another workflow per item, or `runCode` (see Step 0a above) for a per-item data transform/branch that doesn't need to call another platform task. Still common in existing workflows, and this section stays detailed because you'll need to read/modify existing `forEach` loops even when not authoring new ones. **Incoming:** `data_array` (array) — **ONLY `data_array`**. Do NOT include `job_id` in incoming — it triggers errors. **Outgoing:** `current_item` (any) **Transition pattern (critical):** ``` forEach --state:loop--> firstBodyTask -> ... -> lastBodyTask --(empty {}) forEach --state:success--> nextTaskAfterLoop ``` #### forEach constraints (all four are required) 1. **`incoming` must only contain `data_array`** — do NOT include `job_id` or any other field. Adding `job_id` causes errors at runtime. 2. **`$var..` does NOT resolve inside the loop body** — string references like `$var.n01.current_item` silently resolve to `null` inside a forEach body. Use `$var.job.` instead (bind the forEach's outgoing to a job variable and reference that). This applies to ALL reference styles — even taskRef objects `{"task": "outerTask", "variable": "current_item"}` are unreliable inside a nested body. 3. **Loop body tasks cannot transition to tasks outside the loop** — no error transitions from loop body tasks to external error handlers. The `forEach` task itself handles exit via `state: "error"` on the forEach transition. Handle errors within the loop body, then let the forEach's error transition route out. 4. **The last loop body task signals loop-back with an empty `{}` transition** — do NOT add an explicit loop-back target pointing to forEach. ```json "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) ```json { "incoming": {"name": "taskStatus", "value": "success"}, "outgoing": {"value": "$var.job.taskStatus"} } ``` **GOTCHA:** `value` resolves only a whole-field reference — `"$var.job.x"`, or with Enable Query `"$var.job.x#/path"` plus its decorator. A `$var` embedded in a longer string (`"prefix-$var.job.x"`) or placed inside an object/array value is stored as literal text. Use merge (or a template) to build dynamic values. Tested on Itential Platform 6.5.2. ### makeData Construct data with `` variable substitution. **Incoming:** `input` (string with `` placeholders), `outputType` (`"string"`/`"json"`/`"number"`/`"boolean"`), `variables` (object) **Outgoing:** `output` (any) **The `variables` field must be a resolved object.** Use merge first to build it, then pass via `$var.taskId.merged_object`: ``` merge (build variables object) → makeData (use $var.taskId.merged_object as variables) ``` > **WARNING — `makeData.incoming.variables` cannot use `$var` references to a merge that sources childJob output.** > When a `merge` task's `data_to_merge` contains a childJob reference (e.g., `{"task": "childJobId", "variable": "job_details"}`), the platform cannot compile `$var..merged_object` as a `taskRef` for `makeData.incoming.variables` — it is stored as a literal static string. Template substitution then operates on the literal string and emits unresolved placeholders. > > `query.incoming.obj` does NOT have this limitation — it resolves `$var..merged_object` correctly even when the merge references childJob output. > > **Fix:** extract individual values from the childJob-sourced merge using `query` tasks, then pass those resolved scalars to makeData via a second merge (that contains only non-childJob refs). Do NOT feed a childJob-sourced merge directly into makeData's `variables`. ### delay Pause execution. **Incoming:** `time` (integer, seconds). **Outgoing:** `time_in_milliseconds`. ### push / pop / shift Array manipulation on job variables **by name** (plain string, NOT `$var` reference). ```json { "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. **Before reaching for `restCall`, search `tasks.json` for a native app task first** — `jq '.[] | select(.app=="GatewayManager")' tasks.json` (or `InventoryManager`, or the target adapter's type name). `restCall` against the Itential platform's **own** internal REST API (e.g., `/gateway_manager/v1/services/run`, `/automation-studio/...`) is almost always the wrong choice — a native task exists for this (e.g., `GatewayManager.sendCommand`/`runService`, `InventoryManager.buildInventoryFilter`) and handles auth, response shaping, and validation for you. Reach for `restCall` only for genuinely external third-party APIs that have no adapter and no native task. **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: ```bash 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 of `query` + `merge` when updating a single field on an object already in `$var.job.*`. - **`renderJinja2ContextWithCast`** (ConfigurationManager) — renders a Jinja2 template with the full job context automatically injected, plus optional type casting on the output. Use instead of `merge` → `renderJinja2` → `query` chains when the template needs access to existing job variables. Outputs `renderedTemplate` accessible via `$var..renderedTemplate`. Fetch full schemas with `POST /automation-studio/multipleTaskDetails?dereferenceSchemas=true`. ### Task Endpoint Patterns (Standalone Testing) Some tasks have standalone REST endpoints — **faster than creating test workflows:** - **WorkFlowEngine:** `POST /workflow_engine/{method}` (e.g., `/workflow_engine/query`) — requires `job_id` (use dummy ObjectId `"4321abcdef694aa79dae47ad"`) - **MOP:** `POST /mop/RunCommandTemplate` — test command templates directly - **TemplateBuilder:** `POST /template_builder/templates/{name}/renderJinja` with `{"context": {...}}` (note: `context`, not `variables`) Most utility tasks (array ops, string ops, forEach, childJob, merge) do NOT have standalone endpoints. Test those by creating a minimal `start → task → end` workflow and running via `jobs/start`. --- ## Templates (Jinja2 / TextFSM) ``` POST /automation-studio/templates ``` ```json { "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 ``` ```json {"context": {"vlan_id": 100, "description": "Management"}} ``` **Gotchas:** - `group` cannot be empty or whitespace-only - Use underscores in template names (e.g., `IOS_Switchport_Config`) - `data` field is a JSON string, not an object - Variable syntax is `{{ var }}` (Jinja2), NOT `$var` or `` - **No `from_json` filter** — Ansible's `from_json` Jinja2 filter does NOT exist in Itential's TemplateBuilder. If you need to parse a JSON string, use a `parse` task before the template render step, not a filter inside the template - **`renderJinjaTemplate` as a workflow task** — use `TemplateBuilder.renderJinjaTemplate` with incoming `templateName` (string) and `variables` (object). Output is at `result.renderedTemplate` (string). Different from the standalone API endpoint which uses `context` instead of `variables` --- ## Command Templates (MOP) MOP manages command templates for running CLI commands with validation rules. **MOP is read-only validation only — never use it to push config.** **To push config to a device, use `GatewayManager.sendConfig`** — not MOP. It sends configuration text to Inventory Manager nodes through a Gateway5 cluster. The standard pattern for any config push delivery is: ``` Pre-Check (RunCommandTemplate child) → Push Configuration to Device (renderJinjaTemplate → approval ViewData → sendConfig → per-node success check) → Post-Check (RunCommandTemplate child) → runTemplatesDiff (compare pre vs post) ``` Verified against a live Arista EOS device: - **`config`** is the rendered text as one string, and **`inventory`** is `[{"inventory": "", "nodeNames": ["", ...]}]`. Build both in one `runCode` task before the push — it takes `renderJinjaTemplate`'s output (an object, `{renderedTemplate: "..."}`), the device(s) and the inventory name, and returns `{config, inventory, ...}`; `sendConfig` reads them with Enable Query (`config: "$var..result#/stdout_json/config"`). See "Process Push Configuration Data" in the asset. (Without `runCode`: a `query` task for the text, and `merge` + `arrayPush` for the inventory — `$var` doesn't resolve inside it.) - **Config mode needs enable:** the node's `itential_driver_options.netmiko.become` must be `true` (plus `secret` if the device has an enable password). Without it the push fails with `ReadTimeout: Pattern not detected: '.*\)\#'`. - **Output:** `result.result.results[]`, one `{name, host, output, success}` per node — there is no overall `state` field. Check every node: an `evaluation` with `query: "result.results"` `!=` `[]` **and** `query: "result.results[*].success"` `!contains` `false` (group `all_true_flag: true`). Prove the check by running it once against a push that can't land — a wrong query path passes silently. - A Gateway4-style template that wraps its lines in `conf t` … `end` still works on EOS; the driver enters config mode itself, so new templates don't need the wrapper. **`AGManager.itential_cli` / `itential_set_config` are Gateway4.** Don't build new deliveries on them; use them only while maintaining an existing Gateway4 workflow. When that workflow moves to `sendConfig`, every task that read the old output (`stdout.completed[0].response[*].status`, `…icode`) must change too — two ways: 1. **Re-point each consumer** to the new shape (the `evaluation` above, a `query` on `result.results[0].output` for a ViewData body). Best when only a few tasks read it. 2. **Rebuild the old shape** with one `runCode` task right after `sendConfig`, then point the consumers at it and prefix their queries with `stdout_json.` — the query logic itself stays as it was. Best when many tasks or transformations parse the Gateway4 shape: ```python import sys, json data = json.load(sys.stdin) # data: {"result": "$var..result"} nodes = ((data.get("result") or {}).get("result") or {}).get("results") or [] print(json.dumps({"completed": [{"icode": "AD.200", "response": [ {"host": n.get("name"), "status": "SUCCESS" if n.get("success") else "FAILURE", "stdout": n.get("output", "")} for n in nodes]}]})) ``` `runCode`'s own `result` is flat (`{status, return_code, stdout, stderr, stdout_json}`) — no JSON-RPC envelope, unlike `sendConfig`'s. Gateway4's `icode: "AD.200"` only means the call reached the gateway — a push the device rejected still returns it. A workflow that checked only `icode` never caught failed pushes; the per-node check does. Read the Arista EOS "Push Configuration to Device - IAG" and "Command Template Runner" workflows before building any config push delivery: ```bash jq '[.components[] | select(.type=="workflow") | select(.document.name | test("Push Config|Command Template"))] | .[].document | {name:.name, tasks:.tasks, transitions:.transitions}' \ assets/helpers/assets/vendor-arista-eos.json ``` ### Create a Command Template ``` POST /mop/createTemplate ``` ```json { "mop": { "name": "Port_Turn_Up_Pre_Check", "description": "Validates interface and VLAN", "os": "", "passRule": true, "ignoreWarnings": false, "commands": [ { "command": "show interface ", "passRule": true, "rules": [ { "rule": "line protocol is", "eval": "contains", "severity": "error" } ] }, { "command": "show vlan brief", "passRule": true, "rules": [ { "rule": "", "eval": "contains", "severity": "error" } ] } ] } } ``` **Variable syntax:** `` 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: ```json { "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 ``` ```json { "template": "Port_Turn_Up_Pre_Check", "variables": {"interface": "GigabitEthernet0/1", "vlan_id": "100"}, "devices": ["IOS-CAT8KV-1"] } ``` **In a workflow (MOP.RunCommandTemplate task):** ```json { "incoming": { "template": "$var.job.templateName", "variables": "$var.job.templateVariables", "devices": "$var.job.devices" }, "outgoing": { "mop_template_results": null } } ``` ### Response Shape ```json { "all_pass_flag": true, "result": true, "name": "Port_Turn_Up_Pre_Check", "commands_results": [ { "raw": "show 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 ``` ```json { "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):** ```json { "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 ``` ```json { "workflow": "My Workflow Name", "options": { "description": "Test run", "type": "automation", "variables": {"deviceName": "IOS-CAT8KV-1"} } } ``` Response: `{"message": "...", "data": {"_id": "jobId", "status": "running"}}` ### Check Job Status ``` GET /operations-manager/jobs/{jobId} ``` Response wrapped in `{message, data, metadata}`: - `data.status` — `"running"`, `"complete"`, `"error"`, `"canceled"` - `data.variables` — all job variables including outputs - `data.error` — array of error objects on failure ### Debug Failed Jobs 1. `GET /operations-manager/jobs/{jobId}` — check `data.status` 2. If `"error"`, read `data.error[]` — each has `task` (ID) and `message.IAPerror.displayString` 3. Identify the failing task ID, check its `metrics.finish_state` **Common failures:** | 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 | | Same type-mismatch warning recurs after a "fix" that changed task type but not data shape | `$var` Resolution Rule violated (object/array passed as static value) | Don't swap task types — build the value through `merge`/`makeData` per the `$var` Resolution Rules section, then re-validate | | Workflow built via `restCall` against Itential's own internal API (e.g., `/gateway_manager/v1/...`) | Skipped searching `tasks.json` for a native task before reaching for `restCall` | Search `tasks.json` filtered by `app` (e.g., `GatewayManager`, `InventoryManager`) — a native task almost always exists | | "Schema validation failed on must have required property 'X'" | Missing field in adapter body | Add the field to the merge task building it | | "Referenced job variable: undefined" | `merge`'s `data_to_merge` used `"value"` instead of `"variable"` | Change to `"variable"` — see Variable Syntax Reference | | Job stuck in `"running"` indefinitely | No error transition on the failing task | Add a `"state": "error"` transition | **Fix locally, PUT to update, re-run — don't recreate; updating preserves the ID.** ### Standalone Test Endpoints See **Task Endpoint Patterns (Standalone Testing)** above (in Guides) for the full list of tasks with standalone REST endpoints and the dummy-`job_id` trick. ### 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 — use the deep validator (6.5.2+):** ``` POST /workflow_engine/workflows/validate {"asset": {...workflow document...}} → {"isValid": true|false, "errors": [], "warnings": []} ``` `isValid` is exactly `errors.length === 0` — warnings never affect it. This is the default pre-flight gate; it is stateless and safe to call as often as needed. Prefer it over `POST /automation-studio/workflows/validate` (`{"workflow": {...}}` → `{errors, warnings}`, no `isValid`), which misses errors this one catches. **The endpoint can return HTTP 500 instead of a normal body.** A manual-view task (e.g. `ViewData`) with `type` set to anything other than `"manual"` returns `{"message":"An unknown error occurred...", "data":{"error":"Cannot destructure property 'input' of 'undefined'..."}}` at HTTP 500. Always check for this shape before parsing `isValid`/`errors`/`warnings`. Fix the task's `type` first if you hit it. **What this endpoint catches, by category:** - **Errors (block `isValid`):** non-hex/reserved task IDs, workflow-level required fields, per-task required fields (including `description`, required on every task), adapter `app` checked against the platform's registered adapter types (`"No config found for Adapter: X"`), adapter `adapter_id` checked against live registered adapter instances (`"X is not a configured adapter within the platform"` — exempts `$var`/task-ref values), adapter/application method incoming and outgoing field-name mismatches, transition type/state enum values, dangling/missing transition entries, cycles in standard transitions, `created_by`/`last_updated_by` object-vs-string shape on import documents. - **Warnings only (`isValid` stays `true` — inspect `warnings[]` separately):** nested `$var` inside an object/array incoming value (see the allow-list exceptions in the `$var` Resolution Rules section below), static-value type mismatches (`"should be of type X but is of type Y"`), enum-typed static values with a non-enumerated value, dangling/out-of-order job-variable and task-to-task `$var` references. - **Not checked at all — keep doing these manually:** missing `error`/`failure` transitions (only a missing success path is flagged), `evaluation.operator` invalid values like `"regex"` (the closed enum is only wired to the standalone evaluation-test endpoints, not to a workflow document's embedded `evaluation` task), `merge`/`childJob` `"value"` vs `"variable"` key-naming mistakes, wrong `canvasName`, `incomingRefs` cache staleness after PUT, adapter response-shape assumptions, `projects/import` semantics (different service). **A non-empty `warnings` array is not "good enough to ship."** `errors` blocks a job from starting; `warnings` (e.g., `"input should be of type string but is of type object"`, `"outputType is of type enum but got non-enumerated value..."`) means a task's static data won't behave the way you intended even though the workflow will technically save and run. Treat any non-empty `warnings` array as a build defect to fix before marking the component delivered — don't report a workflow as built/delivered just because the save call returned a 200. If the same warning reappears after a "fix," you've patched the symptom, not the cause — re-check the `$var` Resolution Rules section below before trying another variation. **Never author a large workflow JSON payload as an inline shell string.** Write it to a file with a proper file-editing tool, or if constrained to `bash`, generate it via `python3 -c "..." ` with `json.dump()` — never an interactive heredoc with embedded Jinja/markdown/emoji. If a payload fails to parse, regenerate it cleanly rather than patching it byte-by-byte with `sed`. **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. **Fetch → modify → PUT → re-verify, in one contiguous chain — never PUT from a locally-cached copy.** If you fetched a workflow/project/template earlier in the session and it may have changed since (you or something else edited it), a PUT built from that stale local file will silently revert any change made in between — the PUT succeeds, but it overwrites the newer state with the old one. Always: fetch fresh immediately before modifying, PUT, then GET again immediately after to confirm the change actually landed. This is easy to trigger across a multi-step debugging session where the same asset is fetched and modified several times. --- ## 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 ``` ### 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: ```json { "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: ```bash jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "ViewData")] | first | .value' \ assets/helpers/assets/vendor-cisco-ios.json ``` Three rules that cause draft validation errors if missed: 1. `view` is a **top-level** field (sibling of `name`, `type`, `app`) — NOT inside `variables`. Missing it → `"Manual Tasks require 'view' key with path to task view"`. 2. `incoming.variables` **MUST be present** (value can be `{}` if unused). Missing it → `"Input: 'variables' is not defined in task model"`. 3. `displayName` must be `"Tools"` and `actor` must be `null` (no actor field) on manual tasks. Note: production assets include `"error": ""` and `"decorators": []` in the variables block on ViewData tasks — these are added by Studio on export and are harmless. You do not need to add or remove them. ```json { "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:** ```bash jq '[.components[].document.tasks // {} | to_entries[] | select(.value.name == "ViewHTML")] | first | .value' \ assets/helpers/assets/vendor-cisco-ios.json ``` Key differences from ViewData: 1. `view` is `/workflow_engine/task/ViewHTML` 2. `body` is a raw HTML string — use inline CSS (no `