Node types
Every entry under tasks: is a node. Which kind it is depends on which key it carries.
| Node | Key | Runs on a worker | Purpose |
|---|---|---|---|
| Task | an atom name | Yes | Do a unit of work |
| Assign | assign: | No | Set variables |
| Choice | choice: | No | Branch on a condition |
| ForEach | foreach: | Yes, one at a time | Iterate a collection in order |
| Map | map: | Yes, in parallel | Fan out over a collection |
| Wait | wait: | No — suspends | Pause for a signal |
| SubWorkflow | subworkflow: | No — suspends | Run another flow |
| Fail | fail: | No | End the run with an error |
| Succeed | succeed: | No | End the run successfully |
Nodes that do not reach a worker are handled inside the engine. They cost effectively nothing, so there is no reason to avoid them for tidiness.
Routing
Section titled “Routing”A flow is a graph, and its edges are the next: keys. There is no fall-through.
This is the first thing to get right, because getting it wrong fails quietly. A node without
next: is a terminal node — the engine reaches it, runs it, and the run is over:
name: three-stepstasks: - name: one shell: echo one - name: two shell: echo two - name: three shell: echo threeoneexecution engine instance Status: completed Completed Nodes: [one]One node ran, and the run is reported completed — not failed. Writing the nodes underneath each other looks like a sequence and is not one.
Chain them explicitly:
tasks: - name: one shell: echo one next: go: two - name: two shell: echo two next: go: three - name: three shell: echo threeOr, for a flow that is genuinely a straight line, declare it as one with list: true and skip the
routing entirely — see Sequences.
error:
Section titled “error:”- name: deploy shell: ./deploy.sh next: go: smoke-test error: go: rollbackerror: is the edge taken when the node fails. Without one, a failure ends the run.
Setting variables on the way through
Section titled “Setting variables on the way through” next: go: notify set: status: deployed deployed_at: "{{_result}}"set: writes variables as routing happens, which saves an assign: node when all you need is a
flag or two.
The common case: a node that runs one atom.
- name: build shell: script: make build setvar: build_log next: go: testWhere an atom takes only one obvious value, the short form works:
- name: build shell: make build next: go: testThe key is the atom’s name. Which names are available depends on the engine — see Flow engines — and every one of them is documented in the atom reference.
Retries
Section titled “Retries”Any task node can retry:
- name: call-api shell: curl -f https://api.internal/process retry: max_attempts: 3 backoff_base: 500ms backoff_max: 5s error: go: give-up| Field | Default | Meaning |
|---|---|---|
max_attempts | 0 | How many times to retry |
backoff_base | 0 | Delay before the first retry |
backoff_max | 0 | Ceiling on the delay |
backoff_multiplier | 2.0 | Growth per attempt |
jitter_fraction | 0.1 | Randomness added, so retries do not synchronise |
retryable_errors | all | Retry only these error codes |
non_retryable_errors | none | Never retry these. Takes precedence |
The delay is min(backoff_base × multiplier^attempt, backoff_max) plus jitter. error: is
followed only once the attempts are exhausted.
Retry what is transient — rate limits, lock contention, a network blip. Retrying a failure caused by bad input just takes longer to reach the same place.
Assign
Section titled “Assign”Sets variables and continues. No worker, no operation.
- name: defaults assign: set: region: us-west-2 environment: staging features: beta: false next: go: deployKeys are written into the run’s variables, overwriting any that already exist. Nested maps and lists are fine.
pass: is accepted as an older name for the same node.
Choice
Section titled “Choice”Evaluates conditions in order and routes to the first that matches.
- name: route choice: rules: - condition: "status === 'approved' && priority === 'high'" next: fast-track - condition: "status === 'approved'" next: standard - condition: "status === 'rejected'" next: reject default: needs-reviewConditions are JavaScript expressions. Every variable in the run is available by name, so
count > 10, user.name.startsWith("svc-") and typeof total === 'number' all work.
Order decides the outcome. The first matching rule wins, so put the most specific condition
first — reversing the first two rules above would send every approved request to standard.
Give a default:. Without one, a run where nothing matches ends in an error rather than going
anywhere useful.
ForEach
Section titled “ForEach”Runs a task once per item, in order, waiting for each before starting the next.
- name: migrate-each foreach: items_path: databases task: run-migration next: go: verify
- name: run-migration shell: ./migrate.sh {{_item}}| Field | Required | Meaning |
|---|---|---|
items_path | Yes | Dot-path to the collection — databases, deployment.targets |
task | Yes* | The node to run for each item |
sub_workflow_id | Yes* | A flow to run for each item, instead of a task |
iterator | — | A generated source, instead of items_path |
* One of task or sub_workflow_id. (task_name is accepted as an older spelling of task.)
Inside the target, {{_item}} is the current item and {{_index}} its position. When it finishes,
_results holds every result in order.
An empty collection is not an error — the node completes and routing continues.
Use foreach: when order matters or when parallel work would collide: schema migrations, rolling
restarts, anything touching shared state.
The same, in parallel.
- name: build-all map: items_path: images task: build-one max_concurrency: 4 next: go: publish
- name: build-one shell: docker build -t {{_item}} ./{{_item}}| Field | Required | Meaning |
|---|---|---|
items_path | Yes | Dot-path to the collection |
task | Yes* | The node to run for each item |
sub_workflow_id | Yes* | A flow to run for each item |
max_concurrency | No | Ceiling on parallel executions. 0 means unlimited |
iterator | — | A generated source, instead of items_path |
Give the run enough workers. Concurrency is bounded by the worker count as well as by
max_concurrency, so max_concurrency: 4 with the default single worker runs one at a time:
kis flow -f build.yaml -w 4Output from parallel nodes is buffered per item and flushed whole, so logs stay readable instead of interleaving.
_results is indexed by position, so item three’s result is at index three regardless of which
finished first.
Suspends the run until a signal arrives or a timeout expires.
- name: await-approval wait: events: - name: approved required: [approver] go: deploy - name: rejected required: [reason] go: notify-rejection timeout: 24h error: go: handle-timeout| Field | Meaning |
|---|---|
events | The signals this node accepts |
timeout | How long to wait — 30s, 5m, 24h |
Each event takes:
| Field | Meaning |
|---|---|
name | The signal name to match |
required | Fields the signal must carry, or it is rejected |
go | Where to route on receiving it |
on | A script that decides where to route, instead of go: |
on_lang | The script’s language. Defaults to JavaScript |
A suspended run holds no worker. Waiting a day costs nothing.
Signal data lands in _signal, and its fields are also available directly by name.
When static routing is not enough, on: decides at run time. It must call flow.go() exactly
once:
events: - name: review_complete on: | if (score >= 80) { flow.go("fast-track"); } else { flow.go("standard-review"); }On timeout the error: route is followed, with _wait_timeout set to true. A wait: node with
a timeout and no error: route fails the run when it expires — which is sometimes right, but it
should be a decision rather than an omission.
SubWorkflow
Section titled “SubWorkflow”Runs another flow and waits for it.
- name: deploy-service subworkflow: definition_id: service-deploy input_mapping: app_name: service_name environment: target_env output_mapping: deploy_status: result_status next: go: summary| Field | Required | Meaning |
|---|---|---|
definition_id | Yes | The child flow’s id or name |
input_mapping | No | parent_field: child_field |
output_mapping | No | child_field: parent_field |
Without input_mapping the child receives the parent’s whole variable set; without
output_mapping the child’s whole result is merged back. The mappings exist to narrow that —
worth doing, because an explicit contract is what lets the child be reused without surprises.
The child is a separate run with its own record. The parent resumes when the child reaches a
succeed: node.
Ends the run with a typed error. Terminal — no next:.
- name: invalid-input fail: error: ValidationError cause: "region is required"Short form, when the type does not matter:
- name: abort fail: "nothing to deploy"_error and _cause are set, and the run is marked failed. A typed error is worth the extra line
when something downstream alerts on it — ValidationError and UpstreamTimeout can be handled
differently; two runs marked “failed” cannot.
Succeed
Section titled “Succeed”Ends the run successfully. Terminal.
- name: done succeed: trueNeeded when a flow has several possible endings, or when a child flow must signal completion to its parent. A flow that simply runs off the end of its list does not need one.
See also
Section titled “See also”- The flow file — the keys around
tasks: - Execution — what happens when a node fails
- Atom reference — what a task node can run