Skip to content
Talk to our solutions team

Node types

Every entry under tasks: is a node. Which kind it is depends on which key it carries.

NodeKeyRuns on a workerPurpose
Taskan atom nameYesDo a unit of work
Assignassign:NoSet variables
Choicechoice:NoBranch on a condition
ForEachforeach:Yes, one at a timeIterate a collection in order
Mapmap:Yes, in parallelFan out over a collection
Waitwait:No — suspendsPause for a signal
SubWorkflowsubworkflow:No — suspendsRun another flow
Failfail:NoEnd the run with an error
Succeedsucceed:NoEnd 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.

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-steps
tasks:
- name: one
shell: echo one
- name: two
shell: echo two
- name: three
shell: echo three
one
execution 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 three

Or, for a flow that is genuinely a straight line, declare it as one with list: true and skip the routing entirely — see Sequences.

- name: deploy
shell: ./deploy.sh
next:
go: smoke-test
error:
go: rollback

error: is the edge taken when the node fails. Without one, a failure ends the run.

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: test

Where an atom takes only one obvious value, the short form works:

- name: build
shell: make build
next:
go: test

The 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.

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
FieldDefaultMeaning
max_attempts0How many times to retry
backoff_base0Delay before the first retry
backoff_max0Ceiling on the delay
backoff_multiplier2.0Growth per attempt
jitter_fraction0.1Randomness added, so retries do not synchronise
retryable_errorsallRetry only these error codes
non_retryable_errorsnoneNever 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.

Sets variables and continues. No worker, no operation.

- name: defaults
assign:
set:
region: us-west-2
environment: staging
features:
beta: false
next:
go: deploy

Keys 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.

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-review

Conditions 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.

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}}
FieldRequiredMeaning
items_pathYesDot-path to the collection — databases, deployment.targets
taskYes*The node to run for each item
sub_workflow_idYes*A flow to run for each item, instead of a task
iteratorA 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}}
FieldRequiredMeaning
items_pathYesDot-path to the collection
taskYes*The node to run for each item
sub_workflow_idYes*A flow to run for each item
max_concurrencyNoCeiling on parallel executions. 0 means unlimited
iteratorA 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:

Terminal window
kis flow -f build.yaml -w 4

Output 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
FieldMeaning
eventsThe signals this node accepts
timeoutHow long to wait — 30s, 5m, 24h

Each event takes:

FieldMeaning
nameThe signal name to match
requiredFields the signal must carry, or it is rejected
goWhere to route on receiving it
onA script that decides where to route, instead of go:
on_langThe 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.

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
FieldRequiredMeaning
definition_idYesThe child flow’s id or name
input_mappingNoparent_field: child_field
output_mappingNochild_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.

Ends the run successfully. Terminal.

- name: done
succeed: true

Needed 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.