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 file | what it really was | opens without the vendor? |
|---|---|---|
| .doc (pre-2007) | a proprietary binary dump | reverse-engineered, painfully |
| .psd | a proprietary layered blob | partially, with heroics |
| .sketch | a zip — schema undocumented for years | eventually |
| .wbundle | a zip — entries you already know | today, 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
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:
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:
| field | type | who reads it |
|---|---|---|
id | string | anything naming the workbook — restore, the library, you |
format | string — always wbundle/1 | every unpacker — the one tag both lanes emit |
volumes | array of strings | restore — which disk volumes are aboard |
signed | bool | verification — whether provenance travels with the bytes |
private_included | bool | the recipient — was the privacy strip skipped on purpose? |
created | unix seconds | humans 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:
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:
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.