Exporting a Cell in full (explant)
An explant is a complete, verifiable copy of one Cell: the deployed source, the configuration, a consistent snapshot of every database it is bound to, and every object in every bucket it is bound to. One command requests it, waits for the build, downloads it, and verifies every file on the way in:
$ ribo explant export orders-api
✓ Queued export exp_9f2c41d8a03b of cell orders-api
4218/4218 files, 1.2 GB of 1.2 GB
✓ Wrote 1.2 GB to orders-api-2026-08-22T14-03Z
Verify: cd orders-api-2026-08-22T14-03Z && sha256sum -c manifest-sha256.txt
Read: orders-api-2026-08-22T14-03Z/annex/NOTES.mdThe result is a directory of ordinary files in ordinary formats — JavaScript source, SQLite databases, your objects under their original keys, JSON metadata. Nothing in it needs ribo, or Tissue, to read, and nothing in the source needs Tissue to run: see Running it elsewhere.
The account export is the complement, not the same feature: it lists everything the account owns, without content. An explant is the content of one Cell.
What is in the bag
orders-api-2026-08-22T14-03Z/
explant.json the manifest: inventory, hashes, per-plane timestamps
bagit.txt BagIt 1.0 marker (RFC 8493) — archive tools know the shape
bag-info.txt
manifest-sha256.txt one SHA-256 line per payload file
fetch.txt only when payload was left out: what, where, how big
data/
source/cell.js the deployed artifact, verbatim (Wasm Cells: .wasm + glue)
config/ribo.toml reconstructed from the registry: bindings, build, pulse
config/bindings.json the binding list; vault entries as names, never values
c3/orders.db a consistent SQLite snapshot, taken online
c3/orders.schema.sql the schema as text — readable, greppable, diffable
g7/invoices/<key> objects verbatim, original key paths preserved
g7/invoices/_objects.json per-object size, etag, content type, last-modified
annex/
domains.json custom domains the Cell answered on (informational)
pulse.json resolved schedules
NOTES.md what this explant does not contain, in plain EnglishThe shape is BagIt, not ours. BagIt (RFC 8493) is the format libraries and digital archives use to hand each other data: payload under data/, a checksum manifest beside it, bagit.txt and bag-info.txt as plain-text tags at the root. That is why the word here is bag. Any tool that validates bags validates this one without knowing what Tissue is — including fetch.txt, the format's own way of naming payload the bag does not carry.
Every payload file is hashed, and the manifest is hashed over the hashes. sha256sum -c manifest-sha256.txt from the bag root proves the copy complete. An explant is verified, not assumed — ribo checks each hash as the file lands, and anyone you hand the bag to can re-check with stock tools.
Consistency is per plane, and the manifest says so. Each database snapshot is transactionally consistent; the object listing is consistent with itself; the source is the deployed bytes. explant.json records an asOf timestamp per plane rather than pretending to one atomic cut across all three — no export product delivers that, and the honest ones say so.
Only the current deploy exists. The platform keeps the bytes of a Cell's current version and nothing older, and the bag captures exactly that.
Running it elsewhere
A Cell is a fetch handler over standard Request and Response, and data/source/cell.js imports nothing from Tissue — everything the platform provides arrives as a property of env. Supplying env yourself is the whole of the port.
| In the bag | What supplies it elsewhere |
|---|---|
data/source/cell.js |
Bun and Deno serve a fetch handler natively; Node needs an adapter that turns a node:http request into a Request |
env.DB — exec, prepare, bind, run, all, first, raw, batch |
any SQLite driver (node:sqlite, better-sqlite3, bun:sqlite) over data/c3/<name>.db |
env.BUCKET — get, head, put, delete, list |
any S3 client, once data/g7/<bucket>/ is synced into a bucket of your own |
Under Node that is about forty lines:
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
import cell from "./data/source/cell.js";
const env = { DB: c3shim(new DatabaseSync("./data/c3/orders.db")) };
createServer(async (req, res) => {
const out = await cell.fetch(toWebRequest(req), env, {});
res.writeHead(out.status, Object.fromEntries(out.headers));
res.end(Buffer.from(await out.arrayBuffer()));
}).listen(8080);c3shim maps prepare().bind().all() onto the SQLite handle; toWebRequest builds a Request from the Node request. On Bun or Deno both disappear: Bun.serve({ fetch: (req) => cell.fetch(req, env, {}) }).
The data ports even if the code does not come with you. Each data/c3/<name>.db is a SQLite file that the sqlite3 CLI, Python's sqlite3 module, Datasette and DB Browser open with no conversion, and <name>.schema.sql is the same schema as text. Objects keep their original key paths, so they land in any S3-compatible store under the names your code already uses.
What does not travel is outside your function. Gate is a perimeter: the Cell only reads x-tissue-gate-* headers, so the code is unchanged, but something in front of it has to do the sign-in. Pulse schedules are in annex/pulse.json, and anything that can call a URL on a timer replaces them. Vault values are not in the bag at all, only their names.
The commands
| Command | What it does |
|---|---|
ribo explant export [<cell>] |
Request an export, wait for the build, download and verify it |
ribo explant export --no-wait |
Request only; collect later |
ribo explant download <id> |
Collect an export requested earlier |
ribo explant list |
This Cell's exports: id, state, size, expiry |
ribo explant rm <id> |
Delete a staged export before it expires |
As with every ribo command, the Cell defaults to the ribo.toml in the current directory; name it explicitly from anywhere else. -o <dir> chooses the destination — the default carries the cell name and the snapshot time, so two exports never land on top of each other.
Downloads resume. Members are fetched in parallel and verified as they land; re-running a killed download skips everything already on disk and intact, so an interruption costs the one file it was in the middle of. Past 1 GB, ribo prints the size before fetching; past 10 GB it stops until you pass --yes.
The HTTP API
| Route | |
|---|---|
POST /v1/cells/{name}/explant |
Request an export → 202 with a jobId (exp_…) |
GET /v1/cells/{name}/explant |
List this Cell's exports |
GET /v1/cells/{name}/explant/{id} |
Job state; once ready, the full member inventory |
GET /v1/cells/{name}/explant/{id}/download |
The whole bag as a single .tar.zst |
GET /v1/cells/{name}/explant/{id}/member/{path} |
One member — the route ribo fetches through |
DELETE /v1/cells/{name}/explant/{id} |
Delete the staged bag now |
curl -X POST -H "Authorization: Bearer $TISSUE_TOKEN" \
https://api.tissue.systems/v1/cells/orders-api/explant
# {"jobId":"exp_9f2c41d8a03b","state":"queued",...}
curl -C - -o orders-api.tar.zst -H "Authorization: Bearer $TISSUE_TOKEN" \
https://api.tissue.systems/v1/cells/orders-api/explant/exp_9f2c41d8a03b/downloadThe single-file download states an exact Content-Length and honours Range, so curl -C - resumes a multi-gigabyte archive where it stopped and progress bars read true. Members are zstd-compressed once, at build time; zstd -d < orders-api.tar.zst | tar -x unpacks the archive anywhere, and bsdtar -xf reads it directly.
Rules the endpoint enforces
Exporting takes its own permission. The explant:export scope is never implied by cells:read — a signed-in session holds it like every other scope, but an API token must be minted with it explicitly. A token that can read your Cells cannot, by default, walk away with their data.
Ten exports per account per rolling 24 hours. The eleventh answers 429. An export is expensive to build and cheap to ask for.
Bags expire after 7 days. The staged copy is deleted and the job's state becomes expired; request a fresh one. ribo explant rm deletes sooner.
An export outlives its Cell. Ownership is checked against the export, not the Cell, so deleting a Cell does not delete a bag already built from it — which is exactly when somebody most needs to download one.
A locked account can still leave. An account suspended for abuse cannot request a new export, but can download one already built. Withholding a customer's own data is not a sanction this platform uses.
Every step lands on the audit feed. explant.export.requested, explant.export.completed, explant.download and explant.export.deleted appear under Activity, so a request you did not make is visible within minutes.
What an explant does not contain
- Secret values. Vault bindings appear by name only. Set them again wherever the copy lands; nothing in the bag can recover them.
- DNS.
annex/domains.jsonrecords the custom domains the Cell answered on; the records themselves live at your registrar. - Account-level data. Members, billing, API tokens, audit history — those belong to the account, and the account export covers them.
- Previous deploys. Only the current version's bytes exist anywhere.
- The far end of an enormous bucket. Past 50,000 objects in one bucket, the remainder are listed in
fetch.txtwith their sizes and where to get them,explant.jsonmarks the bag incomplete, andNOTES.mdsays so plainly. A thin bag still verifies.
There is no import command yet, and the bag is designed so you do not need one: SQLite opens the databases, any S3 client puts the objects back, and the source is the same file you deployed, which is the same file another runtime serves (Running it elsewhere).