Five step types are performed by the agent; nine run inside the engine. Each has a config whose shape is given here.
Agent-facing steps
instruction — a unit of work
{
"prompt": "Run the test suite for {{input.target}} and summarize failures.\nReport in output: { \"failures\": [ { \"file\": \"…\", \"test\": \"…\", \"error\": \"…\" } ] }",
"instructionFileIds": ["if_abc123"], // optional; contents appended to the prompt
"isolation": "subagent" // optional; "subagent" (default) | "inline"
}The prompt is a template delivered verbatim to the agent. End every prompt with an explicit contract for output; later steps can only use what the agent reports.
Output location. The engine appends an output-location block to every instruction and review step: files the agent produces that are neither source nor documentation — reports, analyses, exports — go to {outputDir}/{package}, where outputDir comes from config.json and the package is the one the workflow lives in. Only spell out a path when a step must write somewhere else.
Context isolation. isolation: "subagent" asks the orchestrating agent to run the step's work inside one isolated subagent and let only the structured result enter its own conversation; "inline" runs it in the orchestrator's conversation. Defaults: instruction and review → subagent; decision and shell → inline. Because a subagent does not remember earlier steps, a step must never rely on the agent "remembering" — pass everything it needs through templates.
decision — a branch point the agent chooses
{
"prompt": "Inspect {{steps.Run Tests.output}}. Did all tests pass?\nRespond with branchLabel \"PASS\" or \"FAIL\".",
"branches": [
{ "label": "PASS", "transitionId": "tr_pass" },
{ "label": "FAIL", "transitionId": "tr_fail", "isDefault": true }
],
"allowSummaryFallback": false // always set false
}The agent inspects the recorded output and picks one label from a closed set. Branch selection order at runtime: result.transitionId → result.branchLabel (case-insensitive) → output.transitionId / output.branch → the isDefault branch → the instance fails.
Rules: every branch's transitionId must be a real outgoing transition of this step; mark exactly one branch isDefault as the safe fallback; set allowSummaryFallback: false — summary matching is a prompt-injection surface, since data quoted in a summary can match a label; and list the allowed labels verbatim in the prompt.
gate — human approval
{ "message": "Approve deployment of {{input.target}} to production?" }Pauses the instance. The engine returns the resolved message with status: "waiting" instead of a next step; the agent puts the question to the person in its session and reports their answer with ccw_continue. Use a gate before anything irreversible — deploys, sends, deletes. One outgoing transition; approval unblocks it.
shell — a command under a structured contract
{
"command": "npm test -- --filter {{input.target}}",
"expectedExitCodes": [0], // default [0]
"timeoutSeconds": 300, // optional
"captureStdout": true, // default true
"stdoutMaxBytes": 65536, // default 64 KB; 0 disables inline capture
"stderrMaxBytes": 65536,
"captureToFile": "/tmp/{{instanceId}}-{{stepId}}.log", // optional
"verifyHash": true // optional; see below
}The engine renders the command and instructs the agent to run it in its own shell and report output.stdout, stderr, exitCode and durationMs — plus capturedFile and outputHash when file capture is on — with status: "success" only for an expected exit code.
Interpolated {{var}} values are POSIX-shell-quoted automatically; a variable cannot break out of the command. {{!raw var}} bypasses quoting — only for values you fully control, never for anything that came from untrusted data.
verifyHash. The engine wraps the command so its combined output is written to a file, then appends sha256sum of that file and preserves the original exit code. The agent must report the printed hash as output.outputHash. A later step can re-read the file and compare — which is how a report that does not match what actually ran is caught. It implies file capture; without captureToFile the engine uses a default path under /tmp.
Prefer shell over an instruction that says "run this command": it gives deterministic quoting, exit-code checking, truncation limits, and with verifyHash tamper-evident output.
review — a bounded quality loop
{
"prompt": "Review the implementation for correctness, style, and test coverage.",
"maxIterations": 3, // optional; default 3
"instructionFileIds": ["if_abc123"] // optional
}One input, two labelled outputs. The engine appends the output of the step feeding into the review — the subject — to the prompt inside <user-data> tags, and instructs the agent to report output.verdict as "approved" or "revise" with output.findings.
revisefollows the transition labelledREVISE; point it back at the step under review. That step re-executes and can read the findings via{{steps.<review name>.output.findings}}— put that template in its prompt so revisions use the feedback.approvedfollowsAPPROVED. The review step's stored result is rewritten so{{steps.<review name>.output}}resolves to the reviewed step's output; the review report itself moves to{{steps.<review name>.review}}(verdict,findings,iterations,forcedApproval).
maxIterations bounds the loop: once the review has run that many times, the engine follows APPROVED regardless and logs forcedApproval: true. A review can never loop indefinitely.
Engine-executed steps
start — required, exactly one
No config. Exactly one outgoing transition.
end — required, at least one
{ "summaryTemplate": "Fixed {{fixCount}} issues in {{input.target}}" } // optionalCompletes the instance; the resolved template is the completion message.
stop — abort
{ "message": "Aborted: {{steps.Safety Check.output.reason}}" } // optionalCancels the entire instance hierarchy — root and all children. Use as the target of a decision branch for unrecoverable situations. Distinct from end.
set_variable
{ "variableName": "fixCount", "valueTemplate": "{{steps.Apply Fixes.output.count}}" }Resolves the template, tries JSON.parse so objects, arrays, numbers and booleans survive as real values, falls back to the raw string. Available afterwards as {{fixCount}}. Use it to pin a value under a stable name, or to snapshot one before a loop overwrites the step result.
create_task_list — fan-out source
{ "source": "{{steps.Scan Repository.output.issues}}", "name": "issues" }The resolved source must be a JSON array of objects or the instance fails. Each element becomes a task { id, title: item.title, data: item, status: "pending" }. Design the producing step to output exactly that: an array, each item with a title and everything a worker needs.
get_next_task — loop head
{ "taskListName": "issues" } // optional; defaults to the most recent listRequires exactly two outgoing transitions labelled HAS_TASK and EMPTY. Validation does not catch a missing one; the instance fails at runtime. On HAS_TASK the next pending task's data becomes {{currentTask}}; on EMPTY every task is done.
complete_task — loop tail
No config. Marks the current task completed and clears {{currentTask}}. Route its transition back to get_next_task to close the loop:
create_task_list → get_next_task ──HAS_TASK──▶ [work steps] → complete_task ─┐
▲ │
└─────────────────────────────────────────────────────┘
└──EMPTY──▶ next step / end
sub_workflow — composition
{
"workflowId": "wf_child123",
"inputMapping": { "target": "{{input.target}}", "issue": "{{steps.Triage.output.id}}" }
}Starts the referenced workflow as a child; the parent resumes when it completes, and the child's result is stored like a step result. Enforced at publish: the child must exist and be published, and there are no recursion cycles. Maximum nesting depth at runtime: 10.
execute_task_list — one child run per task
{
"taskListName": "issues", // optional
"workflowId": "wf_fix_issue",
"inputMapping": { "issue": "{{currentTask}}" }
}Runs the referenced published workflow once per task — the compact form of the manual loop. Use the manual loop when you need extra steps between tasks; use this when each task is exactly one child run.