Reference: session files (format 4)#
Audience: power users and developers
Time: 20–40 minutes
What you’ll learn:
the exact
.cellucid-sessionframing and manifest;which chunks a session file holds, and how its values and arrays are stored; and
how a catalog’s starting view differs from a session you save.
File and framing#
Share… (under State › Session) downloads one binary file with the
.cellucid-session extension. It holds the history of the open dataset as
the person chose it (scope, below): the trackers and branches ticked, each
a tree of steps, and every value those steps refer to; or the current state
only; or the steps only. Its
framing is:
the 19-byte ASCII signature
CELLUCID_SESSION/4\n: the file kind and its session format version,4;manifestByteLengthas an unsigned 32-bit little-endian integer;exactly that many UTF-8 JSON manifest bytes; and
for each manifest chunk entry, in order, a 32-bit little-endian stored length followed by exactly that chunk’s stored bytes.
A frame length is a u32, so the manifest and each stored chunk hold at most 4 GiB − 1 bytes; that is the format’s only size limit.
This release accepts exactly one session format, the one in the signature. A
session of another format version is refused before anything else is read,
naming it. One saved by an earlier Cellucid (format 3 held one state in
per-feature chunks, not the history of the steps; formats 1 and 2 before it) is
refused with This session was saved by an earlier version of Cellucid (session
format 3), which this version no longer reads: a session file now holds the
history of every step (session format 4). It was not opened. Open it in the
version of Cellucid that saved it, or re-create the session in this version.;
one saved by a newer Cellucid with This session was saved by a newer version
of Cellucid (session format 5), which this version cannot read. Open it in that
version of Cellucid. (each with its own format number). Any other file that
does not start with CELLUCID_SESSION/4\n is refused with: This file is not a
Cellucid session file of format 4 (it does not start with
CELLUCID_SESSION/4).
There may be no duplicate chunks, unlisted payload, truncated payload, or trailing bytes. Cellucid treats every file as untrusted input and checks the framing, manifest, chunk inventory, byte lengths, gzip streams, digests, hashes and dataset identity before reporting success; a file that breaks the format is refused with a sentence starting This session file is damaged: (framing faults: Invalid session file).
Exact manifest#
The root has exactly createdAt, app, actions, scope,
datasetFingerprint, source, requires, chrome, activeTracker,
trackers and chunks, plus cells and genes when the session was saved on a subsample
or with genes read for some of the open cells. app is what wrote it (the
Cellucid version and the SHA-256 of its action catalogue), actions one stamp
per action type and parameters version its steps use, and scope what the
save or share chose (Sessions (.cellucid-session files)
says what each choice keeps). The complete layout
is the web app’s assets/js/app/session/README.md (“The file”), in the
cellucid repository; this page shows its shape:
{
"createdAt": "2026-11-03T12:00:00.000Z",
"app": { "version": "0.9.7", "catalog": "<sha256 of the action catalogue>" },
"actions": [
{ "type": "category.merge", "version": 1, "params": "<sha256 of its parameter schema>" }
],
"scope": {
"trackers": ["t-0b7c6f0e-3c1a-4a52-9d1e-5f2b8c4a7e10"],
"branches": [{ "tracker": "t-0b7c6f0e-3c1a-4a52-9d1e-5f2b8c4a7e10", "branches": ["b0"] }],
"content": "history", "results": "include", "notes": true, "chats": false,
"journal": false, "times": "exact", "source": "full"
},
"datasetFingerprint": {
"datasetId": "suo",
"name": "Suo et al. 2022",
"cellCount": 1000,
"varCount": 2000,
"cellOrder": {
"embedding": { "key": "X_umap", "columns": [0, 1] },
"dimension": 2,
"digest": "9f2c41a0b7d3e185"
},
"dataMapping": {
"kind": "anndata",
"embeddings": {
"enabled": [
{ "key": "X_pca", "columns": [0, 1] },
{ "key": "X_umap", "columns": [0, 1] },
{ "key": "X_umap_3d", "columns": [0, 1, 2] }
],
"defaults": {
"1d": null,
"2d": { "key": "X_umap", "columns": [0, 1] },
"3d": { "key": "X_umap_3d", "columns": [0, 1, 2] }
}
},
"knn": "connectivities",
"geneNames": "index",
"expression": "X",
"log1p": {},
"highlyVariable": null,
"latent": null,
"vectorFields": []
}
},
"source": { "kind": "sample", "dataset": "suo" },
"requires": { "obs": ["cell_type", "organ"], "var": ["CD3E"] },
"chrome": { "pressed": { "highlight-mode": "highlight-mode-lasso" } },
"activeTracker": "t-0b7c6f0e-3c1a-4a52-9d1e-5f2b8c4a7e10",
"trackers": [
{
"id": "t-0b7c6f0e-3c1a-4a52-9d1e-5f2b8c4a7e10",
"name": "Annotation",
"dataset": {
"id": "suo", "name": "Suo et al. 2022",
"cellOrder": { "embedding": { "key": "X_umap", "columns": [0, 1] }, "dimension": 2, "digest": "9f2c41a0b7d3e185" }
},
"createdAt": 1793725004466,
"by": { "origin": "user", "agent": null, "model": null, "host": null, "person": null, "turn": null },
"start": { "kind": "opened", "from": null, "report": null, "mapping": null },
"removed": null,
"rootId": "s0",
"headId": "s7",
"activeBranch": "b0",
"mainBranch": "b0",
"paused": null,
"branches": [
{ "id": "b0", "name": "Main", "base": null, "tip": "s7", "createdAt": 1793725004466,
"by": { "origin": "user", "agent": null, "model": null, "host": null, "person": null, "turn": null },
"lane": 0, "locked": false, "archived": false, "notes": [] }
],
"groups": [],
"steps": ["steps/0/0"],
"turns": [],
"notes": [],
"journal": null,
"chats": [],
"nextStep": 8, "nextBranch": 1, "nextGroup": 0, "nextNote": 0, "nextJournal": 0
}
],
"chunks": []
}
createdAtis a canonical ISO timestamp.chromeis page chrome kept outside the history, which the web app puts back after a load:{"pressed": {<group id>: <button id>}}, the pressed button of each pressed-button group (the selection tool:{"highlight-mode": "highlight-mode-lasso"});{"pressed": {}}from a file written without a page. Every form of file keeps it (history, state, steps). A group id is non-empty and names its button by a non-empty id; anything else is refused.sourcesays where the dataset was opened from, so a load can open it first:{"kind": "sample", "dataset"}for a sample of the page’s catalog,{"kind": "link", "locator", "dataset"}for a data link (thelocatoris the?data=value in its public form, never a key, signature or token the link carried; see Open from a link),{"kind": "remote", "url", "dataset"}for a Cellucid server,{"kind": "file", "name", "dataset"}for a file or folder opened from this computer (nameis its file or folder name), ornullfor a notebook. A local file is opened from the session only where this browser kept its handle (Chrome and Edge, Opening a file again, keyed by the record’sdataset): a load reopens it at once while the browser still lets it be read, else offers Reopen, whose click asks the browser and then loads the session. Anywhere else a load goes onto whatever is open and, when that is another dataset, the refusal names the dataset it needs. The handle is never part of the session. A starting view’s sourceis alwaysnull. The source is only a pointer: the fingerprint, not the source, decides whether the session applies.datasetFingerprinthas exactlydatasetId,name,cellCount,varCount,cellOrder, anddataMapping.nameis the dataset’s display name (ornull); it is informational (messages name a dataset by it) and is never compared.requireslists, sorted and without repeats, every dataset observation field (obs) and gene (var) the shown step uses: what its views are coloured by, filter on or have changed the colours of, and the fields its highlight groups are bound to, by the dataset’s own key (a field renamed in the session by its original key). Fields the session creates itself are not listed. Before anything is put back, Cellucid refuses a dataset missing any of them, naming every missing key at once: This session uses the observation fields “…” and the genes “…”, which the dataset that is open now does not have. Genes are compared only when the session names genes the way the open dataset does (the same gene-name column and modality).cells(present only for a subsample) is the subsample’s specification (spec), its kept original positions (kept, cell-set bytes in base64) and their digest; a load opens exactly those cells first. A manifest withoutcellswas saved on every cell.genes(present only when genes were read for some of the open cells) is how many of the open cells, in which order, and their digest; a load reads genes for at least those cells again.cellOrderhas exactlyembedding,dimension, anddigest.embeddingis{"key", "columns"}: an AnnDataobsmkey with its 0-based columns (a list of integers, never null): the embedding the live view showed when the session was saved.dimensionis its dimension, exactly1,2, or3.digestis 16 lowercase hexadecimal characters computed over the stored values of that embedding’s columns for the cells the session was saved on, each cell named by its original position (MurmurHash3 lanes summed over the cells; specified in Sessions: persistence and serialization), so how the cells were opened (whole, grown, a share, priority order) does not change it.dataMappingrecords the arrays the dataset was shown with. Itskindis always"anndata"(any otherkindis refused:kind must be "anndata"), and it has exactlykind,embeddings(enabled: the embedding references that are turned on, at least one, sorted by key then columns without repeats; anddefaults: the1d/2d/3ddefault, each an enabled reference or null, at least one named),knn(anobspkey or null),modality(only for a dataset with several modalities, such as a MuData file: the modality’s name, whose genes the next four keys then describe),geneNames("index"or"column:<name>"),expression("X","raw","layer:<name>", or null),log1p(for each expression matrix whose values arelog1p, keyed"X","raw"or"layer:<name>"in code-point order,{"base": null}for e or{"base": <number>}; a matrix not listed is as stored),highlyVariable(the true/false var column marking the highly variable genes of the expression matrix’s axis, or null),latent(anobsmkey or null), andvectorFields: the vector bindings in use, each{"key", "embedding"}, the vector’sobsmkey and the enabled embedding reference it is drawn over. Bindings are sorted by embedding key, columns, then key. The Python and R packages read the same shape; see Sessions (.cellucid-session files).activeTrackeris the id of the tracker shown; it must be one oftrackers.trackerslists every tracker: each is the history model’sTracker(assets/js/app/history/model.js, mirrored to Python and R ashistory_model.schema.json) with exactlyid,name,dataset(the dataset it belongs to),createdAt,by(who started it),start(how: opened, shown, a replay or an import),removed(null unless it is in Recently deleted),rootId(its first entry, Opened),headId(the step shown),activeBranch,mainBranch,paused(null while it records, else since when, who acted and, never in a file, a draft),branches(each{id, name, base, tip, createdAt, by, lane, locked, archived, notes}),groups,steps,turns,notes,journal,chatsand the countersnextStep,nextBranch,nextGroup,nextNoteandnextJournal. Itsstepslists its steps chunks in the order the file holds them,journalnames its journal chunk (or null) andchatsits conversation chunks (only when the share included them).chunksis the ordered inventory.
Chunks#
Every chunk entry has exactly id, kind, priority, codec,
storedBytes and uncompressedBytes, plus its kind’s own keys:
Chunk id |
Kind (own keys) |
Priority |
Contains |
|---|---|---|---|
|
|
eager |
a run of the n-th tracker’s steps, in id order; the tracker lists its steps chunks, read in order as one list |
|
|
eager for what the values of the step shown name, else lazy |
sub-values stored once (below) |
|
|
eager for what the values of the step shown name, else lazy |
one typed array, stored once however many values hold it |
|
|
eager: the values of the step shown; lazy: every other value a step or a journal entry refers to |
values, as skeletons |
|
|
lazy |
the n-th tracker’s journal (Recently deleted), when included |
|
|
lazy |
a conversation kept with the n-th tracker, when included |
priority is eager or lazy. Every eager chunk comes before every lazy
one, and within each priority the kinds come in the order of the table
(steps, parts, array, values, journal, chat). steps, parts and
values are JSON cut where their text passes 16 MiB: one list or map in
several chunks, read back as one. codec is gzip (where gzip saves at least
a sixteenth of the chunk’s bytes) or none (stored as it is: storedBytes
equals uncompressedBytes); each chunk is decompressed to exactly its
uncompressedBytes. A file holds at least one eager values chunk, except a
file of steps only, which holds none; each tracker names steps chunks of its
own. Unknown, missing, duplicate, unreferenced or reordered chunks, a value,
part or array nothing refers to and a step referring to a value the file lacks
are refused.
Steps#
Each step is the model’s Step, with exactly id, parent, children,
branch, kind (root, action, outside for a change made outside an
action, or squashed), type (the action), params (its parameters, by
name), v (the version of its parameters), code (its line of pseudocode),
label, by ({origin, agent, model, host, person, turn}: who acted, origin
user, agent, host, session, replay, startup or outside), at and
until (when, in milliseconds since 1970), merged, ms, touches (the
parts it changed), before and after (each part it changed, mapped to the
content hash of its value), reads (the hash of each input it read), status
(applied, or unapplicable for a step that no longer applies), reason,
outcome, coalesceKey, replayOf, squashed (for a squashed step: why,
how many steps it replaced, who acted and what they carried), notes,
bookmark and group. params and outcome are skeletons, like values.
Each tracker’s steps must form one tree with one root, parents and children
agreeing, each branch one line from its fork step to its tip, and the step
shown on the active branch’s path; the full rules are the README’s and
model.js’s.
The parts a step can change are the fifteen state domains, in the order a load
puts them back: cells, mapping, field-overlays, gene-sets, views,
active-field, filters, colors, visibility, highlights, camera,
camera-path, settings, analysis, layout
(What gets saved and restored says what each holds). The value of a
part at a step is the after value of the nearest step on the way from the
root that changed it, or the root’s. A part this version does not have is
refused, naming it.
Values#
A values chunk is a JSON object mapping each value’s content hash (64
lowercase hexadecimal characters) to its skeleton: the value as JSON with
every typed array replaced by {"$array": "<digest>"}, every number JSON
cannot hold by {"$number": "NaN" | "Infinity" | "-Infinity" | "-0"}, and
every object or list whose skeleton text is at least 512 characters by
{"$part": "<name>"}, its skeleton stored once in a parts chunk under the
SHA-256 of its text (a part names the parts inside it). Values that share a
sub-value (the gene names of every analysis result, a highlight group) store
it once. Every value is checked against its hash once its parts and arrays are
joined back in: a part’s name is never trusted. A part that names itself
(through others), and a value whose text would pass 2^29 − 24 characters, are
refused before anything is expanded. There is
no encoder per feature: a part’s value is stored as it is. The hash’s
canonical form is specified in
Sessions: persistence and serialization.
Arrays#
An array’s digest is <type>:<length>:<sha256 of its bytes>, for example
Uint32Array:5:<64 hex characters>. Its encoding is raw (its elements,
little-endian) or cell-set, used for a strictly increasing Uint32Array
(the cells of a set) when that is smaller: a uvarint bound (the last cell plus
one), then the cell-set bytes, whose byte 0 names the encoding:
Byte 0 |
Encoding |
Body |
|---|---|---|
0 |
sorted delta |
uvarint count, then that many uvarint gaps (the first is the index itself) |
1 |
bitset |
one bit per cell below the bound, |
2 |
runs |
uvarint run count, then per run a uvarint gap from the end of the previous run and a uvarint length |
The writer writes whichever is smallest. Every array is checked against its digest after it is decoded. A uvarint is an unsigned LEB128 integer.
Exact dataset identity#
Before anything is put back, Cellucid checks, in order: the cell and gene
counts, the dataset id, the data mapping, and the cell-order digest; then,
under the session’s own mapping, the requires field list. A dataset id,
cell count, gene count, cell-order embedding, or cell-order digest that
differs refuses the whole load. A data mapping may differ: a session whose
embeddings or other choices (graph, modality, gene names, matrix, …) differ
is put back onto them when the dataset can show or use them (they are put
back right after the cells, before the views, and rolled back with the rest),
and refused, naming what is missing, when it cannot; a group the dataset
cannot change must equal the one it is shown with. The digest is checked on
the embedding cellOrder.embedding names, over the cells the session was
saved on (opened first), whichever embedding is on screen; it is not loaded
for display. Cellucid does not put back the layout alone and does not skip the
parts that name cells. Any departure from the fingerprint’s six-key shape is a
format violation.
This strict equality keeps a session’s per-cell filters, fields and highlights
from being attached to a different dataset, or to a re-ordered republication
of the same one. The scalars are all preserved when a dataset is written again
at the same id from re-sorted input, so cellOrder is what makes that case
detectable. It is still your responsibility not to reuse the same identity for
scientifically changed content that leaves the coordinates alone.
Load order and outcome#
A load is one awaited operation, all or nothing:
the session’s dataset is opened if needed, with the cells it was saved on;
the signature, manifest, fingerprint and
requiresare checked;the eager chunks are read and the step shown is put back, part by part in domain order, changing only the parts whose value differs;
the lazy chunks are read while that step is shown; a new step waits; and
the history’s trackers are replaced by the file’s, and the progress entry ends as Loaded with Session fully restored.
Any failure, and Cancel, puts back what was shown and keeps the trackers there were. Starting a newer load cancels the older one, which is dismissed without a false failure or success. A camera path saved with Autoplay on starts once the load is done. The history kept in this browser (Saved states and autosave) is read the same way when you open a saved state.
A catalog’s starting view#
A catalog may name a SHA-256-pinned starting view for a dataset (the session
entry of its catalog, such as pancreas.cellucid-session). Cellucid fetches
it and puts it back by itself only through the catalog, as described in
Starting views named by a catalog. It is a format-4 file with this profile,
checked on the manifest before any chunk is read:
exactly one tracker, whose root is its step shown and its only step (no step, no branch), with no conversation;
every chunk eager;
sourceisnull, and there is nocellsand nogenes.
Once its one step is read, its root must hold neither cells nor layout. It
is put back without a step, before the dataset’s first tracker starts, so the
history starts from it; it applies to any subsample of its dataset (on a
subsample the cell-order digest is not compared, as the catalog pins the
file). Otherwise it goes through the same manifest checks, fingerprint
comparison and requires check as any session, so it too must carry
cellOrder and dataMapping.
A shared session file becomes a starting view only once it is published:
trimToPublishedDefault (in assets/js/app/session/published-default.js of
the web app, used by cellucid-datasets/scripts/publish_session_preset.mjs)
makes the head of the active tracker the one root, drops every step, and
binds the fingerprint to the identity the catalog serves the dataset under. It
refuses a capture whose steps made content (highlights, gene sets, a camera
path, cells hidden one by one, field and category edits).
Implementation pointers#
In the web app repository:
The format (manifest, chunks, checks):
assets/js/app/session/history-file.jsFraming and format version:
assets/js/app/session/bundle/format.jsSave and load:
assets/js/app/session/session-serializer.jsDataset fingerprint and
requires:assets/js/app/session/session-context.jsStarting views:
assets/js/app/session/published-default.js,assets/js/app/session/catalog-default-session.jsValues as skeletons and arrays:
assets/js/app/history/value-parts.js,assets/js/app/history/value-pool.jsCell-set encodings:
assets/js/app/session/codecs/cell-set.jsThe state domains:
assets/js/app/state/domains/README.mdMaintainer notes:
assets/js/app/session/README.md