Info
flow makes an unusual promise: the same workflow JSON either runs through an
interpreter or compiles to a self-contained, mypy --strict-clean Python
module, and the two agree node-for-node. We call it interpret == compile, and
it is the whole point of the kernel.
That promise is only cheap to keep because the graph is static. Every node and every edge is known at load time, so the compiler can emit one function per node and a fixed control-flow skeleton. Nothing about the shape of the run is decided while it runs. This is a genuine feature — it is why the generated code is readable, type-checks, and can be dropped into a host with no flow dependency.
But a static graph has edges, in both senses. Having just finished the runtime resilience roadmap — retries, checkpointing, an event stream, failure isolation, human-in-the-loop, deterministic reuse — we went looking for the honest answer to a different question: not "is it robust?" but "what can it not even say?" Here is what we found, smallest to largest.
The condition language can't compare numbers
A conditional edge in flow routes on one of five operators: equals,
contains, and the boolean combinators not, and, or. Look closely and
you'll notice the two leaf operators are both string operators — equals is
string equality, contains is substring. There is no >.
That sounds minor until you try to write the most natural agent loop there is:
draft, critique, and stop once the critic's score clears a threshold. The
score is a number. The kernel gives you no way to say score >= 0.8, so you end
up matching against a formatted string and praying nobody emits 0.80 instead of
0.8. The fix is small and local — four new operators, gt/gte/lt/lte, coerced
through the number path that already exists for script nodes, mirrored in the
one place codegen turns a condition into a Python expression. Low risk, high
everyday value.
Interpolation swallows your typos
flow fills prompts with a one-line template function: {{key}} is replaced by
the matching value, and a missing key becomes the empty string. That last clause
is the problem. Write {{cotnext}} instead of {{context}} and the template
doesn't error — it quietly drops an entire section of the prompt, the agent runs
on the degraded input, and the workflow succeeds. It is the worst kind of bug:
invisible, and dressed as success.
Here the static graph pays us back. Because every node declares its input ports,
we don't need to wait until runtime to catch this. Every {{key}} in a prompt
can be checked against the node's declared inputs at load time — before a
single token is spent — and an unknown key becomes a validation error that names
the typo and the node. Same check for both engines, because it runs before
either of them. Of everything in this post, this is the highest value for the
least code, and it turns a silent correctness hazard into a fail-fast.
The one real gap: you can't map over a list
The first two are conveniences and a safety net. This one is a capability the kernel genuinely lacks.
A loop in flow is a back-edge with a compile-time bound: run this cycle at most
N times. N is a literal, baked into the JSON, and the compiler turns it into a
plain for _ in range(N). What you cannot write is this:
The
plannode just produced seven subtasks. Run theworknode once per subtask, in parallel, then gather the seven results intomerge.
Seven is only known at runtime. This pattern — dynamic task mapping, or
scatter-gather — is the bread and butter of production engines
(Airflow's .expand(), Prefect's .map(), Temporal's child-workflow fan-out),
and flow simply can't model it. You can fake it by pre-declaring a fixed number
of parallel branches and leaving some idle, but the count has to be a constant
and the branches are wired by hand.
It is tempting to say this is impossible under interpret == compile, and when I
first looked I said exactly that. Reading the scheduler and the code generator
more carefully, I was wrong. Generated Python can absolutely express
await asyncio.gather(*[work(x) for x in subtasks]) — the fan-out itself is
easy. The hard part is everything around it: the trace and the port store are
keyed by node id, so ten instances of one node need ten distinct keys or they
collide; the checkpoint has to know which instances finished so a resumed run
re-runs only the rest; and "gather" needs a defined reduce — list, concat,
first-wins — which is a new kind of edge, not a flag. None of that is
intractable. All of it needs to be designed before it's coded, which is why
dynamic fan-out gets its own design pass rather than a quick patch.
Two things it is not: it is not a bug (the kernel does exactly what it was scoped to), and it is not a reopening of our distributed non-goal. Fanning a node across a runtime-sized list is single-machine parallelism — it runs through the same semaphore that already caps concurrency today.
What this says about the shape of the thing
The pattern across all three is the same. flow trades expressiveness for a
static, compilable graph, and most of what it "can't do" is the shadow of that
trade: stringly-typed data between agents, no sub-workflows to reuse a common
triad, while-loops you have to invert into bounded loops. Almost all of it is
either a small symmetric addition (numeric conditions), a load-time check the
static graph hands us for free (strict interpolation), or composition of
features we already have (structured agent data falls out of nested
interpolation plus the output_schema we already ship).
Only one item on the list — dynamic fan-out — actually widens what the kernel can express, and it's the only one that earns a design document before a single line of code. Everything else is the top-left quadrant: small, local, low-risk, and useful now.
The full analysis, with the priority matrix and a phased plan that keeps
interpret == compile intact at every step, lives in docs/expressiveness.md.
The roadmap phase tracking this work is P5 — Expressiveness.