learn / 08·6 — under workflows · worlds

the graphNOBODYwrote

A world is what the kernel compiles a workflow into — components, inferred edges, computed imports and exports, nested sub-worlds, a schedule. You never write the graph and you never declare the interface, because both are computed from the plan in about thirty lines of set arithmetic. The interface is the residue of the dataflow.

worlds12 min read
A small figure standing before a vast self-assembling orrery of glowing rings — components clicking into orbit, bright wiring threading between them with no hand drawing the lines — monumental machine, tiny human, 1970s sci-fi style

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 / Bazelyes — by hand, separatelyyes — a second artifact
a worldno — edges are matchedno — 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

world /wɜrld/ noun

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:

#+begin_src — the component's interface, in six tokens
:depscomma-split list — [email protected],serde@1 — what gets compiled in-sandbox
:usesrepeatable — a capability the component needs; many declares, many caps
:outat most one — the named value this component produces
:inat most one — the named value it consumes (no fan-in, on purpose)
:persista bare flag, no value — opt in to durable state across runs
:dirpoint at a real project directory instead of the inline block
unknown tokens are silently ignored — the lens is small on purpose

Two 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.

world.wit — the kernel's own boundary
world oqlsix exports, all string → string
no importszero host calls — instantiates with no WASI context
a fault trapsinside Wasmtime — pure in, pure out
the kernel rides the same Component Model it imposes on user code

Six 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 :out is 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:list as one opaque string. The :type suffix only bites when check_upgrade compares 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.