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.

  1. The project files (.tpy / .tropy) are SQLite databases — you can read and write them directly
  2. With --port <num> Tropy stands up an HTTP API server that lets you do CRUD against the open project from outside
  3. 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 (main HEAD: 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:sqlite module 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 photos rows pointing at the same item_id, ordered by position
  • Tags: tags (with name UNIQUE) + taggings (id × tag_id); colours are arbitrary strings in tags.color
  • List hierarchy: lists.parent_list_id plus an update_lists_cycle_check trigger preventing loops; list_items for 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: transcriptions table — text (plain), data (ALTO (Analyzed Layout and Text Object) XML etc.), config (JSON with plugin name and OCR settings), status 0 = pending / 1 = done
  • Soft delete: INSERT INTO trash (id, reason) to hide, DELETE FROM trash to 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/notes is application/x-www-form-urlencoded with html (body) plus either photo or selection (the parent subject id) and an optional language. Returns { id: [<note_id>] }. The UI updates live.
  • POST /project/data/:id is the one route expecting application/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/tags with item=<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/tags assumes the tag name exists. If it doesn't, findTagIds returns NaN and Tropy responds 500 (SQLITE_ERROR: no such column: NaN).
  • POST /project/import with file=<path> imports a local file as a new item. With data=<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. --help and src/main/args.js (or equivalent) are good first stops to look for --port flags
  • 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
  • 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)