learn / 01·2 — under workbook · nesting

workbooksINSIDEworkbooks

Every headline in a workbook is the root of a complete, compilable workbook. So combining two systems is a paste, splitting one out is a gated prune, and one file carries the whole nested plan — every level typed, every level schedulable. And the one place nesting never recurses, on purpose: the sandbox.

nesting12 min read
A small gardener grafting a sapling onto a colossal bright tree whose every branch is itself a whole tree, canopies within canopies receding upward into sunlit haze — 1970s sci-fi style

combining systems is a MIGRATION

Composition is the thing software is worst at. Two working projects, one obvious move — put them together — and the industry's answers are all surgery: git submodules, a package registry and a version matrix, or the full monorepo migration with its weeks of build-system archaeology. And un-combining is worse. Ask anyone who has tried to extract a service from a codebase it grew into: nothing comes out clean, because nothing was ever a unit in the first place.

The parent lesson made a simple claim: one workbook is one file — the app, its disk, its plans, all of it. Which leaves exactly the question a person with ten workbooks actually has. Do these things compose? Or is the ecosystem's endgame a folder of beautiful silos, each one whole and none of them able to join?

This page is the answer, and it has three beats. Combining is a paste. The paste compiles. And the prune — the un-combining — is gated, so taking systems apart is the one move that finally tells you who breaks.

the DEFINITION

nest·ing /ˈnɛs·tɪŋ/ noun

1. the property that a workbook is a tree whose every subtree is itself a complete, compilable workbook — so combining systems is grafting a subtree in, splitting them is pruning one out, and the prune is gated.

The unit underneath is the subtree, and the kernel defines it in one line: a headline plus everything deeper, until the level pops back. That boundary — subtree_end() in the kernel — is what the compiler skips over when it consumes a nested workflow, and it's the same boundary your editor moves when you cut and paste a branch of the outline. Where the subtree ends is where the unit ends. Everything else on this page is consequences.

the paste that COMPILES

Here's the experiment, run for real against the kernel. Take a complete workflow — the Pipeline you'll meet fully in the next section — and graft it into a different workbook: demote every headline two levels (* becomes ***) and paste the whole subtree under someone's prose, two layers deep in their notes file. Then tangle both files.

The compiled world is identical. Same components, same edges, same exports, same sub-worlds — byte for byte. Not similar. The same.

