Files
app/docs/concepts/flows.md
T
stroblmeandClaude Opus 5 bdad6d7fc2
Docs / docs (push) Successful in 37s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m35s
Playwright Tests / test-playwright (2, 2) (push) Failing after 17s
pre-commit / pre-commit (push) Failing after 2m8s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m25s
Make the docs state things rather than argue them
The site read as a design journal: rationale paragraphs, hedges
("deliberately", "on purpose", "genuinely"), meta-commentary about the docs
themselves, and one em-dash every ten lines carrying an aside.

Roughly twenty rationale blocks are gone or reduced to what a reader needs
in order to use the thing. Em-dashes go from 507 to 135, and what is left is
structural rather than prose: list and definition separators, table cells,
and four inside code blocks that quote what the CLI actually prints.

Also: api.example.com becomes api.fluksio.com (the emails stay, since
bootstrap.py really defaults to admin@example.com and RFC 2606 reserves it);
the mqtt table gains the two settings it had drifted behind on and inject's
wording matches the engine; llms.txt lists the two connector pages that were
in the nav but not in it; and the two device/device_policy notes now agree.

Builds clean under `zensical build --strict`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:49:58 +02:00

8.8 KiB

Flows, nodes and messages

Three ideas hold the system up. Almost everything else follows from them.

A flow is a graph you did not draw

A flow is a set of nodes. Each node declares the messages it needs (requires) and the messages it produces (provides). The graph is whatever those declarations imply:

# node "read"
def process():
    return {"temperature": read_sensor()}

# node "decide"
def process(temperature, setpoint=21.0):
    return {"heat": temperature < setpoint}

decide is downstream of read because it needs temperature and read produces it. Nobody drew a wire.

The consequences:

  • Fan-in is free. Two nodes providing temperature are two producers of one message. The consumer does not change.
  • A node runs when something it reads was published. Not merely when something upstream of it ran: a node that produced nothing this time (held back by a rate limit, say) leaves what reads it on the value it already has, and so does everything behind that.
  • A wire cannot be wrong. There is no wire. There is a name that either matches or does not, and the canvas tells you at edit time which it is.
  • Layout is not a document. The canvas computes the arrangement, so a flow has no stored positions to maintain, merge or fight over.
  • Flows stay small. A graph nobody can hand-arrange is one worth keeping small, which is the intent. Several atomic flows that name each other beat one flow with sixty nodes in it.

Message names are namespaced

Inside flow house, a message named temperature is really house.temperature. A bare name is qualified with its own flow; a dotted name is used as written. That is how two flows share a value:

# in flow `dashboard`, reading a message that flow `house` produces
def process(house_temperature):     # port bound to "house.temperature"
    ...

The canvas draws messages arriving from another flow as labelled endpoints, so you can see where they come from without opening the other flow.

A node is a function with declared ports

Most nodes are Function nodes: a Python file defining process(...). Its arguments are its input ports by name; its return value is a dict keyed by output ports.

def process(reading, unit="C"):
    return {"shown": reading if unit == "C" else reading * 1.8 + 32}

reading is a port. unit is a setting: a constant of this node's code, typed into its panel and stored with the flow. Both arrive as arguments, which is why a setting may not share a name with a port. See Where a node's values come from.

The rest of the node types are the ones that would be tedious or unsafe to write yourself: MQTT, HTTP, InfluxDB, schedules, switches, notifications. Each one is configured by filling in a form the editor generates from its parameter schema, so they all behave the same way. The full list is in Node types.

Ports are typed

A port declares a dtype: float, int, str, bool, json, record, list, series, artifact, or one of the media types image, audio and video. Every value that passes through is checked against it.

Types are not decoration. They are what lets the dashboard editor offer you only the messages a gauge can actually draw, and what lets the canvas refuse a binding before anything runs. See Payload types.

