learn / 01·1 — under workbook · bundles

the cartonAROUNDthe app

You bundled a workbook and got a second file with a strange extension. Relax: a .wbundle is a plain zip — the app, its source, its disk, and a one-object label. This page opens one with tools you already have, walks the round trips, and shows exactly what leaves home when you share — and what stays.

the carton11 min read
A small courier standing on a bright launch platform before one monumental shipping carton, its four flaps open to reveal tidy luminous compartments — a glowing screen, a paper scroll, a stack of disk platters, and a tiny stamped label — 1970s sci-fi style

the second FILE

The parent lesson made a one-file promise: the workbook is the app, the source, and the disk, in a single artifact. Then you ran wbx bundle and a second file appeared, wearing an extension you've never seen. Every instinct you've trained on twenty years of software says: here it comes. The custom container. The moat.

Those instincts are earned. The history of "our format" is mostly a history of files that stopped opening when a vendor stopped caring:

the filewhat it really wasopens without the vendor?
.doc (pre-2007)a proprietary binary dumpreverse-engineered, painfully
.psda proprietary layered blobpartially, with heroics
.sketcha zip — schema undocumented for yearseventually
.wbundlea zip — entries you already knowtoday, with unzip

So the question this page answers first, before any mechanics: did the one-file promise just sprout format lock-in? And the second question, the one that actually matters when you hit send: when I hand this to someone, what exactly did I hand them — does my agent's memory go along for the ride?

the DEFINITION

w·bun·dle /ˈdʌb·əl·juː ˌbʌn·dəl/ noun

1. a plain zip holding a workbook's runnable workbook.html, its workbook.org source, its vfs.sqlite disk, and a one-object manifest.json whose format tag is wbundle/1.

That defcard is not a summary of the spec. It is the spec. The runtime packs bundles with the zip library that ships in Erlang's standard library; the CLI packs them with a stock Rust zip crate using ordinary deflate. Two implementations, one deliberately boring container — the design's own words: because it's a plain zip, any tool can open it. Not a moat. A carton. The 1989 kind.

open one — with NO workbooks software

Proof beats reassurance. Bundle a project, then crack the result open with the unzip that has shipped on every Unix since before some of your dependencies were born:

$ wbx bundle shop/
bundled shop/shop.org → shop.wbundle (412403 bytes, with vfs.sqlite)

$ unzip -l shop.wbundle
Archive:  shop.wbundle
  workbook.html        the app — open it in a browser right now
  workbook.org         the source — prose, tasks, code
  vfs.sqlite           the disk — sqlite3 can query it directly
  manifest.json        the label

Four entries, all standards older than most companies:

shop.wbundle — a zip, exploded
workbook.htmlthe runnable artifact — the workbook itself, rendered and self-contained
workbook.orgthe source — travels with the artifact, so recipients can re-author, not just run
vfs.sqlitethe disk — included only when the project has one; plain SQLite
manifest.jsonthe label — one small object whose main job is to say I am wbundle/1
the bundle is not the app — it's the shipping carton around the app

And the disk really is just the disk. The entire VFS schema is one table — vfs(volume, path, content, mtime) — so this works, today, with no Workbooks software installed:

$ sqlite3 vfs.sqlite "SELECT volume, path FROM vfs"
   → workspace · /data/orders.csv
   → workspace · /reports/week-24.org

That's the no-moat moment. Every layer of the carton is inspectable with tooling you had before you ever heard of this project — and that's not an accident you're exploiting, it's the design promise of the whole anatomy, kept at the shipping layer too.

bundle ⇄ UNBUNDLE

The local round trip is two CLI verbs, and both run entirely on your machine — no engine, no account, no network. First, how wbx bundle decides what to pack. You can hand it a .org file directly; or a directory, in which case it looks for workbook.org inside; or a directory with exactly one .org file, which it takes as the obvious choice. Two or more candidates and it refuses with name one workbook.org or pass it explicitly — guessing is not a feature in a packaging tool.

Then the gate. The bundle verb will not assemble a workbook whose source doesn't lint clean:

$ wbx bundle shop/
error: source has diagnostics — fix them first (wb lint)

