Skip to content
Talk to our solutions team

Flows and scripts

Both models run the same atoms. They differ in what they are made of, and that difference decides everything else — what happens on failure, what an operator can see, and how far the work can spread.

You write a flow as a list of named nodes. Before anything runs, the engine reads the whole definition, resolves every task it references, validates the parameters, and produces an execution plan. Only then does the first node run.

That up-front pass is the reason a flow behaves the way it does:

  • A typo in step nine fails before step one. A misspelled task name or a missing required parameter is a planning error, not a runtime surprise forty minutes into a build.
  • Every step is a checkpoint. The run’s state — which node completed, what it produced, what the variables now hold — is written down as it goes.
  • Steps can move. Because a node is a self-describing unit of work rather than a closure over local memory, the engine can hand it to a different worker, or a worker on a different machine.
name: nightly-backup
list: true
vars:
target: /srv/backups
tasks:
- name: dump
shell:
script: pg_dump app > /tmp/app.sql
setvar: dump_path
- name: compress
archive:
operation: compress
source: /tmp/app.sql
destination: "{{target}}/app.tar.zst"
- name: upload
s3:
command: upload
bucketname: backups
s3url: "{{minio.url}}"
commandparams:
files:
- path: "{{target}}/app.tar.zst"
destination: /nightly/app.tar.zst

Three nodes, three checkpoints. If the upload fails, the dump and the compression are still recorded as done.

You write a script as a function. It is compiled, called with your arguments, and returns a value. There are no nodes and no plan — the language’s own control flow is the control flow.

function main(threshold) {
const rows = db.query({ sql: 'select name, size from artifacts' });
const big = rows.rows.filter((r) => r.size > threshold);
return {
count: big.length,
total: big.reduce((s, r) => s + r.size, 0),
names: big.map((r) => r.name),
};
}
Terminal window
kis script run report.js 1048576

Try writing that as a flow and the awkwardness is immediate: filtering, summing and mapping are three lines of ordinary code and three nodes of ceremony. The flow’s checkpointing buys you nothing here, because there is nothing to resume — if it fails, you run it again.

FlowScript
UnitA node in a graphA function call
ValidatedWhole definition, before the first nodeAt compile, per file
StateRun variables, persisted per stepLocal variables, in memory
On failureThe failed node is known; the run can resume or route to an error branchThe process ends; nothing is retained
On machine lossAnother worker picks the run up from its last checkpointThe run is lost
ConcurrencyWorkers, and fan-out nodes over a collectionWhatever the language provides
DistributionAcross workers and machinesOne process
Read byWhoever operates itWhoever maintains it
Good atSequencing, fan-out, long runs, partial recoveryLogic, transformation, arithmetic, decisions

Everything above follows from one question: when this breaks at 3am, what do you want to be true?

A flow answers with position. The run stopped at upload; dump and compress completed; here is what the variables held. Resume it and the first two do not run again. Route the failure to a fail: node and it terminates with a typed error someone can alert on. On losing a worker mid-run, another one continues from the last checkpoint rather than starting over — restarting the whole run is available, but it is the opt-in.

A script answers with a stack trace. That is often the better answer: for a pure computation, position is meaningless and rerunning is free.

Choose the model that matches the cost of a partial failure. When redoing the work is cheap, a script’s simplicity wins. When redoing it means another forty minutes of build time or a second full table scan, a flow’s bookkeeping pays for itself the first time it is needed.

The choice is not architectural, and it is not permanent.

A flow runs a script. When one step needs real logic, the script: task embeds it — inline or from a file — using the same engine and calling convention as kis script:

tasks:
- name: pick-candidates
script:
language: javascript
file: ./select.js
setvar: candidates
next:
go: process
- name: process
map:
items_path: candidates
task: handle-one
max_concurrency: 4
- name: handle-one
shell: ./process.sh {{_item}}

The script decides what to work on; the flow fans out over the answer and survives losing a worker halfway through. Each model does the part it is good at.

A script becomes a flow. Because the calling convention is identical on both sides, a script you developed with kis script run moves into a flow unchanged — same file, same entry point, same arguments. There is no rewrite step, which is what makes starting with a script safe: you are not committing to it.

tasks:
- name: select
script:
language: javascript
file: ./select.js
args: [1048576]
setvar: result

The usual working order is: prototype as a script because the edit-run loop is a second long; move it into a flow when it needs to run somewhere other than your terminal.

Write a script first. Reach for a flow the moment you need the run to survive something — a failure, a restart, a machine, or a person other than you.