Everything on the wire is JSON. Bytes (a checkpoint, an image, a model) travel as an artifact: the bytes go to a content-addressed store and the message carries a small reference to them. The media types are that same reference, saying what kind of bytes are behind it.

Streaming ports

A port marked stream produces repeatedly during one execution rather than once at the end: a training loss, a progress fraction, a frame from a camera, a second of speech. A node publishes on one by being a generator and yielding, or by calling fluksio.emit.

Each value is delivered to the nodes reading it, in the order it was produced, so a recogniser slower than the microphone in front of it still sees every chunk rather than only the newest. What is in state remains the latest value, which is what everything else reads, and what a run keeps is the whole series.

An interval on a streaming port thins what reaches the canvas without thinning the run's record of it.

Nodes are pure

A node is called with the values of the messages it declares and returns the values of the messages it provides. There is no context object, no global store, no handle to reach for.

That is deliberate: a node with hidden state cannot run twice in parallel, cannot be replayed, and cannot be moved to another machine. Plenty of real automations do need to remember something, and there is a specific way to say so; see Keeping state in a flow.

Two shapes of flow

Set mode on the flow:

live (default) batch
Runs continuously once per run, on request
Started by subscriptions, schedules, webhooks POST /runs/flows/{name}
Ends never when the graph drains
Keeps the last value of each message a run record: params, result, metrics, artifacts
Is a thermostat, an ETL job on a cron an experiment, a CI-style job

A batch flow is built and validated like any other, appears on the same canvas and is type-checked the same way. It is simply never activated: no subscriptions, no schedules, no webhooks. See Runs: pipelines that finish.

Editing is separate from running

Every flow has a published version and, while you are working, a draft.

  • Saving writes the draft. The engine keeps running the published version.
  • Publishing promotes the draft. The engine reloads and picks it up.
  • Discarding throws the draft away.

The store is a git repository (flow.json for the structure, nodes/*.py for the code) and each save is a commit. So a flow's history is readable with ordinary git tooling, and copying a flow between instances is copying a directory.

Saving carries the version you last saw. If someone else saved in between, you get a 409 instead of quietly overwriting their work.

What can be wrong, and when you find out

The canvas validates continuously and names problems on the nodes they belong to:

Issue What it means
unconnected_input a port needs a message nothing in reach provides
missing_initial_value the message exists but has never held a value, and nothing will give it one. Not reported on a batch flow: its inputs arrive with the run
cycle A waits for B and B waits for A — nothing could ever start
self_loop_needs_initial a node reads a message it also writes, with no starting value
node_error the node's code did not load: a syntax error, a missing import
unauthenticated_hook advisory — a webhook with no shared secret is open to anyone
node_unhealthy the node loaded but is not working: a connector that cannot reach its device, or whose last publication failed

A flow with any of these except the advisory one and node_unhealthy does not run. A node reporting itself down is a live condition, not a build error, so the rest of the flow keeps going and the issue clears by itself once the node reports well again. The health summary on Home counts them, so "why is nothing happening?" has an answer that does not involve reading logs.

What happens at runtime

  • A flow can be started and stopped. Stopped means its subscriptions and schedules are torn down.
  • A flow can be paused and stepped. Paused holds messages instead of running them; step releases exactly one. This is how you test something before it moves a relay.
  • A failing node does not take the flow down. It reports an error, keeps its last error visible after it recovers, and can fire an alert.
  • A flow whose background tasks keep crashing is quarantined. The engine stops restarting them and says so, rather than spinning. Publishing a change gives it another chance.

Values that arrive from outside

Some messages are not computed by any node: a dashboard control writes them, the API publishes them, a batch run passes them in. Declare those as the flow's inputs, with the value they start from:

{"inputs": [{"spec": {"name": "setpoint", "dtype": "float"}, "initial": 21.0}]}

Without that, the node reading setpoint waits for something nothing provides, and the canvas says so. With it, the flow starts at 21.0 and whatever writes the message afterwards takes over.

Where to next