That's a deliberate stance: a .wbundle is a thing you hand to other people, so the assembler refuses to carton up known-broken source. Fix the diagnostics, run again, and the pipeline is short:

flowchart LR
  src["workbook.org
+ vfs.sqlite if present"] --> lint{"lint gate
diagnostics?"} lint -- "yes — refuse" --> stop["fix them first"] lint -- "clean" --> render["render
org → workbook.html"] render --> pack["pack — plain zip
+ manifest.json"] pack --> wb["shop.wbundle"] wb --> un["wbx unbundle"] un --> dir["shop/ — the project back,
ready to re-author"] style src fill:#f2ddb0,stroke:#121316 style lint fill:#ffffff,stroke:#121316 style stop fill:#f3c5a3,stroke:#121316 style render fill:#ffffff,stroke:#121316 style pack fill:#ffffff,stroke:#121316 style wb fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style un fill:#ffffff,stroke:#121316 style dir fill:#aee5c2,stroke:#121316

Defaults are unsurprising on purpose: output is named after the org file's stem — <stem>.wbundle, written where you ran the command; wbx unbundle extracts into a directory named after the file's stem unless you say otherwise. The exact strings, because exactness is this page's whole register:

$ wbx bundle shop/
bundled shop/shop.org → shop.wbundle (412403 bytes, with vfs.sqlite)

$ wbx unbundle shop.wbundle
unbundled shop.wbundle → shop/ (4 files)

And the trip is lossless where it counts: the source rides inside, so bundle → unbundle gives the recipient the project back — org file, disk, rendered app — not a flattened export. That's the difference between sending someone a printout and sending them the document.

the smallest MANIFEST in software

depth rung · skippable — the label, field by field

Here is a real manifest, verbatim, as the CLI writes it:

{"id":"shop","format":"wbundle/1","volumes":[],"signed":false,
 "private_included":false,"created":1781136000}

Six fields. No registry lookups, no nested config tree, no version matrix. Each one:

fieldtypewho reads it
idstringanything naming the workbook — restore, the library, you
formatstring — always wbundle/1every unpacker — the one tag both lanes emit
volumesarray of stringsrestore — which disk volumes are aboard
signedboolverification — whether provenance travels with the bytes
private_includedboolthe recipient — was the privacy strip skipped on purpose?
createdunix secondshumans and archives

Pack the same workbook on an engine instead and the delta is small and legible: "volumes":["workspace"] says which volume of the disk made the trip. And there is exactly one field that ever appears conditionally — key_refs, present only when entries are sealed:

"key_refs":{"vfs.sqlite":{"key_id":"shop:dmZzLnNxbGl0ZQ","algo":"aes-256-gcm"}}

Read that carefully: it's a reference, never a key. The manifest tells you which door is locked and which keyring holds the key — the key itself never rides in the carton. Which is why a bundle, sealed entries and all, is safe to mirror to any storage you don't fully trust.

the same verbs at ENGINE scale

Everything so far ran on your laptop. The engine speaks the same container through two verbs of its own: ship packs a live workbook into a bundle blob, and restore is its exact inverse — manifest, html, and disk bytes back out, ready to reopen. The round trip is exercised end-to-end in the engine's own demo: ship, restore, reopen the VFS, same bytes.

But ship adds the step that answers this page's opening worry. Before anything leaves the engine, the disk copy that's about to travel gets stripped — and the strip is not a policy document, it's one SQL statement run against the egress copy:

DELETE FROM vfs WHERE volume NOT IN ('workspace');
VACUUM;

The VFS has three named volumes, and only one of them is public:

the three volumes — what travels, what stays
workspacethe working tree — the work itself. ships
memorythe agent's long-term memory. stays home
tmpscratch space. stays home
sharing exposes the work — never the session that produced it

That rule — the work, never the session — is stated once in the engine's source and enforced as a delete, not promised in a README. Your agent's accumulated memory, its half-thoughts in scratch space: none of it is in the carton unless you explicitly pass include_private, and the manifest's private_included flag tells the recipient you did.

sequenceDiagram
  participant O as owner's engine
  participant B as the .wbundle
  participant R as recipient
  O->>O: copy the disk · DELETE non-workspace volumes · VACUUM
  O->>B: ship — pack html + stripped vfs + manifest
  Note over B: a plain zip — safe to mail, mirror, archive
  B->>R: restore — manifest, workbook.html, vfs.sqlite back out
  R->>R: reopen the disk — the work, intact
  

One honest nuance: the two lanes pack slightly different entry sets. The CLI bundle carries four entries including workbook.org; the engine's ship carries three — html, disk, manifest — because it ships an assembled workbook, whose source already rides inside the html. Same container, same tag, same restore; the org entry is the CLI lane's extra courtesy to re-authors. And there's a sharing/archiving distinction worth knowing: the engine's library pack supports two purposes — share, which strips, and archive, which keeps everything, because an archive is your complete snapshot, for you. It even honors the repo's own .gitignore when packing, so what git wouldn't track, the carton doesn't carry.

one carton, EVERY door

depth rung · skippable — where the same zip shows up

Here's the part that makes the boring container a design decision rather than a missed opportunity: the platform reuses this exact zip shape for every flow where a workbook crosses a boundary. Not five formats for five features — one carton, five doors:

flowchart TD
  wb([".wbundle — one container"])
  co["checkout / checkin
working tree ⇄ engine, over RCP"] st["store / fetch
cold archive — workbooks/{slug}.wbundle"] pub["publish
pulls workbook.html out by name"] tk["toolkit install
a built toolkit is wasm in the same zip"] fr["fractal pack
a whole workspace — bundles inside a bundle"] wb --- co wb --- st wb --- pub wb --- tk wb --- fr style wb fill:#a8d4f0,stroke:#121316,stroke-width:2.5px style co fill:#fbfaf6,stroke:#121316 style st fill:#fbfaf6,stroke:#121316 style pub fill:#fbfaf6,stroke:#121316 style tk fill:#f3c5a3,stroke:#121316 style fr fill:#f2ddb0,stroke:#121316

In order. Checkout and checkin: when you pull a workbook's working tree down from an engine or push edits back, the payload crossing the wire is this zip, base64-wrapped in JSON — the engine packs, the CLI unpacks, and vice versa. Store and fetch: archiving a workbook is literally writing the blob to storage under workbooks/<slug>.wbundle; fetching restores it. The design doc says it plainly — archiving is just writing the blob to cold storage. Publish accepts a .wbundle and pulls workbook.html out of it by name — and refuses raw org source with publish ships ASSEMBLED workbooks — run wb bundle first. Toolkit install: a built toolkit — compiled wasm and all — ships in the same container; the engine's own tests put it in writing: it's still just a .wbundle. And the fractal pack: a whole workspace — its index plus every member workbook — packs into one parent bundle. Containers hold containers; the carton nests.

The payoff of one shape everywhere: every tool that learns to open the carton learns every door at once — including the tools that don't exist yet, and the ones that aren't ours.

the way BACK in

depth rung · skippable — what the unpacker refuses

Plain zips have one classic ambush: an archive whose entry is named ../../etc/cron.d/x, hoping the extractor will cheerfully write outside its target directory. The unbundler checks every entry name and rejects anything rooted at / or containing a .. component, with the refusal spelled out:

refusing unsafe entry path in bundle: ../../etc/cron.d/x

The server side has the same guard on toolkit install — extraction paths are expanded and checked against the destination prefix before a single byte is written. Hand either lane something that isn't a zip at all and the answer is equally blunt: not a workbook bundle (zip). Fail closed, say why, write nothing.

Sizes are bounded where bundles cross the network: checkin bodies cap at 100 MB, toolkit installs at 50 MB. Local wbx bundle has no ceiling in code — the caps live at the engine's doors, where untrusted bytes arrive. Notice the symmetry with the previous section: the privacy strip guards the way out, the path guard the way in. A container format is only as honest as both directions of its boundary.

when part of the carton is CIPHERTEXT

depth rung · skippable — the gated case

Sometimes a bundle should travel freely while part of its contents stays gated — a paid dataset inside a free demo, say. Ship accepts a list of gated entries, and each one is sealed as real AES-256-GCM ciphertext with a layout you can audit byte by byte:

a sealed entry — the wbseal1 envelope
wbseal17-byte magic — this entry is sealed
iv12 bytes — the nonce
tag16 bytes — the GCM authentication tag
ciphertextthe entry — unreadable without the key
a real gate — a key the client lacks, not a login screen drawn over data

The keys never ride along. Each sealed entry gets its own fresh 256-bit key — per entry, so revoking one doesn't unlock the rest — and the keys are escrowed at the runtime. The manifest carries only the key_refs you met above: which entry, which key id, which algorithm. To open a sealed entry, the client asks the engine to release the key — a single endpoint, POST /rcp/key/:key_id — and the engine runs the same access enforcement as any gated call, failing closed with the same error envelope as any unauthorized request.

And GCM gives the gate a clean failure mode: a wrong key, a tampered ciphertext, and a mismatched context all fail the authentication tag indistinguishably. There is no partial decrypt, no oracle to probe — the entry opens correctly or not at all. Bundles without key_refs unpack as plain zips, exactly as before; the sealed case is the exception, and the round trip — seal, release, open — is covered by the engine's test suite, not just this paragraph.

where the carton ENDS

Honesty section. Five edges worth knowing before you lean on them.

A bundle is a snapshot, not a replica. Nothing inside it syncs. Send someone a .wbundle and you've sent them the project as it stood; if you want two copies to converge again, that's the engine's job, not the carton's.

Bundle is not build. wbx bundle is local assembly — lint, render, zip. Compiling components is a different, engine-backed verb. The lint gate will tell you the difference the moment your source needs more than assembly.

The two lanes differ by one entry. CLI bundles carry the org source as a separate file; engine ships don't, because an assembled workbook already embeds its source. If your tooling reaches for a top-level workbook.org, know which lane produced the bundle in your hand.

Sharing shares contents. Anyone can unzip what you send — that's the feature. The privacy strip protects the volumes that never left; sealed entries protect what shipped as ciphertext; everything else in the carton is readable by design. Don't put a secret in the workspace and hope the extension protects it.

The .html is still the artifact people open. The parent lesson's terminal story ends at a self-contained shop.html — and that's right: the html is the runnable thing, the page you double-click. The .wbundle is the shipping carton around it — html plus source plus disk plus label — and it exists for round-tripping authorship, not for viewing. Hand a reader the html; hand a collaborator the carton. As for the two packers — Erlang's on the engine, Rust's in the CLI — the guarantee is the same container either way: same entries, same manifest, same tag. Byte-identical archives are not the claim; interchangeability is.

questions people actually ASK

Can I just rename it to .zip?

Yes. It is one. The extension is a courtesy to humans and file managers; the format is identified by the wbundle/1 tag in the manifest, not by the name. unzip never cared what you called it.

Why not just ship the bare .html?

You can — that's exactly what publish does: it pulls workbook.html out of the carton and serves it. The bundle earns its keep when the recipient is a collaborator, not a viewer: it adds the org source for re-authoring and the disk as a separate, directly queryable file.

Does my agent's memory ship when I share?

Not from the engine, by default. Ship strips every volume except workspace before packing — the agent's memory volume and tmp scratch are deleted from the egress copy by a literal SQL statement. If you override with include_private, the manifest's private_included flag says so, on the record.

Will this open in 2056?

The carton is zip — in continuous use since 1989. Inside: html, SQLite — the most deployed database on earth — and UTF-8 text. Pick the one you think dies first. The format's survival doesn't depend on this project existing, which is the strongest durability claim a format can make.

Is a .wbundle compressed twice?

No — it's deflate per entry, like every zip you've ever made. The html inside carries its own bound assets, so the carton's compression is doing ordinary work on ordinary bytes, not wrapping compression in compression for sport.

What if the zip is corrupt or hostile?

Garbage is refused up front — not a workbook bundle (zip) — and entry paths that try to climb out of the extraction directory are rejected by name, on both the CLI and the engine. Fail closed, write nothing.

keep GOING

The carton makes the most sense next to the thing it carries and the doors it passes through.