Info
An earlier post, The Edges of a Static Graph, took an honest inventory of what
flow couldn't yet express. One item was the absence of a reusable unit: a
WorkflowDef was flat — nodes and edges, one level — so a common
draft → critique → revise triad had to be copy-pasted into every workflow that
wanted it. This post is about closing that gap, and about the one decision that
turned a scary multi-week change into a two-day one.
The obvious design is the expensive one
"Call a workflow as a node" sounds like inlining. Take the child's nodes, splice them into the parent graph, prefix their ids to avoid collisions, and let the parent scheduler run the whole thing as one big flattened graph. That is how you'd draw it on a whiteboard, and it is a trap.
The trap is that flow's runtime concerns are all keyed to a single flat graph. The
output store, the completed-set, the checkpoint, the trace frames — every one of
them is a dict keyed by node id. The token budget is one accumulator. Failure
isolation is per-node. Concurrency is one semaphore. Inline the child and you
inherit all of it twice: now you have to namespace checkpoints across two levels,
thread a step counter through nested scopes, bubble child tokens into the parent's
budget breaker, decide how a child's isolated failure maps onto the parent node,
and reason about a semaphore acquired inside a semaphore. None of it is
impossible. All of it is surface area, and surface area is where
interpret == compile goes to die.
Don't expand the child
The move that makes the whole thing cheap is refusing to inline. A sub-workflow node is opaque: from the parent's point of view it is one node that runs once and produces some outputs. What happens inside is the child's business.
Concretely, a type: "subflow" node just calls the same execute() the top-level
run uses, on the child, as a black box:
child_result = await execute(node.child, inputs=child_inputs, ...)
outputs[node_id] = project(child_result.runtime["out"])
That single decision evaporates the entire list above. The child's execute()
owns its own checkpoint (under a run-id qualified by the parent node), its own
trace, its own token accounting, its own isolation, its own semaphore. The parent
scheduler never learns any of it — it sees one node complete, exactly like a
script or an agent node. The five cross-cutting concerns don't get solved; they
get contained, one level down, by code that already exists.
The trade, stated honestly
Nothing is free. An opaque sub-workflow calls execute(), which lives in the
flow package — so a generated module that uses a sub-workflow now imports flow.
That is a real change: until now, generated modules were deliberately
flow-independent — they inline flow's helpers so the compiled artifact stands
alone on just ai and agent.
We took the trade, scoped tightly. A workflow with no sub-workflow node is
byte-for-byte as independent as before (there's a regression test that asserts the
generated module contains no import flow). Only a sub-workflow-using module gains
the dependency, and the --portable bundle simply vendors flow alongside ai
and agent when it detects one. In exchange we delete an entire category of
complexity — and, more interestingly, we make the core guarantee stronger.
Why this makes interpret == compile stronger
Here's the part I didn't expect. Normally every feature has to prove
interpret == compile — you write the interpreter path, you write the codegen
path, and a parity test runs the same workflow both ways to confirm they agree.
The two paths are different code, so the parity test is doing real work.
For sub-workflows there is nothing to prove. Both engines run the child by calling
the same execute() function. The interpreter calls it directly; the generated
module imports it and calls it. The child's semantics aren't matched across two
implementations — they're shared, because it is literally one implementation.
The compiled parent embeds the child as a JSON literal and hands it to the same
runtime the interpreter uses. Parity isn't a property we test for here; it's a
property we can't violate.
The ports tell the same story. A sub-workflow node doesn't declare its own inputs
and outputs — it derives them from the child's signature. The child's typed
$in (declared, or inferred from how each seed is consumed) becomes the node's
input ports; the child's $output becomes its output ports. There is no boundary
to keep in sync because there is no second declaration — the parent's interface to
the child is the child's signature.
What it looks like
The canonical example is the one the gap post named. A compose node wraps the
draft → critique → revise triad as a reusable child, referenced by path:
{ "id": "compose", "type": "subflow", "subflow": "./essay_compose.json" }
The child (essay_compose.json) is a complete, independently-runnable workflow —
you can run it on its own. Dropped into a parent as a subflow, its ports are
derived, its critic score flows back out to a script node that gates on it, and
the whole thing compiles to a Python module that calls execute() on the embedded
child. Both engines produce the same result on a live provider, because both call
the same function.
The shape of the lesson
The recurring pattern in flow's design is that the static graph is a constraint you design with, not against. Dynamic fan-out — the other capability gap from the edges post — got the same treatment: rather than teaching the scheduler about runtime-sized node sets, we kept the fan group as one scheduler node and let the parallelism happen inside it. Same instinct here. The parent graph stays static and flat; the dynamic, recursive, nested part is sealed inside a single node that, from the outside, is as boring as any other.
Sub-workflows ship as P6 — Expressiveness, alongside numeric conditions,
strict interpolation, and dynamic fan-out. The full design, including the resume
and recursion boundaries we deferred to a v2, lives in docs/subflow.md.