This article was co-authored with a generative AI. We have cross-checked facts against official documentation where possible, but errors may remain. Please verify against primary sources before making important decisions.
Tropy is a research-photo manager for humanities scholars — an Electron app that lets you bundle scanned source images with metadata, notes, and tags. A colleague asked whether Tropy could be driven from the command line, which led to a deeper look at its internals and a fairly thorough exercise of every interface it exposes.
The short version: Tropy has three layers you can operate at.
- The project files (
.tpy/.tropy) are SQLite databases — you can read and write them directly - With
--port <num>Tropy stands up an HTTP API server that lets you do CRUD against the open project from outside - Below that is the Redux + redux-saga layer, which is reachable only from inside the Tropy process
This article walks through all three with concrete code: generating an empty project, registering images, attaching metadata / notes / tags / selections / transcriptions / list hierarchies, exporting JSON-LD, and exercising every documented HTTP API route. As a side effect we found two bugs in upstream and submitted one PR and one issue.
What was tested
- Tropy 1.18.0-beta.1 (
mainHEAD:11a086d70, as of 2026-05-15) - macOS 26.4.1 / Apple Silicon
- Node 25.6.0 (Tropy itself bundles better-sqlite3; the scripts in this article use the built-in
node:sqlitemodule to stay independent of Tropy's Electron-ABI native binding)
Layer 1: .tpy is a SQLite database
Tropy projects come in two formats: .tpy (a single file) and .tropy (a managed directory with project.tpy + assets/ inside). Both use SQLite as the storage engine — the single-file variant in rollback-journal mode and the managed variant in WAL (Write-Ahead Logging) mode. The schema is dumped to db/schema/project.sql (~400 lines) and breaks down roughly as:
project UUID, name, base, store
subjects shared id allocator across items / photos / selections
items items (a bundle of photos)
images physical attributes (width, height, angle, brightness …)
photos photo files belonging to items (path, checksum, mimetype …)
selections rectangular regions inside a photo
notes rich-text (ProseMirror) attached to photos or selections
transcriptions OCR/manual transcription text
metadata (subject, property URI, value_id) triples — Dublin Core + Tropy
metadata_values immutable value table with a (datatype, text) UNIQUE
tags / taggings tag definitions and links
lists / list_items hierarchical lists and item membership
trash tombstones (reason: user / auto / merge)
fts_notes / fts_metadata / fts_transcriptions FTS5 indices
The standout design choice is the subjects table. items, photos, and selections live in separate tables, but they all share the subjects.id namespace. So an integer id in Tropy always means "some subject" — figure out which by looking up which child table contains it.
Generating an empty project from Node
Feed the schema file straight into the sqlite3 CLI and you have a fresh .tpy Tropy can open. One subtlety: Tropy's dump uses PRAGMA writable_schema = ON to register its FTS5 virtual tables by writing rows into sqlite_schema directly. Node 22+'s built-in node:sqlite (DatabaseSync) rejects that via exec(), so the cleanest pattern is to initialize through the CLI and then populate from Node:
import { execFileSync, readFileSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { randomUUID } from 'node:crypto'
// 1. schema via sqlite3 CLI
execFileSync('sqlite3', [TPY_PATH], {
input: readFileSync(SCHEMA),
stdio: ['pipe', 'inherit', 'inherit']
})
// 2. populate via node:sqlite
const db = new DatabaseSync(TPY_PATH)
db.exec('PRAGMA foreign_keys = ON')
db.prepare(`
INSERT INTO project (project_id, name, base, store)
VALUES (?, ?, 'project', NULL)
`).run(randomUUID(), 'Demo')
That's all you need; Tropy can open the file via File → Open and treat it as an empty project.
Registering an item
A single item requires at minimum five inserts: a subject (the item id), an items row, another subject (the photo id), an images row, and a photos row. Metadata adds one more pair (metadata_values + metadata):
// item
const itemId = Number(insSubject.run(TPL_ITEM).lastInsertRowid) // 'https://tropy.org/v1/templates/generic'
insItem.run(itemId)
// photo attached to the item
const photoId = Number(insSubject.run(TPL_PHOTO).lastInsertRowid) // 'https://tropy.org/v1/templates/photo'
insImage.run(photoId, width, height)
insPhoto.run(
photoId, itemId, /*position*/ 1, relPath,
'image/jpeg', checksum, filename, size
)
setCover.run(photoId, itemId)
The templates/generic URI marks an item subject; templates/photo marks a photo subject; templates/selection marks a selection subject. The subjects.template column is what discriminates the kind.
Metadata writes are mildly tricky because metadata_values is immutable (an update_metadata_values_abort trigger forbids UPDATE). Each (datatype, text) pair must exist exactly once. The pattern is INSERT … ON CONFLICT (datatype, text) DO NOTHING followed by a SELECT value_id, then a metadata row that ties (subject_id, property_uri, value_id) together.
Layer 1.5: the managed .tropy format
A .tropy is a directory containing project.tpy and assets/. Per src/asset/store.js, assets are content-addressable: each file is named <checksum><ext>:
// src/asset/store.js
getPathFor (asset) {
return join(this.root, `${asset.checksum}${asset.ext.toLowerCase()}`)
}
photos.path stores a relative path (assets/<checksum>.jpg) from the project directory, and since project.base = 'project', Tropy resolves photo files relative to wherever the project dir lives.
Tropy uses MD5, not SHA-1
A subtle trap: our populate script computed SHA-1 and wrote assets/<sha1>.jpg. When Tropy opens the project it re-hashes each photo and compares against photos.checksum. The hash function it uses is MD5 (src/asset/asset.js:145), not SHA-1. The mismatch was treated as "unknown file", and Tropy re-imported each asset under its MD5 name, leaving the SHA-1 files orphaned in assets/. The fix is to use MD5 from the start.
What we exercised at Layer 1
This layer alone covers a remarkable amount of ground. Working from the Tropy source we tried each in turn:
- Multi-page items: multiple
photosrows pointing at the sameitem_id, ordered byposition - Tags:
tags(withnameUNIQUE) +taggings(id × tag_id); colours are arbitrary strings intags.color - List hierarchy:
lists.parent_list_idplus anupdate_lists_cycle_checktrigger preventing loops;list_itemsfor membership - Selections: a selection subject + an images row (dimensions of the cropped region) + a selections row (
photo_id, top-left x/y) - Photo transforms:
images.angle / mirror / brightness / contrast / hue / saturation / sharpen / negative - Notes: the ProseMirror state as JSON string, the plain text version (for FTS), and a language code
- Transcriptions:
transcriptionstable —text(plain),data(ALTO (Analyzed Layout and Text Object) XML etc.),config(JSON with plugin name and OCR settings),status0 = pending / 1 = done - Soft delete:
INSERT INTO trash (id, reason)to hide,DELETE FROM trashto restore - JSON-LD export: traversing items × photos × notes × selections × tags × lists, using Dublin Core + Tropy URIs in
@vocab
What ProseMirror nodes and marks are allowed is documented by src/editor/schema.js. Nodes: doc / paragraph (with an align attr) / text / hard_break / blockquote / horizontal_rule / ordered_list / bullet_list / list_item. Marks (eight of them): italic / bold / underline / overline / strikethrough / link / superscript / subscript.
Direct-SQL caveats
Bypassing the Redux/saga pipeline means none of Tropy's side effects fire:
- No cache invalidation
- No UI event dispatch (a running Tropy will not see your changes until the project is closed and reopened)
- No auto OCR / transcription trigger
- No validation beyond what the SQL triggers enforce
If Tropy is holding the project open, write conflicts are real. The safe pattern is BEGIN IMMEDIATE so you fail-fast on contention, or quit Tropy with Cmd+Q before running the script.
Layer 2: the HTTP API
Start Tropy with npm start -- --port 2019 path/to/some.tropy and it stands up an HTTP server on 127.0.0.1:2019. The implementation is src/main/api.js plus src/common/api.js — Koa + @koa/router, roughly 30 routes. From src/common/api.js:427-465:
GET / root info
GET /version version
POST /project/import import via file or JSON-LD data
GET /project/items items (?tag=, ?q=, ?sort=)
GET /project/items/:id item detail
GET /project/items/:id/photos photos of an item
GET /project/items/:id/tags tags of an item
POST /project/items/:id/tags link an existing tag to an item
DELETE /project/items/:id/tags unlink a tag from an item
GET /project/items/:id/transcriptions transcriptions for an item
GET /project/tags all tags
POST /project/tags create tag (item= optional for linking)
DELETE /project/tags delete tag globally
GET /project/tags/:id tag detail
GET /project/data/:id metadata for a subject
POST /project/data/:id save metadata (JSON body)
GET /project/lists{/:id} list tree
GET /project/lists/:id/items items in a list
GET /project/notes/:id note (?format=html|text|md)
POST /project/notes create note (html + photo|selection)
DELETE /project/notes/:id delete note (tombstone)
GET /project/transcriptions/:id transcription (?format=text|alto)
POST /project/transcriptions create transcription
GET /project/photos/:id photo metadata
GET /project/photos/:id/raw raw bytes
GET /project/photos/:id/file.:format jpg/png/webp/raw transcoded
GET /project/selections/:id selection metadata
GET /project/selections/:id/file.:format selection as image
The API operates on current = () => app.state.recent[0] — the currently open project. Whatever project was passed at startup becomes the implicit target; switching projects in the GUI re-points the API.
Is the API actually up?
If Tropy is launched without --port, no server is started (src/main/api.js:35 short-circuits). lsof -nP -iTCP -sTCP:LISTEN -p <pid> against the main process is a quick check — no listening sockets means quit and relaunch with --port.
POST-side gotchas
POST /project/notesisapplication/x-www-form-urlencodedwithhtml(body) plus eitherphotoorselection(the parent subject id) and an optionallanguage. Returns{ id: [<note_id>] }. The UI updates live.POST /project/data/:idis the one route expectingapplication/json. Body shape:{ "<property-uri>": { type, text } }, e.g.{ "http://purl.org/dc/elements/1.1/description": { type: "http://www.w3.org/2001/XMLSchema#string", text: "..." } }. Also live-updates.POST /project/tagswithitem=<id>creates-or-finds the tag and links it to an item in one call. The easiest way to "tag this item with X" when X may not exist yet.POST /project/items/:id/tagsassumes the tag name exists. If it doesn't,findTagIdsreturns NaN and Tropy responds 500 (SQLITE_ERROR: no such column: NaN).POST /project/importwithfile=<path>imports a local file as a new item. Withdata=<json>you can stream JSON-LD straight in.
POST /project/notes accepts plain HTML and Tropy converts it to ProseMirror state internally via fromHTML(html) — you don't have to hand-roll the ProseMirror JSON.
Image retrieval
GET /project/photos/:id/raw streams the original file, and file.<png|jpg|webp|raw> returns a server-side sharp conversion. In our test the JPEG (18 KB original) came back as WebP at 6.7 KB — useful if you want Tropy to handle thumbnail generation for you.
Side findings
Transcriptions are versioned in the UI
Reading src/components/transcription/panel.js shows that the transcription panel renders an <ol class="transcription-versions"> of every transcription attached to a photo, with click-to-activate switching between them. So the design supports "OCR re-run with different parameters" and "archivist's manual correction" living as parallel versions of the same photo's text. Useful for research workflows that don't want to overwrite primary OCR output.
FTS5 and CJK
Tropy's FTS5 tables use tokenize = 'porter unicode61'. unicode61 splits on Unicode categories, so a long run of Japanese/Chinese characters with no whitespace becomes a single token:
"京都市中心部 — 御所周辺の区画。" → one token (the whole left side of the dash)
MATCH '京都' therefore returns nothing; MATCH '京都*' (prefix) or MATCH '京都市中心部' (full phrase) does. If you need substring search, switching to a trigram tokenizer via a custom migration solves it at the cost of a larger index.
Two upstream bugs
Bug 1: POST /project/transcriptions always 500s
src/common/api.js:187 calls act.transcriptions.create(...), but src/actions/api.js only exports transcription (singular). act.transcriptions is therefore undefined, and reading .create throws a TypeError. Every POST returned 500.
- let { payload } = await rsvp('project', act.transcriptions.create({
+ let { payload } = await rsvp('project', act.transcription.create({
gh search turned up no existing report. Submitted as Pull Request #965.
Bug 2: Successful POST doesn't update the Redux store
With Bug 1 patched, POST /project/transcriptions returns 200 and the row lands in SQLite, but an immediate GET /project/transcriptions/<id> returns 404. Reason: TranscriptionCreate (src/commands/api/transcription.js) writes to the DB and sets this.undo / this.redo but never yield puts anything to populate state.transcriptions. The show endpoint reads from Redux state, so the new row is invisible until the project is reloaded.
Compare with NoteCreate (src/commands/api/note.js):
// note — explicitly updates the store
let note = yield call(mod.note.create, db, { id, state, text, language })
yield put(act[type].notes.add({ id, notes: [note.id] }))
yield put(act.note.select({ note: note.id, photo, selection }))
// transcription — no store update
let transcriptions = yield call(db.transaction, async tx => ...)
this.undo = slice.remove(...)
this.redo = slice.restore(...)
return transcriptions.reduce(...)
There are several plausible fixes (extra reducer on the transcriptions slice for api.transcription.create, an explicit yield put(slice.insert(...)) in TranscriptionCreate, dispatching photo.transcriptions.add to mirror NoteCreate). Rather than pick one unilaterally, this was filed as Issue #966 so the maintainer can pick the shape they prefer.
Layer 3: Redux and DevTools
The Redux store isn't exposed from outside the process, but Tropy's window does open with DevTools (Cmd+Opt+I on macOS). From the console you can dispatch directly: window.store?.dispatch({ type: 'transcriptions/insert', payload: [...] }). Useful for poking UI state without writing to the DB at all.
Takeaways
- If a desktop app stores its data in SQLite, you can almost always drive it from outside. Electron + SQLite is a common shape; the same patterns transfer between apps
- Apps that don't ship a CLI often still embed an HTTP API.
--helpandsrc/main/args.js(or equivalent) are good first stops to look for--portflags - Each layer has a trade-off. Direct SQL is fastest and most powerful but bypasses side effects; the HTTP API integrates with the live UI but is constrained to the documented surface; DevTools dispatch is the most surgical way to manipulate UI state
- Verification can contribute upstream. A one-character typo broke an entire endpoint here, and finding it during exploratory testing is a natural way to give back
Related links
- Tropy and the tropy/tropy repository
- Pull Request #965 and Issue #966
- All the test scripts referenced here live locally under
demo/scripts/(create-demo / populate-aaa / add-note-tags / extras / extras-2 / try-api)




Comments
…