Data
Export and backup
Eleven export routes, what each is for, the CSV conventions, pup.json, how to back up and restore an instance with latch.restore, and the one warning that matters.
On this page
Your data is one SQLite file and one media folder. Export gives you readable copies of both; backup is your job, and it is a small one.
The full export is an unencrypted copy of every reflection and check-in
It is the copy you restore from. It is never the copy you share. Share stamped photos and
recap cards โ those are built by whitelist. The export page and the bundle's MANIFEST.txt
both say this in one line, on purpose.
The routes
All under /export, all behind whatever authentication your reverse proxy provides, none on
the API. Filenames are latch-<what>-YYYYMMDD-HHMM.<ext> in your local time.
| Route | What it is |
|---|---|
/export |
the page, with size estimates |
/export/latch.json |
every table, verbatim โ the only authoritative copy, the one to restore from |
/export/daily.csv |
one row per local day, gaps filled with zeros โ the one to open in a spreadsheet |
/export/events.csv |
one row per event; undone rows are kept and marked with voided_at |
/export/sessions.csv |
one row per lock, clock recomputed from the log |
/export/entries.csv |
reflections and check-ins, the five scores as their own columns |
/export/assignments.csv |
every training assignment |
/export/kit.csv |
one row per thing you own: specs, cost, bouts, hours, cost per bout |
/export/bouts.csv |
every bout of play or wear โ what was on, how long, caged or not, how it ended |
/export/bundle.zip |
all of the above plus media/ (originals and thumbnails), your installed playsets' files (playsets/), your sealed Pup ID key (identity/pup.key.enc, when you set a passphrase) and MANIFEST.txt, streamed. ?media=0 for data only (the playset files and the sealed key still ride) |
/export/pup.json |
your Pup ID, for arriving on your own new box โ see below |
The bundle carries your key only sealed, under your passphrase, and says in MANIFEST.txt
whether it did. The plain key (pup.key) and this box's own key (instance.key) are never in
any export.
A window of time: chapters and slices
Every route above except kit.csv and pup.json also takes a window, and then exports a
slice โ only what happened inside it:
?chapter=<id>โ a saved chapter (the Chapters page, under Story), or?start=YYYY-MM-DD&end=YYYY-MM-DDโ a range;endis inclusive, either may be left out (no start = the first thing you ever logged, no end = now).
The Log does this for you: open ๐ Range, pick this lock, an event, this month, last month, everything or two dates, and the export links under it carry the window. Save as a chapter keeps the window under a name โ a preset saves its exact window (a lock to the second), a range saves whole days.
What a slice holds: events, reflections, assignments, photos, bouts, trance sits, habit logs,
badges and Handler lines whose time is inside the window; health days inside it; every lock
that overlaps it, whole (daily.csv clips them per day, and keeps every zero day inside the
window); the chapters overlapping it with their notes; and the catalogues (activities, prompts,
tasks, kit) whole, so the ids resolve. Filenames carry the window's name:
latch-slice-locktober-2026-YYYYMMDD-HHMM.zip.
A slice is not a backup
Its rows point at rows outside the window, so latch.restore refuses it โ the JSON says
"kind": "slice" and the zip's MANIFEST.txt says so in its title. It carries no sealed key
and no playset files. And it is unencrypted, with every reflection in the window in full.
Reading a chapter. On the Chapters page, Read it opens the chapter as a story: a cover (the chapter's chosen photo, or its first), the foreword, what it added up to โ hours locked, the longest stretch, reflections, tasks, photos, badges, the most-locked day and the best headspace โ the hours and check-in trends across it (by week once it is longer than three months), and then every day in order: one line of what happened, its moments (locks, unlocks, tasks, badges), the reflections and check-ins written that day in full, its photos, and the looking-back notes about it. Runs of empty days fold into quiet for N days. A finished chapter is told as of its last day, so it reads the same whenever you open it; an open one grows until it ends.
Writing in a chapter. The story page is also where you add the second voice โ today's words about then. Write a foreword at the top; choose the cover photo from the photos taken inside the chapter (or leave it on the first); and under any day, add a note about this day โ about the whole day, or about one thing on it: a reflection, a photo, a lock or an unlock. A note says when you wrote it, and when you last revised it. A day that was folded as quiet can have a note too, from add a note about any day at the foot of the page. Notes never change the Log, and they are private prose: no shareable export carries them.
A goal. A chapter can carry one want, in your own words โ four days of guitar a week โ and, if you like, how the log should measure it: a weekly habit held to its own target (every full week, or a number of weeks on target), days locked, or days with a reflection. Set it on the story page (๐ฏ). While the chapter runs, the Handler opens its line with it every day and the dashboard shows it; the story โ and the keepsake โ say whether it was reached, and only once that is known: a count of days is reached the moment you hit it and not reached only when the chapter has closed short; a habit goal of "every week" on a chapter with an end is lost the moment one full week falls short; an open-ended chapter's is never decided, only read as k of w weeks on target. A goal with no measure reads said, not measured. It rides in pup.json and in the full export, and never in a shareable copy.
Keepsakes
A keepsake is a chapter as one file you keep: on its story page, open ๐ Make a keepsake,
set a passphrase, and Latch hands you a single .html file. Inside it is the whole story โ
cover, foreword, the summary and trends, every day with its reflections, photos and your
looking-back notes โ and the chapter's data (the same slice as latch.json?chapter=), with a
button to save it out.
- It opens anywhere, offline, forever. Any current browser, no Latch, no server, no network: the styles, the photos and the opener are all inside the file. It also prints (or Save as PDF) cleanly.
- It is encrypted. Locked, the file says only that it is a Latch keepsake โ tick show the title if you want the title on the locked page too. Typing the passphrase unlocks it in the browser; nothing is sent anywhere. The construction is the Pup ID's: scrypt (n = 2ยนโท, r = 8, p = 1) on the passphrase, then AES-256-GCM. Unlocking takes a few seconds on purpose.
- It has its own passphrase โ never your Pup ID's, so you can hand the file to someone without handing them your identity.
Save the passphrase now
Put it in a password manager (Bitwarden, 1Password, the Passwords app) or Apple Notes when you make the file. A keepsake whose passphrase is lost cannot be opened โ by you, by the person who wrote Latch, by anyone. There is no recovery and no back door, which is the point.
Photos are re-encoded (1600 px, or 1080 px for a smaller file, or left out) with their EXIF and location stripped; the originals stay in your box. A keepsake is a copy to keep, not a backup โ restore only from the full export.
A shareable copy
Beside it, ๐ค Make a shareable copy makes a second, different kind of file โ one meant to be shown to someone. It is plain and unencrypted, and it is built from a short list of what may leave, never by trimming the private keepsake:
- In it: the title you give it, the dates, hours locked and the longest stretch, the hours chart, each day's locks, unlocks, time added or taken off, tasks and spins, your game badges, and only the photos you tick โ stamped with your share stats by default (never a location), or plain.
- Never in it: your reflections, check-ins and their scores (no pills, no chart, no "best headspace", not even how many you wrote), confessions, looking-back notes, the foreword, a lock's note, photo captions, kit, and badges about kit, health or habits you are leaving.
Chapters are yours, so they travel. They ride in the full backup like every other table, and
in pup.json: a note about one event arrives on the new box as a note about that event's day,
because the event itself stays behind. A pup.json made before chapters still arrives; one made
on a box with chapters needs a box at least as new to arrive on.
pup.json
Your pup without the game: the sealed key, your profile, every paper, the toys and gear and the photos you marked ๐งณ (photos re-encoded โ no location, no camera details, no caption). No log, no reflections. It needs a passphrase set on the Pup ID page; without one the route sends you there. See Identity.
pup.json holds your sealed key
Never hand it to anyone โ not a Handler, not a pack. Whoever has it and your passphrase is your pup. It is for arriving on your own box.
pup.json carries no pack. Each den keeps your membership through the box you made it on, and
refuses a join by your Pup ID from the new box until it has let go of the old one โ so leave your
packs there first (Packs), then join again from the new box with
fresh tags. This page and the Pup ID page name every pack a pup.json would leave behind, with a
link to leave each; the download still works (a warning, not a block). bundle.zip keeps your packs.
CSV conventions
- Every timestamp appears twice: the epoch column is the stored truth; the
*_localcolumn (YYYY-MM-DD HH:MM:SS, no offset, zone in thetzcolumn) is what a spreadsheet parses. - An empty score cell means no measurement was taken. It does not mean zero โ the scale starts at 1.
- Files carry a UTF-8 BOM so Excel reads them correctly. Python readers want
encoding="utf-8-sig". - Voided rows stay in
events.csvwith theirvoided_at, because the log is append-only and the export is the log.
Backing up an instance
The container writes to one volume (/data). A backup is that volume, taken WAL-safely:
# the database โ never `cp` a live SQLite file; the WAL may hold hours of writes
docker exec latch python -c "import sqlite3; s=sqlite3.connect('/data/latch.db'); d=sqlite3.connect('/tmp/latch.backup.db'); s.backup(d); d.close()"
docker cp latch:/tmp/latch.backup.db ./latch-$(date +%F).db
# the media โ plain files, copy them however you like
docker cp latch:/data/media ./media-$(date +%F)
Or simply download /export/bundle.zip on a schedule you will actually keep. Verify a backup
by a content watermark, never by file size:
SELECT COUNT(*), MAX(ts) FROM events;
The same two numbers on the live database and the copy mean the copy is current.
Restoring a bundle
The app restores its own bundle:
python -m latch.restore --data-dir /data --bundle latch-bundle-20260920-1024.zip --passphrase-file pass.txt
It refuses a box that is not fresh (no locks, no events, no badges, no Pup ID) and a bundle
made by a newer Latch. Before it writes anything it also refuses a bundle whose files or
photo rows point outside the data folder, a sealed key that is not the Pup ID the bundle names,
a passphrase that does not open it โ so a typo costs nothing: run it again โ and a box whose
clock is behind the bundle's newest record (set the clock, then run it again: a resume
dated after the restore's pause would otherwise lift that pause by itself once the clock
caught up). Then it inserts
every table in order with foreign keys off, checks every table's row count against
MANIFEST.txt and runs PRAGMA foreign_key_check (a mismatch writes nothing and exits 2), and
commits the rows together with the pause below; then it copies the photos, the playset files
and the sealed key back. With the passphrase it unlocks your Pup ID; without it the Pup ID
waits, locked, for the Pup ID page. Leave out --passphrase-file and it asks.
In the container (the setup wizard's install, or any Docker one), run it from the install folder: copy the zip into the data volume, restore inside the container, then restart it.
docker cp latch-bundle-20260920-1024.zip latch:/data/
docker compose exec latch python -m latch.restore --data-dir /data --bundle /data/latch-bundle-20260920-1024.zip
docker compose restart
A box you have opened but not played on is still fresh, so the running app does not stand in the
way. The zip can be deleted from /data afterwards
(docker compose exec latch rm /data/latch-bundle-20260920-1024.zip).
Exit codes: 0 restored ยท 1 refused, nothing written ยท 2 the check failed (nothing
written), or copying the files failed after the rows were committed โ it then says so
("restored, but: โฆ"), and the box is already paused.
A restore pauses your packs and your AI
If you granted anybody anything, the restored box comes back paused โ nobody else can see or do anything until you have looked at Consent and resumed. A restored box is also never "packing to move": it is the box you moved to โ though it keeps the record of when the old box was packing, so a command a Handler sent to it meanwhile never lands here.
Only the photo files the restored rows name are copied back; anything else under media/ in a
bundle is skipped and counted. A bundle made on a box that never set a passphrase carries no
sealed key, and the game comes back with no Pup ID: make a new one on the Pup ID page, or
arrive yours there from a pup.json. The papers the old key signed stay as history, under
Signed by an earlier Pup ID; the new one papers every badge afresh.
Moving to a new box
A move is explicit, and it happens on the Pup ID page (Identity has every step):
- Prepare to move. Packs, your AI and papers stop; your own game โ the lock, the emergency key โ keeps working. Cancel any time before you retire.
- Take your pup: the whole game as
bundle.zip, restored on the new box withpython -m latch.restore(above), or just the pup aspup.json, arrived on the new box's Pup ID page. - Retire this box once the pup is alive on the new one. It asks for the passphrase that opens the sealed copy โ the proof that the copy you are relying on works โ then deletes this box's plain key and keeps the sealed one. It refuses while no sealed copy exists.
- Bring my pup back undoes a retire, with the same passphrase.
Restoring by hand
- Start a fresh instance (empty volume) so the schema exists.
- Stop it. Replace
/data/latch.dbwith your backup (or importlatch.jsontable by table with foreign keys off โevents.voided_bypoints forward to a later id, so an id-ordered insert with them on fails partway; the JSON lists tables in a safe order). - Copy
media/andplaysets/back under/data/. Thumbnails are optional: a missing one is rebuilt from the original on demand. Withoutplaysets/your playsets come back with their badge art missing. - Start it.
/healthzreports the version and the session state;/progressshould read as it did.
Run PRAGMA foreign_key_check after a JSON import. A media row whose bytes are gone shows in
the gallery as a refusal, not a crash.
Where the data is
| Container | Dev on a workstation | |
|---|---|---|
| database | /data/latch.db (WAL mode) |
./.data/latch.db |
| media | /data/media/YYYY/MM/ + media/thumbs/ |
./.data/media/ |
| playset files | /data/playsets/<slug>/ (art, scores, README) |
./.data/playsets/ |
| your Pup ID | /data/identity/ โ pup.key (0600; plain unless seal-at-rest), pup.key.enc (sealed), instance.key |
./.data/identity/ |
| the name given on Settings | /data/allowed_hosts.pin (only if you did not set LATCH_ALLOWED_HOSTS) |
./.data/allowed_hosts.pin |
| settings | rows in the settings table |
same |
Nothing is stored anywhere else.
Originals keep their EXIF
The full backup is a backup: photos go in exactly as you uploaded them, with whatever your
camera wrote into them (GPS, the camera's serial). Share cards, stamped photos, recaps and the
photos in pup.json are re-encoded and carry none of it.
Your volume backups hold your key
A backup of the whole /data volume includes identity/pup.key in plain form unless
seal-at-rest is on (see Identity).
This page also ships inside the app, at /guide โ so your own instance always serves the guide for the version you are running, with the internet unplugged. Get Latch ยท Something wrong here? Tell me.