the graph nobody WROTE
The parent lesson ends on a single
diagram: a workflow with two components compiles into a world that
exports: site. It's true, and it's one breath — render and
publish go in, a deployable unit comes out. But it has the smell of a
marketing claim. Compiles how? Nobody wrote the wire between
render and publish. Nobody declared an interface. Yet the engine somehow
gets a typed-looking, deployable thing with a public surface. Either
there's a real algorithm under that arrow, or there's a demo-ware trick.
Every other orchestrator makes you author the graph by hand. Airflow has
you write the DAG in Python. GitHub Actions makes you spell out
needs: on each job. Bazel wants explicit deps.
And separately — in a second artifact that drifts from the first — you
write the interface: a WIT world, a protobuf, an OpenAPI spec. Two graphs
to keep honest, and an agent reviewing your plan can't read either when one
lives in a UI and the other in a sidecar file.
| you write the graph? | you write the interface? | |
|---|---|---|
| Airflow / Actions / Bazel | yes — by hand, separately | yes — a second artifact |
| a world | no — edges are matched | no — the surface is computed |
The whole of this page is the question that table sets up: what if both were computed from the plan you already wrote — and the computation were small enough to run in your head?
the WORLD
1. the build plan the kernel's
tangle_plan emits from a workflow —
components, inferred data-flow edges, computed imports and
exports, nested sub-worlds, and a schedule — one JSON object per
:workflow: headline, deliberately shaped like a WIT world.
One :workflow: headline in, one world out. The shape is
fixed — seven keys, no more:
{ name, schedule, imports, exports, components, edges, workflows }
components are the tasks you wrote. edges,
imports, and exports are the three things you
didn't — the kernel works them out. workflows holds
nested sub-worlds (a world contains worlds). schedule rides
along from an org timestamp. The kernel that emits this is about 550 lines
of pure Rust — no host calls, the same wasm in every surface — and
tangle_plan is one of its six exports. This page is that one
export, told properly. The crate itself has its own
lesson.
six words on a begin_src LINE
A component's whole interface is the header args on its first source block. There are six, and that's the entire authoring surface — the grammar for writing them lives in tangling; here's what each one becomes in the world:
[email protected],serde@1 — what gets compiled in-sandboxTwo of those carry weight the parent never mentioned. :in
and :out are each single — a component declares at most
one input and one output. That's not an oversight; it's the shape that
makes the edge inference a clean name-match, and it's a real limit we'll be
honest about later. :uses is the opposite — repeatable, so one
component can declare several capabilities. The component's name is just
the headline title with the trailing tags stripped.
matching out to IN
Here is the first half of the inference, and it is almost embarrassingly
small. Build a map from each :out name to the component that
produces it. Then for every component with an :in, look up a
producer of that exact name. If one exists, emit an edge
{from: producer, to: consumer}. That's it.
Take the real Nightly digest workflow — three components, three languages, written without a single arrow between them:
* Nightly digest :workflow: SCHEDULED: <2026-06-06 Sat 06:00 +1d> ** Fetch events :component: #+begin_src rust :deps [email protected],serde@1 :uses workbook:vfs/query :out events:list ** Summarize :component: #+begin_src js :in events:list :uses workbook:llm/complete :out summary:string ** Send :component: #+begin_src go :in summary:string :uses workbook:net/email
Fetch produces events:list; Summarize consumes a name
spelled exactly events:list — edge. Summarize produces
summary:string; Send consumes summary:string —
edge. The graph draws itself:
flowchart LR fetch["Fetch events
:out events:list"] sum["Summarize
:in events:list · :out summary:string"] send["Send
:in summary:string"] fetch -- "events:list" --> sum -- "summary:string" --> send style fetch fill:#f2ddb0,stroke:#121316 style sum fill:#a8d4f0,stroke:#121316 style send fill:#aee5c2,stroke:#121316
The match is whole-string equality on the name. The
:string half of summary:string is a convention
living inside the name, not a parsed type — the matcher sees one opaque
string and compares it to another. Fan-out works: one producer feeds many
consumers, each gets its own edge. Fan-in does not — a single
:in means a component can never join two upstreams. That
asymmetry is load-bearing, and we'll come back to it.
the surface is the RESIDUE
Now the inversion — the moment the whole page turns on. You have the
edges. So ask the producer map a single question: which outputs did
nobody consume? Those leftover outputs are, by definition, the
world's public surface. Exports are not declared. They're computed as
the unconsumed outputs. Imports are just as cheap: the sorted union of
every component's :uses.
You never write the interface because the interface is the dataflow's residue. Run that rule against the digest and something striking falls out:
imports : [ workbook:llm/complete, workbook:net/email, workbook:vfs/query ]
edges : [ Fetch events → Summarize, Summarize → Send ]
exports : [ ]
schedule: { at: 2026-06-06T06:00, repeat: +1d, active: true }
Exports is empty — and that emptiness is information. Every output this workflow produces is consumed inside it; the digest is a closed pipeline whose only effect on the world is the email capability it imports. The plan is telling you, mechanically: this world has no API, only effects. Leave one output loose — wire nothing to it — and that becomes your export, your public surface, without you ever typing the word.
Contrast it with a pipeline that leaves a value dangling. The
Transform sub-world below ends on a Score
component whose score:f64 nobody downstream consumes:
flowchart LR
subgraph A["Nightly digest"]
direction LR
a1["Fetch"] --> a2["Summarize"] --> a3["Send"]
ax["exports: [ ]"]
end
subgraph B["Transform"]
direction LR
b1["Clean"] -- "clean:json" --> b2["Score"]
bx["exports: [ score:f64 ]"]
end
style ax fill:#d9dbd3,stroke:#121316
style bx fill:#13d943,stroke:#121316,stroke-width:2.5px
Same algorithm, two verdicts. The digest consumes everything, so its
surface is empty — pure effect. Transform leaves score:f64
unconsumed, so its surface is exactly that one value. The author chose
neither; the dataflow decided.
worlds inside WORLDS
Depth rung. The compiler walks the headline tree, and a
:workflow: headline nested inside another isn't a component —
it's a sub-world, compiled by the same recursion, its subtree
skipped at the parent level. A :component: child joins this
world's component list; anything else is skipped. The whole tree becomes
one nested DAG.
Here is the real nested example — a Pipeline whose middle task is itself a workflow:
* Pipeline :workflow: ** Ingest :component: :out raw:bytes ** Transform :workflow: *** Clean :component: :in raw:bytes :out clean:json *** Score :component: :in clean:json :out score:f64 ** Publish :component: :in raw:bytes
flowchart TD
subgraph P["Pipeline (parent world)"]
direction LR
ing["Ingest
:out raw:bytes"]
pub["Publish
:in raw:bytes"]
ing -- "raw:bytes" --> pub
subgraph T["Transform (sub-world)"]
direction LR
cl["Clean
:in raw:bytes"] -- "clean:json" --> sc["Score"]
tx["exports: [ score:f64 ]"]
end
end
px["Pipeline exports: [ ]"]
style P fill:#fbfaf6,stroke:#121316
style T fill:#ffffff,stroke:#121316
style tx fill:#13d943,stroke:#121316
style px fill:#d9dbd3,stroke:#121316
The verified output: the parent Pipeline has components
[Ingest, Publish], one edge Ingest → Publish on
raw:bytes, and exports: []. The nested Transform
has [Clean, Score], edge Clean → Score on
clean:json, and exports: [score:f64] — the
unconsumed value surfaces on the inner world only, because
inference is scoped per nesting level.
And the honest seam: Clean declares :in raw:bytes, but no
producer of raw:bytes exists inside Transform — so
Clean gets no edge. At run time a sub-workflow receives the original
workflow input, not the parent's intermediate value; cross-boundary
dataflow isn't edge-wired. This page owns the algorithm; the story
of combining workbooks — grafting, surfaces, the flat forest — is
nesting's.
the two flags the diagram SKIPS
The parent diagram never mentions the two header args that change what a component is rather than how it wires. Both deserve a plain account, because both are easy to imagine wrongly.
:persist is a bare flag — Motoko-style orthogonal
persistence. Mark a component :persist and it opts into durable
state across runs. The Agent workflow below declares it on
Remember:
* Agent :workflow: ** Remember :component: :persist :out memory:json ** Act :component: :in memory:json
Ask the engine which components survive a redeploy and it answers with a
one-item list — verified: durable_components(tangle_plan(...))
returns ["Remember"]. But here is the part worth stating
plainly, because the intuition is wrong: :persist is
not a snapshot of Wasm linear memory. Components are stateless
between run calls. :persist is the contract
that a component checkpoints its state to the VFS — and the VFS is the
orthogonal-persistence layer, where freeze/resume copies it and Litestream
replicates it. The durable thing is a file, not a frozen heap. Where that
state actually lives is vfs and sync.
sequenceDiagram participant C as a component participant V as the VFS (durable) Note over C: run 1 C->>V: checkpoint state (only if :persist) Note over C: process exits — memory gone either way Note over C: run 2 (after redeploy) V-->>C: state restored (only if :persist) Note over C: without :persist — starts clean
:dir is the escape hatch. Instead of an inline source
block, point the component at a real project directory with its own
Cargo.toml or package.json. That's Mode 2 — a full project compiled
entirely in-sandbox (for Rust, mrustc.wasm hands off to clang.wasm; native
cargo is never invoked). The inline path is Mode 1 — a
content-addressed cache, each block keyed by a sha to a built
.wasm, with :deps fetched and compiled in the
sandbox too. Both lanes are the in-sandbox compilers behind
toolkit and lanes; the plan just
records which one a component uses.
the shape is a CONFESSION
Why these exact seven keys? Why imports and
exports and nested workflows — the vocabulary of a
WIT world? Because the kernel that emits worlds is itself a WIT
world, and it eats its own constraint.
string → stringSix string-to-string exports, no imports, no unsafe, no
hand-rolled pointer ABI — bindings generated by cargo-component. The same
Rust functions link two ways: a native rlib into the wb CLI,
and the wasm Component the BEAM embeds and calls. There is no separate
"wasm version" of the kernel logic.
So the plan is WIT-shaped because worlds are meant to be WIT
worlds. Two proofs that the shape isn't decoration. First: the conformance
demo runs the kernel itself against the workbooks:engine
contract and the kernel is rejected — it has no run
export, so it is correctly not an engine. Worlds are distinct contracts,
enforced. Second: the kernel keeps a comp_out map — each
component's name and output — so the upgrade gate can notice when an output
type changed. That's the moment the :type suffix in
summary:string stops being decoration and becomes
load-bearing. It's invisible to the edge matcher and decisive at upgrade
time — covered in upgrades.
who reads a WORLD
One plan, many readers — that's the payoff. The same JSON is build instructions, an execution graph, a capability manifest, and an upgrade contract, and each consumer takes the field it needs:
flowchart TD w[["a world (JSON)"]] w --> pm["PackageManager
builds the components"] w --> wv["waves
runs them in topological order"] w --> dk["the Dock
grants from imports"] w --> up["check_upgrade
diffs two worlds"] w --> va["validate
pre-flights the plan"] w --> sc["schedule field
rides along"] style w fill:#f2ddb0,stroke:#121316,stroke-width:2.5px style wv fill:#13d943,stroke:#121316
The edges-become-pipes story is two real lines. The runner builds a consumer-to-producer map from the world's edges, then a node's stdin is either its producer's stdout or — if it has no edge — the workflow input:
producer = Map.new(world["edges"], fn e -> {e["to"], e["from"]} end)
in_data = if from = producer[name], do: acc[from], else: input
From there: components run in topological waves — each wave is the
set of steps whose predecessors are done — parallel within a wave, up to
eight at once, a long per-step timeout. Depth on that belongs to
waves. A component whose language is agent
runs the agent loop instead: its source block is the system prompt, its
piped input is the task. The imports field becomes the
capability grants the Dock hands out —
workbook:vfs/query, workbook:llm/complete,
workbook:net/email are WIT-style namespace:interface/func
paths, covered in capabilities.
check_upgrade diffs two worlds (upgrades);
validate pre-flights one (validations);
the schedule record is read by schedules
and rendered onto boards. The org headline IS the DAG —
no DSL, no board model, the runtime just executes the plan.
dumb on PURPOSE
Honesty section, and the honesty is the design argument. The inference is deliberately small, and small means it has sharp edges you should know before they bite.
- One input per component — no fan-in. A component can't join two
upstreams; there's a single
:in. Need to merge two producers? Restructure so something upstream emits the combined value. Joins are a real gap, not a bug. - Duplicate
:outis silently last-wins. Two components producing the same name — the producer map keeps the last one, no warning. Name your outputs apart, or a consumer will quietly wire to a producer you didn't mean. (Whether that silence is intentional is uncertain; that it happens is verified.) - Names are not types — until upgrade. The matcher sees
events:listas one opaque string. The:typesuffix only bites whencheck_upgradecompares the recorded outputs of two worlds. Between then it's pure convention. - Sub-world boundaries aren't dataflow-wired. A sub-world's component consuming a parent name gets no edge and, at run time, the workflow's original input. The flat validator won't flag it either.
- Execution is host-piped today, not WIT-composed. The runner composes stdin/stdout filters without WIT — and is honest about its tier: typed in-WASM composition (wac plug) is the declared upgrade, and it needs WIT-declared components. The WIT shape is ahead of the WIT plumbing, on purpose.
None of this is a hedge. Thirty lines of set arithmetic you can predict by hand beat a planner you can't — that trade is the point. A surface you can compute in your head is a surface you can trust.
questions people actually ASK
Is the type suffix in events:list checked?
Not by the edge matcher — it compares whole names as opaque strings,
so events:list only matches events:list. The
suffix becomes load-bearing exactly once: when check_upgrade
diffs two worlds and notices a component's output type changed. Convention
everywhere else, a gate at upgrade time.
Can two components produce the same output name?
They can, and the last one silently wins — the producer map just keeps the most recent insert. No error. Name them apart, because a consumer of that name will wire to whichever producer landed last, and that's rarely what you meant.
Can a component take two inputs?
No — a single :in, so no fan-in or joins. Restructure so
something upstream emits the combined value, then consume that. Fan-out
is fine: one producer can feed many consumers.
Do I ever write a world by hand?
Never. A world is compiled output — the org workflow is the source, and
tangle_plan emits the JSON. You write headlines and six
header args; the kernel computes the edges, imports, and exports. Editing
the world directly would be editing the build artifact, not the build.
Where does the schedule go?
Into the world record — a :SCHEDULE: property becomes a
cron field, else an org SCHEDULED: timestamp becomes
{at, repeat, active}, else null. The schedule rides in
the world; firing it is the engine's job, covered in
schedules.
Is this the same world as WIT's world?
Same idea, same shape — imports, exports, nested worlds — at the plan level rather than the bytes level, for now. The kernel itself ships as a real WIT world, and the plan is shaped to match so worlds can someday be wac-plugged without changing the format. The shape is ahead of the plumbing, deliberately.
keep GOING
Worlds are one export of the kernel and the input to everything that runs — these four are the natural next reads.