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