The backend is your GitHub, and there is no server for this app anywhere. Rather than cloning the repository, it fetches exactly the files it needs through the GitHub API.
What it reads, what it writes
| Paths | |
|---|---|
| Reads | Card .md files (which files are cards) / data/reviews/YYYY-MM-DD.jsonl / data/fsrs.json / data/insights.json |
| Writes | data/reviews/YYYY-MM-DD.jsonl (your grades) / data/insights.json (weak cards) / data/fsrs.json (only when the Mac build optimises it) |
There is no code path that writes a card. Nothing rewrites them, nothing deletes them.
data/insights.json holds up to 15 of your weakest cards (most lapses first) and per-deck totals, due and new counts. The app only writes it and never reads it back. It is there so an AI agent that opens the repository can see what you are struggling with without waiting for a clipboard hand-off.
What is allowed through is an allowlist
GitHub’s tree API has no server-side path filter — the whole tree comes back, so filtering is the client’s job.
- It is an allowlist, not a blocklist, so that when a new directory appears in your repo the answer defaults to “don’t sync what we don’t know”
- On top of that,
_references/audio/episodes/podcast/sources/.git/.github/.claudeact as a tripwire. The structural rules should already have excluded them, so anything caught here is a bug — and it is surfaced in settings rather than silently dropped
Measured on one real vault: of 6,351 tracked files, 5,839 (91%) were under
_references/— scanned textbooks. Those never reach the device.
The order matters
Get the head → get the tree → fetch only what changed → merge → push
- Before fetching contents, the device computes each blob’s SHA and compares. Unchanged files are never fetched
- A write finishes in three requests regardless of how many files changed
- The push is conditional: if another device moved ahead, it is rejected, and the app re-fetches and retries
- The merge always happens before the push. If review-log lines were added remotely between fetch and push, pushing without merging would erase them
Review logs merge as a union
When a day’s file exists both remotely and locally, it takes the union of lines, keyed by card id plus grade timestamp. Nothing is ever overwritten.
Grade on several devices on the same day and lines are only ever added. This works because the log is append-only.
Where it lives on the device
App data/
├── vault/ cards fetched by sync
├── reviews/ review logs
└── manifest.json a ledger of which file you hold at which version
The ledger sits outside vault/, so the whole vault can be deleted and re-synced. Deletion is limited to paths recorded in the ledger, and review logs are excluded from deletion entirely.
“Erase this device’s data” in settings removes this device’s vault, review logs and token. Nothing on GitHub changes. Grades not yet synced cannot be recovered, so sync before you erase.
Manners on the wire
- Reads sit inside a budget of 5,000 requests per hour. The Mac build fetches six at a time so the first sync is not a wait (iOS and Android fetch one by one)
- Writes are serial, 1.1 seconds apart, on every platform
- Nothing is sent while you are not connected. The demo reads a public repository unauthenticated, which uses a 60-per-hour budget
Trial grades are not sent
Grades made on the demo, on the bundled samples, or before you have ever picked a repository of your own are written to a separate place on the device. The moment you pick your own repository they are let go, and the app tells you it let them go.
A grade for a card that does not exist in your vault would sit in data/reviews/ forever — the log is append-only, so the app could never remove it — and it would pollute both the weak-card list and the optimiser’s input.
The separation is physical on iOS; Mac and Android stop it at the send step instead (trial grades stay on the device but never enter your repository).