on this page

CellsFull Export (Explant)

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

The 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 English

The 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.DBexec, prepare, bind, run, all, first, raw, batch any SQLite driver (node:sqlite, better-sqlite3, bun:sqlite) over data/c3/<name>.db
env.BUCKETget, 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/download

The 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.json records 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.txt with their sizes and where to get them, explant.json marks the bag incomplete, and NOTES.md says 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).