flowchart LR
  p["pipeline.org
* Pipeline :workflow:
(its own file, top level)"] g["company.org
* Company notes
** Some prose section
*** Pipeline :workflow:"] w[["one world JSON
byte-identical either way"]] p -. "demote two levels, paste the subtree" .-> g p -- "wb tangle" --> w g -- "wb tangle" --> w style p fill:#ffffff,stroke:#121316 style g fill:#ffffff,stroke:#121316 style w fill:#13d943,stroke:#121316,stroke-width:2.5px

The reason is small and structural: every comparison in the compiler is relative. A child is anything deeper than its parent — deeper than, never depth three. No absolute level appears anywhere in the compile, and top-level worlds are found wherever they stand: any :workflow: headline not already consumed by a parent becomes a world, whether it sits at level one or level five. So every workbook is graft-ready by construction. Nobody designed an import mechanism; the grammar's levels being relative is the import mechanism.

And notice what the move is made of. There is no graft verb in the CLI — no wb merge, no manifest to update, no registry round-trip. Grafting is a text edit: cut the subtree, fix the levels, paste, re-tangle. Which means an agent can do it with the one tool agents actually have, and you can review it the way you review any edit — as a diff.

one tree, one DAG

The graft would be a parlor trick if the paste were only storage. It isn't — the nested tree compiles, nested. The workflows lesson ended with headline-nesting as a planning convenience (plans hold plans) and a single-level compile. This is where those two ideas meet. Here's the whole worked example:

* Pipeline                                          :workflow:
** Ingest                                           :component:
   #+begin_src rust :out raw:bytes
   #+end_src
** Transform                                        :workflow:
*** Clean                                           :component:
    #+begin_src js :in raw:bytes :out clean:json
    #+end_src
*** Score                                           :component:
    #+begin_src rust :in clean:json :out score:f64
    #+end_src
** Publish                                          :component:
   #+begin_src go :in raw:bytes
   #+end_src

One workflow, three tasks — and one of the tasks is a workflow. The compiler, build_world(), is literally recursive: walking the children, a :component: child becomes a leaf; a :workflow: child triggers the same function on the subtree, then skips past it. wb tangle pipeline.org emits one plan (trimmed, real output):

{"worlds":[{"name":"Pipeline","exports":[],"imports":[],
  "edges":[{"from":"Ingest","to":"Publish"}],
  "workflows":[{"name":"Transform","exports":["score:f64"],
    "edges":[{"from":"Clean","to":"Score"}],"workflows":[]}]}]}

Read the shape before the values: the sub-world is not a stub or a reference — it's a full world, the same JSON shape at every depth, with its own components, edges, and exports, and room for sub-worlds of its own. Each level can even carry its own clock — a :SCHEDULE: cron property or a native SCHEDULED: timestamp on any workflow headline, so a monthly parent can hold an hourly child.

flowchart TD
  subgraph P["Pipeline  :workflow: — exports nothing"]
    I["Ingest
:component: · out raw:bytes"] subgraph T["Transform :workflow: — exports score:f64"] C["Clean
:component:"] -- "clean:json" --> S["Score
:component:"] end Pub["Publish
:component:"] I -- "raw:bytes" --> Pub I -. "raw:bytes — declared, not piped yet" .-> C end style P fill:#fbfaf6,stroke:#121316 style T fill:#e8f3fa,stroke:#121316 style I fill:#ffffff,stroke:#121316 style C fill:#ffffff,stroke:#121316 style S fill:#ffffff,stroke:#121316 style Pub fill:#ffffff,stroke:#121316

The org tree and the execution DAG are the same object at different zoom levels — zoom out and it's an outline a person edits; zoom in and it's a typed plan an engine runs. And you can run this yourself, right now, with nothing docked: wb tangle, wb query, and wb lint are local kernel verbs — the same ~550-line pure Rust kernel, built into the CLI, also served over HTTP and WebSocket. One kernel, every surface, no runtime required to see your own plan.

what a world shows OUTSIDE

depth rung · skippable — how the kernel computes each world's typed surface

Every world in that JSON — at every depth — gets a computed signature, and the rules are short enough to state exactly. Edges: one component's :out name matches another's :in. Imports: the sorted union of every component's :uses capability strings — names like workbook:vfs/query, workbook:llm/complete, workbook:net/email, the same capabilities toolkits fulfill. Exports: the outputs nobody inside the world consumed. That last rule is the elegant one — consumption is encapsulation. Trace it through the Pipeline:

component:in:outconsumed byso the surface says
Ingestraw:bytesPublishraw:bytes vanishes — internal
Transform / Cleanraw:bytesclean:jsonScoreclean:json vanishes — internal
Transform / Scoreclean:jsonscore:f64nobodyTransform exports score:f64
Publishraw:bytesa sink — exports nothing

That's why Pipeline exports [] while its own sub-world exports score:f64 — the parent's raw bytes were eaten by Publish, the child's score was eaten by no one. The names are typed by convention, name:typeraw:bytes, clean:json, score:f64 — and the imports column is empty here only because no component declares :uses; add :uses workbook:net/email to one block and the world's surface grows that import.

If imports-exports-typed-names sounds familiar, it should: the plan is deliberately shaped like a WebAssembly WIT world. The kernel takes its own medicine — it ships as a WIT-typed component, workbooks:oql, exporting tangle-plan and check-upgrade as plain string-to-string functions with no host imports at all. The thing that computes the surfaces has one.

grow free, shrink GATED

Pasting in is half of composition. The other half — the half nobody ships — is taking things back apart without silently breaking whoever depended on the part you removed. That's check_upgrade: diff the old plan's signatures against the new plan's, in the style of interface subtyping. The rules, exactly as the kernel applies them:

change between old and newverdictwhy
exports growsafenew consumers become possible; existing ones are untouched
export removederrorsomeone downstream may be reading it
new import requiredwarna new capability ask — flagged, not forbidden; granting is the engine's call
component output type changederrorevery consumer parses that type
workflow removederrorpruning a subtree takes its exports with it

Here's a real run — a one-component world whose new version starts emailing and changes its output from string to json. Three diagnostics, each named and scoped:

[{"level":"error","message":"export `report:string` removed (breaking)","scope":"Report"},
 {"level":"warn","message":"new capability `workbook:net/email` now required","scope":"Report"},
 {"level":"error","message":"component `Build` output type changed (breaking)","scope":"Report"}]

Now the nesting payoff. Prune the Transform subtree out of the Pipeline and gate old against new — two errors, two scopes:

[{"level":"error","message":"export `score:f64` removed (breaking)","scope":"Pipeline"},
 {"level":"error","message":"workflow removed (breaking)","scope":"Transform"}]

Run the same gate the other direction — old is the flat Pipeline, new is the nested one — and the answer is []. Empty. Growing the tree is always safe; pruning is gated. That asymmetry is the page's thesis in one diff: graft freely, and when you finally split the product back into two workbooks, the gate hands you the exact list of promises you're about to break — named, scoped, before anything deploys.

One subtlety is worth being precise about, because it explains why Pipeline appears in that error at all. The gate computes each workflow's signature over the flat set of all component descendants — so a parent's upgrade-signature includes its sub-worlds' exports, which is exactly why Pipeline is charged with losing score:f64 when Transform goes. The compiled plan you saw earlier scopes each surface to direct children instead. Two lenses on the same tree: scoped for execution, flat for the safety diff. The flat lens is also what lets a parent-level output reach a sub-world's component in the gate's accounting — though, as the seams note, that cross-boundary edge is neither piped nor cleanly validated yet.

And the honest edge, stated here so the next paragraph can't oversell it: the gate is keyed by workflow title. Rename a workflow and the diff reads remove-plus-add — breaking. Today the gate ships as a kernel export with a runtime wrapper; wiring it into the deploy path is the runtime's job, not yet done. More in the seams.

how the nest EXECUTES

depth rung · skippable — what the engine does with a nested plan

A nested plan runs as a nested run. The engine's recursion mirrors the compiler's: running a world executes its components in topological waves — everything whose producers are done runs together, up to eight steps in parallel, ten minutes each — and recurses into each sub-world the same way. A producer's stdout is piped into its consumer's stdin along the computed edges, and the result is a nested run record: each level reports its workflow, schedule, exports, tasks, and its sub-workflows' records inside it.

sequenceDiagram
  participant E as the engine
  participant P as Pipeline — the world
  participant T as Transform — the sub-world
  E->>P: run_world(input)
  Note over P: wave 1 — Ingest runs
  Note over P: wave 2 — Publish runs,
Ingest's stdout on its stdin P->>T: recurse — run_world(original input) Note over T: wave 1 — Clean · wave 2 — Score,
clean:json piped between them T-->>P: sub-record — exports score:f64 P-->>E: one nested run record

Two details make the recursion more than bookkeeping. First, an agent-language component is just another step: its source block is the system prompt, and the engine runs it as a bounded agent — six steps, then done — with the wave's piped input as its input. A sub-world can fan out sub-agents inside the same DAG, no second orchestrator. Second, the tree's properties survive the recursion: :persist markers are collected by walking worlds and their nested workflows all the way down, and a build compiles every component in the whole tree, however deep the graft put it.

One boundary, flagged now and detailed in the seams: when the engine recurses into a sub-world, it hands it the workflow's original input — not a sibling's piped output. The dotted edge in the earlier diagram is declared and validated, not yet piped.

the nesting that ISN'T

There's a second thing people mean by workbooks-inside-workbooks: not trees in a file, but runtimes — a running, sandboxed program calling another program. And here the design does the opposite of everything above, on purpose. Declarative nesting recurses without bound, because a tree is just a tree. Runtime nesting never truly recurses at all.

When a sandboxed guest execs a command, the host does not boot a sandbox inside the sandbox — no wasm-in-wasm, no OS shell, ever. The host brokers the call: it runs the target as a registered wasm command in its own fresh, isolated instance — a sibling, not a child. However deep the call chain reads in your code, the actual shape is a flat forest of peer sandboxes, with the host between every pair.

sequenceDiagram
  participant G as a running guest — depth 0
  participant H as the host — the exec broker
  participant S as a fresh sibling instance — depth 1
  G->>H: host_exec — name, argv, stdin (structural, length-prefixed)
  Note over H: the cond ladder — revoked? rate-limited?
commands cap granted? depth within bounds?
allow-listed? registered? H->>S: run the registered wasm command — its own instance S-->>H: stdout — capped at 8 MiB H-->>G: result · every denial audit-logged

The ladder is default-deny: a guest can exec nothing unless the commands capability was granted to that instance — capabilities flow down by explicit grant, never by inheritance. The argv is a structural list, not a string, so ; rm -rf / arrives as a literal argument to a wasm program, not a shell incantation. And two constants bound the blast radius, straight from the broker:

@default_max_output 8 * 1024 * 1024   # 8 MiB output cap
@max_depth 8                          # exec-recursion bomb stop
# …
depth > @max_depth ->
  deny(name, "nesting depth exceeded"); {:error, :max_depth}

The depth counter exists for one named failure mode: a guest exec'ing guests that exec guests — the recursion bomb. The test suite checks the cliff directly: ask the broker to run seq 1 at depth 99 and the answer is {:error, :max_depth} before any sandbox spins up. Full honesty about today: current lanes launch commands as plain subprocesses without the exec import wired in, so depth above zero doesn't yet arise in practice — the bound is defense-in-depth for the lane that will use it, not a description of live eight-deep chains.

So the dek's last clause, earned: in the file, nesting is real and unbounded, because text costs nothing. In the runtime, nesting is an illusion the host maintains — siblings in a flat forest, each in its own sandbox, counted to eight. The tree recurses; the sandbox never does.

where the SEAMS are

Honesty section — boundaries, not apologies. Each of these is a fact about today's code, and each marks where the composition story currently hands off to the runtime's roadmap.

Sub-worlds get the original input. The recursion passes a sub-world the same input the parent run started with — not a sibling component's piped output. Cross-boundary wiring (Ingest's raw:bytes into Transform's Clean) is declared in the grammar, and the gate's flat signatures account for it — but the validator also checks each sub-world on its own, where raw:bytes has no producer, so the declaration draws a diagnostic today. And the executor doesn't pipe across the world boundary either. The dotted edge is a contract waiting for both its check and its plumbing.

The gate is a kernel export, not yet a tollbooth. check_upgrade ships in the kernel, has a runtime wrapper, and runs in the demos — but no deploy path calls it yet. The kernel can refuse a breaking diff; making every push stop at that refusal is wiring the runtime still owes. Nothing on this page claims your redeploys are gated today.

Renames read as removals. The gate keys workflows by title, so renaming one is remove-plus-add — breaking — and two same-titled workflows at different depths would collide in the signature map. Titles are identity; treat them that way.

One producer per name. Within a world, edge computation maps each output name to a single producer — if two components both declare :out raw:bytes, the last writer wins. Name your outputs like you name your exports, because that's what they are.

questions people actually ASK

Is there a depth limit on nesting workbooks?

No — the compile is unbounded recursion over a tree, and a tree is just text. The famous limit of eight belongs to a different axis entirely: it caps runtime exec chains in the sandbox broker, not headline depth in your file. Nest your plans as deep as your outline goes.

How do I split a workbook back out?

The graft, reversed: cut the subtree, promote its levels, save it as its own file — both halves compile, because every subtree was already a complete workbook. Then run the gate on the half you took it from, and it names exactly which exports disappeared and who is scoped to the breakage. Un-combining with a manifest of consequences is the entire point.

Do sub-workflows see the parent's data?

Declared, yes — a sub-world's component can name a parent-level output as its :in, and the gate's flat signatures account for it. Piped, not yet — at run time the sub-world receives the workflow's original input, and the validator still flags the cross-boundary input as dangling inside the sub-world. The contract is in the file ahead of the plumbing, which is the order this ecosystem prefers.

Is a nested workbook still one file?

Yes. Nesting happens inside the org layer — deeper headlines, same file. Nothing on this page adds a second artifact. Shipping a system as several files is a different story with its own mechanics, and its own lesson when it lands.

Why is a new import only a warning, when removals are errors?

Because the two failures have different owners. A removed export breaks consumers no matter who approves it — the kernel can say so with certainty. A new :uses is an ask: capabilities are granted at dock time, per instance, by the engine and whoever runs it. The gate's job is to make the ask visible before it's granted — flagging, not forbidding.

keep GOING

Nesting is the composition story — four neighbors carry the rest.