JSON in, JSON out. The app reaches these routes through the api Tauri
command (share_api for /api/share/*); the headless bin serves them over
HTTP, and the dev shim serves all but /api/share/*. Errors carry
{status, detail} over IPC and {detail} with the HTTP status over HTTP.
Body and Response name the wire struct (see src/types/generated.ts)
when the handler names one directly; blank means not statically named. Notes
is the handler's doc comment.
Source: route/annotations.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/annotations?source_id=&project_id=&all_projects= |
Unknown paper → {"annotations": []} (not 404). |
||
| POST | /api/annotations |
AnnotationCreateBody |
404 if the paper is not in the library. | |
| PATCH | /api/annotations/{id} |
AnnotationUpdateBody |
OkReceipt |
edit the comment and optionally the anchor. 404 if no row matched. |
| DELETE | /api/annotations/{id} |
OkReceipt |
404 if no row matched. |
Source: route/authors.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/authors?exclude_single= |
AuthorsResponse |
||
| GET | /api/authors/{id} |
|||
| GET | /api/authors/{id}/merge-candidates |
MergeCandidates |
likely-duplicate suggestions for the merge UI, split by evidence strength: candidates shares this author's ORCID (near-certain duplicate), name_candidates only its exact full name (NOCASE — weak evidence). An author matching both surfaces once, as ORCID. |
|
| PATCH | /api/authors/{id} |
AuthorUpdateBody |
forwards the (all-optional) fields to update_fields, then returns the same detail shape as GET. |
|
| POST | /api/authors/{id}/merge |
AuthorMergeBody |
AuthorMergeResponse |
fold duplicate_ids into author {id}, re-pointing their papers, then returns the canonical author's detail shape. |
| POST | /api/authors/{id}/papers/{pid} |
OkReceipt |
attach one paper to an author without merging whole author records. Idempotent: relinking a pair is a no-op under the unique link index. 404 if either side is absent — the FK constraint would otherwise surface as a 500. | |
| DELETE | /api/authors/{id}/papers/{pid} |
OkReceipt |
detach one paper from an author. Unlinks every stored version of the paper's root: link rows are per-version and the author detail page shows latest-version ids, so an author linked only to v1 must still detach when addressed by v2's id. Idempotent on an absent link; unlinking a paper's last author is allowed (author-less papers are a legal state — delete requires it). |
|
| DELETE | /api/authors/{id} |
OkReceipt |
404 if absent, 409 if still linked to papers; both guards live in svc_author::delete. |
Source: route/editor.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/editor/projects?project_id= |
EditorProjectsResponse |
||
| POST | /api/editor/projects |
CreateEditorProjectBody |
BadRequest → 400; returns core's created payload. |
|
| GET | /api/editor/projects/{id}/doc |
|||
| POST | /api/editor/vault/{id}/fs |
FsOp |
parse the FsOp (422), 404 if the note is not an editor project, then run it. NotFound → 404, else 400. |
Source: route/feed.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/feed |
|||
| POST | /api/feed/dismiss |
FeedDismissBody |
OkReceipt |
hide an entry. permanent: true blocks the whole paper; otherwise dismisses just this version (the default). |
| GET | /api/feed/rules |
FeedRulesResponse |
list auto-filter rules. | |
| POST | /api/feed/rules |
FeedRuleCreateBody |
CreatedFeedRule |
create an auto-filter rule. |
| DELETE | /api/feed/rules/{id} |
OkReceipt |
remove an auto-filter rule. 404 if absent. |
Source: route/graph.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/graph?exclude_single_authors= |
the whole GraphView. |
Source: route/history.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/history/actor |
DeviceActor |
this device's journal actor (None before init). | |
| GET | /api/history/library |
|||
| GET | /api/history/library/diff |
|||
| POST | /api/history/library/restore |
|||
| GET | /api/history/project/{fk} |
|||
| GET | /api/history/project/{fk}/diff |
|||
| POST | /api/history/project/{fk}/restore |
|||
| GET | /api/history/share/{id} |
|||
| GET | /api/history/share/{id}/diff |
Source: route/mod.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/stats |
service::stats owns the envelope; all three surfaces emit it. |
||
| GET | /api/categories |
wraps the list in {"categories": …} (the CLI emits a bare array; the envelope divergence is deliberate). |
Source: route/notes.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/notes?source_id=&project_id=&all_projects= |
Unknown paper → {"notes": []} (not 404). |
||
| GET | /api/notes/{id} |
single note by NOTE_SK, for the dedicated note page. 404 if no row matched. | ||
| POST | /api/notes |
NoteCreateBody |
404 if the paper is not in the library. | |
| PATCH | /api/notes/{id} |
NoteUpdateBody |
404 if no row matched. | |
| DELETE | /api/notes/{id} |
row + vault tree drop together. |
Source: route/orcid.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| POST | /api/orcid/backfill |
OrcidBackfillBody |
OrcidBackfillResponse |
one pass over limit random ORCID-less, DOI-linked authors, via CrossRef then OpenAlex per DOI (paced, one write). |
Source: route/papers.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/papers?limit=&offset=&sort=&dir=&project= |
sort: published (default) / added / title; dir defaults per metric (newest first, titles A–Z). project narrows in SQL; an unknown id yields []. |
||
| GET | /api/papers/sfk/{fk}/versions |
|||
| GET | /api/papers/sfk/{fk}/doi-candidates |
other paper roots sharing this one's DOI, i.e. merge candidates. | ||
| GET | /api/papers/sfk/{fk}?version= |
the bare paper object, no envelope. | ||
| PUT | /api/papers/sfk/{fk} |
rebuilds metadata from the existing paper's identity (source_id/version/source) + the PUT body. | ||
| POST | /api/papers/sfk/{fk}/merge |
PaperMergeBody |
merge_papers. Merges the duplicate root named in the body INTO this paper (this paper's metadata is canonical; the duplicate's notes, annotations, memberships, tags, missing versions and PDFs move over, then the duplicate root is deleted). 404 on unknown roots, 409 on a self-merge, a trashed root, or a shared-project loser. |
|
| DELETE | /api/papers/sfk/{fk}/projects |
remove the paper from every project. | ||
| GET | /api/papers/search?q=&limit= |
|||
| POST | /api/papers/saved |
PaperSavedBody |
which of the given canonical stored ids (arxiv:2204.12985-style, the wire entry_id) the library holds active. Matches are echoed verbatim; trashed and unknown ids are simply absent. The search page's saved-indicator query is the caller. |
|
| GET | /api/papers/full-text-pending |
how many fetchable arXiv papers still lack TeX, i.e. full_text_worker's remaining backlog. |
||
| POST | /api/papers/{id}/full-text?force= |
download the paper's arXiv TeX tarball, extract, and store the text so papers_fts has something to match. Not automatic on save (arXiv pacing + tarball size); the opt-in background path is full_text_worker, which chews through the same backlog. |
||
| GET | /api/papers/{id}/full-text-diff?from=&to= |
FullTextDiffResponse |
the splice hunks between two stored versions' TeX bodies, computed per request (no storage). 404 when either version has no stored text; 422 on bad/equal versions. | |
| GET | /api/papers/{id} |
the bare paper object, no envelope. | ||
| DELETE | /api/papers/{id} |
soft-delete into the trash. |
Source: route/pdfs.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/papers/{id}/pdf-path?version= |
|||
| GET | /api/pdfs |
latest-version papers whose PDF is on disk, with file sizes, largest first, capped at 200. | ||
| DELETE | /api/pdfs/{id} |
drops every version's local PDF (409 if a file is outside the managed dir), keeping the paper record. |
Source: route/projects.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/projects?status= |
ProjectsResponse |
default "active"; "all" => no filter. | |
| POST | /api/projects |
ProjectCreateBody |
CreatedProject |
|
| GET | /api/projects/{id} |
not-found wording comes from CoreError::ProjectNotFound (the shared contract), mapped to 404 here. |
||
| PATCH | /api/projects/{id} |
ProjectUpdateBody |
OkReceipt |
partial update; color cleared only when the color_hex key is present in the body. |
| DELETE | /api/projects/{id} |
OkReceipt |
404 if absent, then soft-delete. | |
| POST | /api/projects/{id}/papers |
ProjectAddPaperBody |
core's shared receipt; ? maps PaperNotFound/ProjectNotFound → 404, ProjectDeleted → 400. |
|
| POST | /api/projects/{id}/papers/bulk |
ProjectAddPapersBulkBody |
BulkAddReceipt |
partial success. |
| DELETE | /api/projects/{id}/papers/{sid} |
sid arrives already percent-decoded in ctx.segs. |
||
| POST | /api/projects/{id}/export |
ProjectExportBody |
OkReceipt |
writes the .lxproj archive to dest_path, returns {ok}. No streaming branch: the Tauri frontend always sends a path. |
| GET | /api/projects/{id}/export/bibtex |
|||
| GET | /api/projects/{id}/export/obsidian |
|||
| GET | /api/projects/{id}/export/zotero |
Source: route/reading_status.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/reading-status |
{"statuses": {source_id: "reading"|"read"}}. Sparse: unread papers are absent. |
||
| PUT | /api/reading-status/{sid} |
ReadingStatusPutBody |
set the status in every reading list the paper is on; "unread" clears. applied = lists written (0 is a no-op, not an error). |
Source: route/search.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/search/history?prefix=&limit= |
SearchHistoryResponse |
limit defaults to 10, range 1–50: out-of-range or non-integer is a 422. |
|
| GET | /api/search/state |
SearchStateResponse |
{state: null|obj}. |
|
| POST | /api/search/state |
SearchStateBody |
OkReceipt |
the history recording and its settings gate live in service::search_state::save. |
Source: route/settings.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/settings |
settings first, then each present env key overlaid; missing env keys are skipped. Secrets become <KEY>_SET booleans. |
||
| PATCH | /api/settings |
Body |
OkReceipt |
loops set(k, v) over the body. |
| PATCH | /api/env |
EnvPatchBody |
OkReceipt |
allowlist-gated (400 otherwise). Sets the live process env var (source clients + the GET overlay read via std::env::var) and persists to user settings. set_var mutates global process env: a concurrent GET can race it (values are short ASCII — old-or-new, never torn). The persisted copy is NOT reloaded into the env at startup. |
Source: route/share.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| POST | /api/share/project/{id}/ticket |
TicketMinted |
publish the project if needed, then mint a pasteable ticket carrying the sender's address + share id. Access is gated by whether that id is currently published, not a per-recipient secret. | |
| POST | /api/share/join |
JoinedSummary |
dial the ticket's sender, fetch the CRDT doc, and materialize it as a read-only mirror under the receiver's share dir. Returns the resulting shared-project summary. | |
| POST | /api/share/project/{id}/publish |
PublishedReceipt |
snapshot a canonical project into the CRDT store (read-only over the canonical connection) and return its share_id. | |
| POST | /api/share/{id}/unpublish |
UnpublishedReceipt |
park the published doc as <id>.automerge.unpublished and delete its settings sidecar. An e2ee doc revokes every active member first, which stops the beelay node serving it. |
|
| POST | /api/share/received/{id}/import |
|||
| POST | /api/share/received/{id}/leave |
LeftReceipt |
delete the mirror + ticket + settings, and drop the beelay registration behind an e2ee mirror so a later rejoin adopts from scratch. forgotten: false = node down, so a rejoin reuses the old doc. |
|
| POST | /api/share/received/{id}/unlink |
UnlinkedReceipt |
detach the linked local project from a received share. Membership, mirror, and the project all stay; the interval sync keeps refreshing the mirror but stops importing (its import legs are gated on find_by_share_id). Re-importing creates a fresh link. |
|
| POST | /api/share/{id}/sync |
|||
| GET | /api/share/member_code |
MemberCode |
this device's pasteable membership code. | |
| POST | /api/share/relay/reconnect |
rebind the p2p node against whatever is currently saved under Settings → Sharing, without an app restart. Save the relay settings first (PATCH /api/settings), then call this. |
||
| GET | /api/share/received |
ReceivedListing |
every mirror's summary; pub for the headless bin. |
|
| POST | /api/share/project/{id}/publish_secure |
PublishedReceipt |
snapshot a canonical project into an e2ee share (doc under share_dir/e2ee, beelay-registered), sharing PDF blobs for papers with a local file, and seed the members sidecar with this device as hoster. |
|
| POST | /api/share/{id}/invite |
|||
| GET | /api/share/{id}/members |
MembersListing |
the roster (synced doc metadata ∪ the local invite sidecar), each entry truth-checked with a live query_role. Co-admin: management ops run on whichever admin-tier device issues them — keyhive delegation and PCS rotation happen locally and propagate through the session preamble — so this and the other member routes accept hosted AND received e2ee shares. A hosted store-only state (no node; tests) degrades to the sidecar-only legacy listing. |
|
| GET | /api/share/{id}/presence |
PresenceListing |
every member's last heartbeat. Open to all members (unlike members, which is admin-tier). |
|
| POST | /api/share/presence |
PresenceUpdate |
||
| POST | /api/share/{id}/member/{mid}/role |
|||
| POST | /api/share/{id}/revoke |
|||
| POST | /api/share/{id}/transfer_admin |
|||
| POST | /api/share/{id}/member/{mid}/remove |
RemovedReceipt |
revoke, then drop the sidecar row and roster entry entirely, so a re-invite of the same device starts clean (a revoked row keeps its stale role and dead invite string). Revoking is what withdraws the capability; the rows are bookkeeping. Already-revoked members skip to the row delete. Same admin-tier matrix as revoke_member. |
|
| POST | /api/share/{id}/rekey |
RekeyedReceipt |
re-encrypt a hosted e2ee share's history under the current epoch, then republish it (and re-key its PDF blobs) so every current member can read everything. Invites re-seal on their own; this repairs shares sealed before a member was invited, which leaves them fetching commits they hold no key for (keyhive #136) with no way out but this. | |
| POST | /api/share/{id}/pdf |
|||
| GET | /api/share/projects |
SharedProjectsListing |
summaries of every published shared project. pub so the headless bin's status aggregate reuses it. |
|
| GET | /api/share/received/{id} |
ReceivedDetail |
the full subgraph of one received mirror (plain, falling back to the e2ee mirror of the same id). | |
| GET | /api/share/{id}/settings |
the per-share sidecar (defaults if unset). |
Source: route/sources.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| POST | /api/arxiv/search |
ArxivSearchBody |
ArxivSearchResponse |
502 on any source error. save bulk-saves every result; saved_source_ids reports library membership either way. |
| POST | /api/arxiv/fetch |
ArxivFetchBody |
ArxivFetchResponse |
404 not-found / 502 other. The save is idempotent (INSERT OR IGNORE), so re-fetching a stored paper cannot conflict. |
| POST | /api/openalex/search |
OpenAlexSearchBody |
OpenAlexSearchResponse |
502 on any source error. Never saves; saved_source_ids reports what the library already holds. |
| POST | /api/openalex/save |
OpenAlexSaveBody |
OpenAlexSaveResponse |
404/400/502 on fetch. The save is idempotent (INSERT OR IGNORE). Returns the stripped stored source_id. |
| POST | /api/crossref/search |
CrossrefSearchBody |
CrossrefSearchResponse |
a results-only envelope. The wire body carries no sort, so this arm pins relevance; a transport failure is a 502 rather than an empty result list. |
| POST | /api/doi/resolve |
DoiResolveBody |
DoiResolveResponse |
a bad DOI is a 400 (CoreError::BadRequest via ?). |
| POST | /api/doi/save |
DoiSaveBody |
DoiSaveResponse |
resolve then save; returns the resolved meta. With pdf_url, then best-effort fetches the PDF (SSRF/quota guarded) onto it. |
Source: route/storage.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| POST | /api/storage/backup |
StorageBackupBody |
{dest_path} → {path, bytes}. Vacuums to a temp file, then renames over dest. Temp path includes process ID for collision safety. |
|
| POST | /api/storage/restore |
StorageRestoreBody |
OkReceipt |
{src_path} → {ok}. db_admin::restore_in_place owns the park/swap/reopen sequence (shared with the MCP tool); the state mutex is held throughout. The UI still tells the user to restart: in-flight frontend state is not rewound by the swap. |
| POST | /api/storage/import |
StorageImportBody |
{src_path} → ImportReport. Merges a backup into the live DB insert-only; unlike restore, nothing is replaced and no restart is needed. |
|
| GET | /api/storage/pre-migration-backups |
→ PreMigrationBackup[], the copies init takes before a schema upgrade, newest first. |
Source: route/tags.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/tags |
each tag carries its active-paper count so the index can render a table sortable by name or by count. | ||
| POST | /api/tags |
TagCreateBody |
CreatedTag |
svc_tag::upsert, same envelope as the CLI tag create. Upsert semantics: an existing label returns its id instead of erroring. |
| GET | /api/tags/{label} |
canonical label via tag::get. |
||
| DELETE | /api/tags/{id} |
DeletedTag |
svc_tag::delete by numeric id. Core's delete is a silent no-op on an unknown id, so existence is checked first for the 404. |
|
| POST | /api/papers/{source_id}/tags |
PaperTags |
svc_paper::add_paper_tags; returns the paper's full tag list after the union. Unknown paper → PaperNotFound → 404. |
|
| DELETE | /api/papers/{source_id}/tags |
PaperTags |
svc_paper::remove_paper_tags; returns the remaining tags. Unknown paper → PaperNotFound → 404. |
Source: route/trash.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/trash |
the canonical TrashListing envelope, shared with linxiv trash list and MCP list_trash. |
||
| POST | /api/trash/projects/{id}/restore |
un-trash a project. 422 on a non-integer id, 404 if absent, 400 if the project is active or archived rather than trashed. | ||
| DELETE | /api/trash/projects/{id} |
permanently delete a trashed project. 422 on a non-integer id, 404 if absent, 400 if it is not in the trash. ?force=1 skips the trash guard and is irreversible. |
||
| POST | /api/trash/{rest..}/restore |
404 unless the paper is actually in the trash. | ||
| DELETE | /api/trash/{rest..} |
permanent, so it 404s unless the paper is in the trash; use DELETE /api/papers/{id} to trash one. ?force=1 skips that guard (only 404 on a missing paper) and is irreversible. |
Source: route/uploads.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| PUT | /api/papers/{id}/pdf |
UploadPdfBody |
store PDF bytes the client already fetched for a saved paper. Check order: 413 size → 404 paper → 400 magic. | |
| POST | /api/papers/import/pdf |
ImportPdfBody |
resolve metadata (network) OUTSIDE the DB lock, then run the sync DB+FS import under it. | |
| POST | /api/papers/import/recognize |
RecognizeBody |
pure classify of a pasted string, no network. | |
| POST | /api/papers/import/pdf-url |
ImportPdfUrlBody |
SSRF/size-guarded fetch of a direct PDF URL, then the same two-phase import as /import/pdf. |
|
| POST | /api/papers/import/bibtex |
ImportBibtexBody |
service::paper_import owns guard order, parse and link; ? maps ProjectNotFound → 404, the other refusals → 400. |
|
| POST | /api/projects/import/preview |
ImportPreviewBody |
spill the decoded bytes to a temp .lxproj (preview takes a path); any error is a 400. Temp removed on every path. |
|
| POST | /api/projects/import/commit |
ImportCommitBody |
on_conflict defaults to "merge"; a ProjectImport is a 422, any other failure a 400. |
Source: route/versions.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| POST | /api/versions/check |
VersionsCheckBody |
VersionCheckResponse |
one poll pass over the stalest limit papers. Candidates are read, the single batched arXiv call awaits with no DB lock held, then results are applied. 502 on an arXiv failure (nothing recorded, so the same papers stay at the front of the staleness queue). |
| GET | /api/versions/new |
NewVersionsResponse |
papers with an un-acknowledged new version. | |
| POST | /api/versions/ack |
VersionsAckBody |
clear the flag for one paper. 404 when unset. |
Source: bin/headless.rs
| Method | Path | Body | Response | Notes |
|---|---|---|---|---|
| GET | /api/admin/relay/members |
|||
| GET | /api/admin/relay/log |
EntriesResponse |
newest-last tail of log entries. | |
| GET | /api/admin/transfers |
EntriesResponse |
newest-last tail of log entries. | |
| GET | /api/admin/node-address |
NodeAddressResponse |
the locator members dial (endpoint id + relay URL; not a capability). 409 until the node is bound to a configured custom relay — n0's default relay set has no single URL to encode. | |
| GET | /api/admin/db/backup |
the snapshot as an attachment, so the browser saves it instead of rendering it. Failures answer as the usual JSON detail. | ||
| POST | /api/admin/db/import |
DbImportBody |
DbImportResponse |
spill the upload to a temp dir, validate it, then merge (insert-only) or replace (destructive) per mode. validate_backup_source runs before any with_conn, so a junk upload is a 400 with the live library untouched. |
| GET | /api/admin/actors |
ActorsList |
||
| POST | /api/admin/relay/members |
MemberUpsertBody |
role/name/actors stay raw JSON so each bespoke 400 fires separately from the endpoint-id check; absent fields preserve the member's existing values. |
|
| DELETE | /api/admin/relay/members/{id} |
|||
| GET | /admin |
|||
| GET | /api/status |
StatusResponse |
one-call health/config aggregate for a headless node. | |
| POST | /api/relay-access |
iroh-relay's access check. text/plain true/false, never the JSON envelope: the relay string-matches the body. |