Open Connected app details in the header to inspect the runtime ID, last observation, process/start/version and saved schema version. The header checks every 15 seconds; pending or failed checks label the previous observation stale. A changed runtime requires Use this running app and a fresh successful observation. This acceptance lives in this mounted header only; reload starts a new observation. Other desktop requests are not pinned.
The runtime ID identifies one serving process and changes on restart. It is not a corpus, installation or build fingerprint; corpus and build identity remain unknown. Advanced Settings separately polls every 5 seconds and retains its attached-app versus daemon status.
MCP health and get_execution alone accept optional expected_runtime_id. Read health's
identity.runtime_id, compare it with the app you intend to inspect, then supply it on
those reads. A mismatch refuses the read; other 16 reads and 7 confirmed writes keep their
existing behavior. Runtime matching does not authenticate a hostile local service.
resmon is an automated, customizable literature surveillance platform that monitors open-access scholarly repositories, aggregates newly published papers against user-defined criteria, and compiles chronological reading reports. By default it functions as a streamlined metadata and abstract extractor; an optional AI-powered pipeline can generate concise, customized summaries of abstracts, methodologies, results, and discussions. The desktop application surfaces this capability through a dashboard of active routines and recent activity, manual Deep Dive and Deep Sweep runs, scheduled Automated Deep Sweeps, a calendar of upcoming and past executions, and a local-first architecture that keeps every report and credential on the user's own machine.
-
Multi-repository ingestion across 27 scholarly sources — unified metadata normalization over arXiv, bioRxiv, CORE, CrossRef, DataCite, DBLP, DOAJ, Dryad, ERIC, EuropePMC, GovInfo, HAL, INSPIRE-HEP, medRxiv, NASA ADS, NDL Search, NIST Resource Metadata Management, OAPEN Library, OpenAIRE, OpenAlex, Open Library, OSTI.GOV, PLOS, PubMed, Semantic Scholar, Springer Nature, and Zenodo. Each client enforces per-source rate limiting, exponential backoff, and graceful degradation when a single source fails mid-sweep.
-
Three operational modes:
- Targeted Deep Dive — a focused, manual query against a single repository within a defined date range, with support for an ephemeral per-execution API key that never persists to disk.
- Broad Deep Sweep — a cross-repository manual query that applies Deep Dive parameters across every selected repository in parallel.
- Automated Deep Sweep (Routine) — a background-scheduled Deep Sweep that runs on a cron expression, emits progress events, and optionally triggers email notifications and Google Drive uploads on completion.
-
AI-powered summarization — optional dual-path LLM integration covering remote commercial APIs (BYOK) and local/open-weight model inference, with token-aware chunking and customizable summarization prompts applied to abstracts, methodologies, results, and discussions. API keys are stored per provider in the OS keyring (one slot for each of
anthropic,openai,google,xai,meta,deepseek,alibaba,local, andcustom), and every per-execution AI override panel on Deep Dive, Deep Sweep, and Routines exposes the full Settings → AI control set (Provider, Model, Length, Tone, Temperature, Extraction Goals) with per-field merge semantics so a single override never clobbers persisted defaults. -
Corpus analytics — a dedicated Analytics page computed entirely from papers already stored locally, so it makes no repository requests and costs no API quota. It reports which repositories contribute papers nothing else found (and which only duplicate others), the median discovery lag between a paper's publication date and the moment resmon first saw it — a figure no repository publishes about itself — per-routine health with an explicit signal when a routine stops finding anything new, and publication volume over time by source or subject category. Counts are always shown; medians and percentages are withheld until the sample is large enough to mean anything, with the sample size displayed either way.
-
The watchdog — silent-failure detection — a literature monitor fails silently: a dead source and a quiet field produce the same empty inbox. resmon compares every source and routine against baselines drawn from its own execution history and reports where reality has departed from them, in two deliberately separate grades. Broken is a recorded fact — a source that failed to answer on three consecutive runs, a required API key that is not configured, an active routine overdue against its own observed cadence (which is what catches the background service being down). Failing to answer covers three things and not a fourth: a query that raised, an endpoint that could not be reached, and a reply resmon could not read. The middle two raise nothing and are recorded as completed runs returning zero, because every source client degrades rather than failing a sweep — before v1.8.6 they were indistinguishable here from a quiet field. A window the source cannot answer at all is deliberately excluded: ERIC and Open Library refusing a sub-year window is correct behaviour, not a fault. Looks unusual is an inference from the user's own baseline — a source that reliably returned papers returning none for four runs running — and is always worded as a prompt to check, with the innocent explanation stated alongside it. Thresholds are conservative and published in the interface; findings can be muted individually, and a mute is dropped automatically once its condition clears. What cannot yet be judged is listed too, so a watchdog silent for want of data is never mistaken for a clean bill of health. Cadence advice derived from discovery lag rounds it out, carrying the honest caveat that the lag was measured through the user's own polling interval.
-
Why nothing came back — a source that returns zero results says why, at the moment you are looking at the zero, and says not recorded where resmon does not know. Every client degrades rather than raising when an upstream fails, so until 1.8.6 a source whose endpoint answered 503, a source whose reply would not parse, and a source in a genuinely quiet field were the same row —
ok, zero results — on the monitor, in the report, and in the search record. resmon now records the reason at the HTTP boundary itself, so no source client has to be edited for an outage to be captured, and clients speak up only for what a status code cannot say: a window the source cannot answer at all, a reply that would not read, records dropped because their rights statement is not one resmon may store. The rule has no third state — a zero reason is a recorded fact with a named source of truth, or it is not recorded — and there is no backfill, because a reason reconstructed for a run nobody observed is a guess wearing a fact's clothes. The reproducible search record stops counting an outage as a database that answered, the watchdog stops calling an outage the last successful run, and a baseline of what a source normally returns is no longer built from runs where it never answered. -
Corpus-wide Explorer — search and filter every paper resmon has collected, across all executions and routines, by free text over titles and abstracts, author, source, subject category, and publication date. Filters live in the URL, so a filtered view can be bookmarked, shared, or reached from the Analytics page by clicking a source or category. Built for scale: free text runs against an FTS5 index rather than a substring scan, authors and categories are filtered through normalized indexed tables rather than parsed at query time, and pagination seeks by sort key rather than counting rows — a page at row 90,000 of 100,000 costs 0.30 ms.
-
Semantic search over your own corpus — with an embedding model configured, the Explorer gains a Sort control that orders results by how close each paper is to what you typed rather than by date, and every result gains Papers like this one, its nearest neighbours in your corpus with the distance and the source of each. Closest ranks the corpus, not a keyword slice of it. Your other filters — source, category, author, date — still choose what is in scope and are untouched by the sort; the phrase you type is what distance is measured from rather than a word filter, so Closest finds papers that contain none of your words and the two sorts can return different numbers of papers. That is deliberate and it was bought with a measurement: on a real 15,707-paper corpus, ranking inside the text filter returned an empty page for eleven of twenty plain-English queries, while the same vectors unfiltered found a relevant paper for almost all of them. Papers resmon has not embedded yet are listed last and marked not ranked rather than given a large distance — they have not been judged distant, they have not been judged — and the count is shown. Vectors live in a plain
document_embeddingstable any SQLite can read; thesqlite-vecvirtual table over it is an index, rebuildable and never the only copy. The whole feature is absent, with a reason, when it cannot work — no embedding model, or a build that cannot load the extension — rather than present and inert. A local model is free and recommended (ollama pull nomic-embed-text): nothing leaves the machine and there is no key. An Anthropic key cannot do this — Anthropic does not offer an embeddings API — and neither agent CLI can either, so a subscription that covers your summaries does not cover semantic search; both are listed in the provider menu, disabled, with the reason, rather than quietly omitted. Embedding runs automatically after each sweep for the papers it found; everything already collected needs the one-off backfill, which can be stopped and restarted freely because it resumes from what is missing rather than from where it stopped. -
The same paper, found twice — resmon links records that look like the same work reaching it from two places, and never merges them. Insert-time deduplication catches an identical record arriving twice; it cannot catch an arXiv preprint and its Crossref publication, which differ in date, author formatting and punctuation. A pair is linked when they share a DOI, or when their titles are near-identical and their text is close and both actually have an abstract — that last condition is not fussiness: a paper with no abstract is embedded from its title alone, so "similar titles and close vectors" would be one signal counted twice, and on a real corpus that produced 27 false links out of 30 (journal front matter, where every
Editorial Boardrecord matches every other). With it, hand-graded precision on two fresh samples is 83% and 87%. Both records are always kept, both counts stay, and collapsing is a per-view toggle that starts off — the corpus is worth having because it keeps what each source actually said. The link names its evidence, because "same DOI" is an identifier and "near-identical title" is an inference. -
Is this routine finding what you meant? — a coverage audit per routine, comparing its results against a plain-English intent you write for it (or, failing that, its keyword string — and it says which, because comparing keywords against results those keywords produced is measuring a query against itself). Two lists: the results sitting furthest from the intent, and papers already in your corpus that this routine never returned. The cutoff is drawn from the routine's own distribution rather than an absolute distance, because a fixed number means different things on different models and subjects, and below a dozen embedded results it declines to draw one at all. The governing limitation is stated everywhere the audit appears rather than in a footnote: resmon can only compare against papers it already holds — "missed" means missed by this routine and found by something else, never missed by resmon. You write the intent in the routine editor (What this routine is really looking for), and each list is a page of 25 that says "Showing 25 of N" when there are more.
-
Watch a person, not just a set of words — a watch profile is somebody you point a routine at: their name, the identifiers you have for them, where they work. A routine can then follow them for new papers or for retractions on the papers already matched to them, instead of you guessing a keyword query that catches them and nothing else. Profiles export and import as JSON on one documented shape, and a curated starter set ships in the repository — every ORCID in it checked against the public ORCID record on the day it was added, with the citation stored beside it, and one candidate dropped because his record has no public name to check against. The rule the whole feature is arranged around: an author match is a string match unless the source gave an identifier, and resmon says so on every paper. Every match records its basis —
ORCID match,name + affiliation, orname only— and the badge is on the paper, in the Explorer, on the profile page, in the run's report and on the MCP surface; aname_onlymatch is never presented as the person, and the string the source actually returned is shown beside it so you can judge the match yourself. A profile with no ORCID is told, while you are typing it, that every match it can ever produce will be name-only. Twenty of resmon's twenty-seven sources can be asked about an author at all, each by a syntax established against a live record and cited in the catalog; the seven that cannot say so on the run's own source row rather than returning a bare zero. And a source's author search is treated as a candidate generator, never a verdict — every record that comes back is re-checked against the profile locally, and the ones that do not survive are counted in the run rather than silently dropped. Theretractionsmode adds no provider and invents no finding: it is the lifecycle check you already have, restricted to that person's papers, reporting notices resmon already holds — and the basis rides on every finding, because a retraction attached to a name is not a retraction attached to a person. All of that is measured, and the numbers are in the app. Four profiles across all twenty askable sources produced 1,369 matches on 7 September 2026: 90% were name-only, 9% carried an ORCID, and 0.7% — nine matches in all — reached name + affiliation, so the basis rule is in practice binary. Hand-graded: 30 of 30 ORCID matches were the right person; of the thirty name-only matches, seventeen named somebody whose identity could be checked and fifteen were right, one was wrong — a library catalogue that lists a molecular biologist as co-author of a book on Coleridge, which resmon matched faithfully and labelled name only, which is exactly the right amount of confidence to have had. The nine name + affiliation matches span at least five different researchers with one common name at one large institution, all labelled identically: the affiliation raises the evidence, it does not identify a person. Matching now preserves initials and surname particles and compares affiliations as complete token runs (MIT does not match SUMMIT). Exact mononyms or shared single-token names remain visibly ambiguous candidates. Conflicting ORCIDs are displayed as counterevidence and prevent affiliation promotion; equal ORCIDs retain the identifier basis. Older saved matches and papers are kept unchanged and labeled Historical match — not rechecked under the current matching policy in Profiles and Explorer. The field-test counts above describe the earlier policy, not measured accuracy of these corrections. -
A reading queue, for the papers you actually meant to read — a run's Papers tab lists what that execution found, one paper at a time with its own Save button, instead of leaving you to read ids out of a Markdown report. Saved papers go to a Reading queue page with two states, To read and Read, and the same Why am I seeing this? evidence every other view shows, because it is the same stored record rather than a copy of one. Membership is keyed on the paper's stored id, so a later run that rediscovers the same record keeps the state you gave it — saving it again never puts something you have read back on the pile — and two records that merely look like the same work stay two entries, on the same rule that makes resmon link near-duplicates rather than merge them. Removing an entry removes membership and nothing else: the paper, its authors and every run that found it are untouched, and the only thing in resmon that deletes a paper is still the Danger Zone you operate yourself. Tick papers and export them as BibTeX, RIS or CSV through the same exporter Results & Logs uses — over exactly the papers ticked on the page in view, so a paper that has left that page cannot end up in the file. It holds no notes, no PDFs, no reminders and ranks nothing — each of those is a decision rather than a gap, and an existing install starts the queue empty because resmon never observed which papers you meant to read before it existed.
-
Match transparency — "why am I seeing this?" — every result in the Explorer can say which of the keywords from the runs that found it actually appear in it, and in which field: title, abstract, subject categories, or author list. Matching is on whole words, so
AIdoes not matchsaid, and a quoted phrase must appear as a phrase. The same arithmetic run corpus-wide gives per-keyword marginal contribution on the Analytics page: how many papers each keyword found that no other keyword of yours did, so a term that only duplicates another becomes visible and can be retired. The feature is defined as much by what it refuses to claim: resmon stores no full text, and most sources are relevance-ranked rather than literal keyword filters, so a paper containing none of your keywords is expected rather than a fault. Every explanation carries those limits alongside the evidence rather than behind a tooltip, and names the source's own documented keyword semantics. bioRxiv and medRxiv are the two exceptions at the API boundary: resmon retrieves date-range records and applies its own keyword filter instead of relying on an opaque upstream ranking. -
Reference-manager exports — selected executions' papers export to BibTeX, RIS, or CSV as one file, alongside the Markdown, PDF, and LaTeX report bundle. Repeated stored document IDs appear once; distinct records remain separate. Papers are ordered by publication date, then internal ID, descending, independently of run selection order. BibTeX citation keys are allocated across the complete file; collision suffixes depend on its contents/order and are not permanent paper IDs. DOI-bearing entries are journal articles, others generic records, and BibTeX special characters are escaped. JSON remains API/MCP-only with CSV's public fields and missing values as
null. Execution JSON can explicitly requestinclude_ids=truefor internal corpus IDs; MCP execution results use that option so a returned paper ID can be passed toexplain_match. Default JSON and CSV columns are unchanged. -
A Danger Zone that tells the truth — every destructive action states exactly what it does and does not remove, including whether your collected papers are affected. A dedicated Erase the paper corpus action removes every paper resmon has collected along with its lifecycle records; Erase all app data and Factory reset now include the corpus too. Before 1.7.0 nothing in the Danger Zone deleted a single paper — a "factory reset" left the entire corpus in place, which on a real install meant tens of thousands of papers surviving a reset that claimed to erase everything.
-
Corpus lifecycle — papers change after you find them — a monitor's corpus is frozen at discovery time and silently goes stale. resmon unfreezes it: retractions and expressions of concern through Crossref, which has distributed the Retraction Watch database openly since 2023; preprints that have since reached a journal, via the bioRxiv API's link to the published DOI; and newer versions of what you hold, on both bioRxiv and arXiv. Discovering after submission that a paper you built on was retracted is a career-grade problem, and no other literature monitor checks for any of it. The governing rule is enforced in code, not left to callers: resmon never asserts a lifecycle event on its own authority — every finding carries a resolvable link to the notice behind it, and one that cannot produce a link is refused at the database layer rather than recorded. The publisher's own wording is stored and displayed verbatim, an expression of concern is graded below a retraction and said to be weaker, and a correction is treated as the routine scholarly upkeep it is rather than colored like an alarm. The check makes outbound requests, so it runs only when asked, bounded and resumable, and coverage is always reported alongside the findings — "no retractions found" means nothing if only a fifth of the corpus has been looked at, and papers with no usable identifier are counted separately rather than silently treated as clean.
-
Cross-platform desktop notifications — routine and manual completions raise a native OS notification on macOS, Linux, and Windows. The notification dispatcher is invoked both from the foreground app and from the headless
resmon-daemon, so completions fire even when the Electron UI is closed. -
Calendar scheduling — a calendar view of scheduled routines and historical executions, driven by the scheduler service and the
/api/calendar/eventsendpoint. -
Email notifications — per-routine and global SMTP-based notifications on routine completion, including attachment of the execution bundle produced by the shared export pipeline.
-
Google Drive backup — optional, least-privilege (
drive.file) backup of the report tree to a user-supplied Drive OAuth client. -
Coverage before conclusions — opening a Results run shows answered, recorded non-answer and unknown sources above its six existing tabs. Counts use saved selected sources when that selection is established; otherwise they describe recorded sources and the full selection stays unknown. A selected source without an outcome is unknown. Genuine empty answers are included in answered; failed requests, unreadable replies and missing history never become evidence that no papers exist. View source details opens the Search record. Explicit report ZIP exports include generated
search-record.jsonandsearch-record.mdcompanions per execution; these describe saved facts at export time and leave the original report/log and existing PDF behavior unchanged. Routine mail bundles retain their existing behavior. -
The reproducible search record — every systematic review's methods section requires the same account of a search: exact terms, sources queried, per-database retrieval counts, deduplication figures, date executed, and software version. It is assembled by hand in spreadsheets essentially everywhere. resmon exports the available saved facts for each execution as JSON or as a Markdown document ready for a methods section, in the shape a PRISMA 2020 flow diagram needs. The design work is in the labeling, not the layout: resmon's counters do not map cleanly onto PRISMA's boxes, and a record that printed them under PRISMA headings would publish a claim resmon cannot support. So each figure names the box it belongs in — or says it has none. resmon flags cross-source duplicates and keeps both rows, so the record reports duplicates found, never duplicates removed. "Already held from an earlier run" is an artefact of monitoring over time with no PRISMA equivalent, and is labeled as such. A figure that was never measured reads not recorded, never
0, because a reviewer reads0as a measurement. Sources that were selected but contributed nothing — a missing key, a failing endpoint — stay in the record, because a strategy listing them as searched would overstate its coverage. This ships the search log and deliberately does not chase Covidence: screening is a different product, and the search-and-document half is the part nobody does. -
Configuration export and import — serialize any Deep Dive, Deep Sweep, or routine configuration to JSON and re-import it on the same or another device for reproducible surveillance setups.
-
Local-first storage and logging — SQLite-backed state, per-execution log files, and a configurable export directory for report and artifact bundles; no credentials or data leave the machine unless the user opts in to Google Drive backup.
-
In-app About resmon page — a dedicated top-level page hosting four self-contained tabs: Tutorials (25 written walk-throughs covering the full app, every page, and every Settings sub-tab, with a shared Tutorial deep-link button rendered next to every page header and every Settings sub-panel header; 17 of them also have a video, linked rather than embedded — the tab renders no third-party frame and sends no request to YouTube, and every link opens in your own browser through the Electron shell), Issues (a credentials-free form that builds either a
mailto:link to the maintainer or a pre-populated GitHub issue deep link — the app never posts the report itself), Blog (an in-app reader for the public resmon blog athttps://ryanjosephkamp.github.io/resmon/, fed by the GitHub Pages source underdocs/_posts/and rendered through an origin-locked Electron<webview>), and About App (build version, recent-update notes, license, privacy notice, author links — relocated out of Settings).
The table below lists the 27 active sources registered in the repository catalog (/api/repositories/catalog). "API key" indicates whether a key is required to query the source from resmon; rate limits are the client-side ceilings enforced by each API client.
| Repository | API Type | API Key | Rate Limit (resmon) | Discipline Coverage |
|---|---|---|---|---|
| arXiv | REST (Atom XML) | Not required | 0.33 req/s (1 per 3 s) | Physics, Math, CS, Quant-bio, Stats, EE, Econ |
| bioRxiv | REST (JSON) | Not required | 2.0 req/s | Life-sciences preprints |
| CORE | REST (JSON) | Required (Bearer) | 5.0 req/s | Multi-disciplinary open access |
| CrossRef | REST (JSON) | Not required | 10.0 req/s (polite pool) | All disciplines (DOI-indexed) |
| DataCite | REST (JSON:API) | Not required | 1.5 req/s | All disciplines, especially data, software, dissertations, and other DOI outputs |
| DBLP | REST (JSON) | Not required | 2.0 req/s | Computer science |
| DOAJ | REST (JSON) | Not required | 5.0 req/s | All disciplines (OA journals) |
| Dryad | REST (JSON) | Not required | 1.0 req/s (conservative; no published API limit) | Multi-disciplinary research data |
| ERIC | REST (JSON served as text/plain) | Not required | 0.5 req/s | Education research, policy, and practice |
| EuropePMC | REST (JSON) | Not required | 5.0 req/s | Biomedicine, Life sciences |
| GovInfo | REST (JSON Search Service) | Required (API.data.gov) | 1.0 req/s | Official government publication metadata; no full text or teasers |
| HAL | REST (Solr JSON) | Not required | 2.0 req/s | All disciplines (French-leaning) |
| INSPIRE-HEP | REST (JSON) | Not required | 2.0 req/s | High-energy physics, Accelerators, Astroparticle physics |
| medRxiv | REST (JSON) | Not required | 2.0 req/s | Medicine, Clinical research, Health sciences preprints |
| NASA ADS | REST (Solr JSON) | Required (Bearer) | 1.0 req/s (≈5000/day cap) | Astronomy, Astrophysics, Planetary science |
| NDL Search | SRU (DC-NDL v3 XML) | Not required | 0.5 req/s (conservative; no published numeric API limit) | Japanese national bibliography, Books, Articles, Cultural heritage metadata |
| NIST Resource Metadata Management | REST (JSON) | Not required | 0.5 req/s (conservative; no published API limit) | Engineering, Government technical publications |
| OAPEN Library | REST (explicit JSON) | Not required | 0.5 req/s (conservative) | Books and chapters; whole publication years only; one 45 s search budget |
| OpenAIRE | REST (XML-derived JSON) | Not required | 0.0167 req/s (60/hour) | Multi-disciplinary publications, Research outputs |
| OpenAlex | REST (JSON) | Not required | 10.0 req/s (polite pool via mailto) | All disciplines |
| Open Library | REST (JSON) | Not required | 1.0 req/s (unidentified-client ceiling) | Books, Humanities, General bibliography |
| OSTI.GOV | REST (JSON; XML and BibTeX optional) | Not required | 0.5 req/s | Energy, physical sciences, DOE-funded research, technical reports |
| PLOS | REST (Solr JSON) | Not required | 5.0 req/s | Biology, Medicine, Natural sciences (PLOS journals) |
| PubMed / NCBI E-utilities | REST (XML) | Optional (raises limit) | 3.0 req/s keyless, 10.0 req/s with key | Biomedicine |
| Semantic Scholar | REST (JSON) | Optional (recommended) | 0.33 req/s (1 per 3 s) | All disciplines (strong CS, biomed) |
| Springer Nature | REST (JSON Meta API) | Required (query-param key) | 5.0 req/s (≈5000/day cap) | STM, Humanities, Social sciences |
| Zenodo | REST (JSON) | Not required | 0.5 req/s (30/min) | Multi-disciplinary publications, data, software, and other outputs |
HAL searches share a 45-second cooperative I/O budget across pages, retries and rate-limit waits. Budget exhaustion is recorded as an upstream failure; an empty successful response remains distinct. Already normalized records are retained. DNS shutdown and blocking CPU work can exceed this budget; see HAL request reliability.
OAPEN searches likewise negotiate JSON explicitly and share one 45-second cooperative I/O budget across pagination, one retry per request, rate-limit waits, and response reads. If a later page fails, already normalized records remain available while the source outcome records the failure; a first-page failure remains empty. As with HAL, DNS shutdown, blocking CPU work, or an operating-system call that does not yield can exceed this cooperative budget.
Sources previously evaluated but excluded from the active catalog (SSRN, RePEc/IDEAS) are documented in .ai:/prep/repos.md and are not queried at runtime.
Open Library, choose an existing parent folder, then select Create managed vault. resmon creates one new child folder and pairs its marker with this app's database. Import selected PDF, TXT or MD files to retain immutable copies. Originals stay where they were; repeating the exact bytes reuses the first file and version only when the format agrees. Changed bytes become another item, even under the same filename. Library never infers a publication identity from a filename or hash.
Search filenames and page beyond the first 50 items. Paging keeps its initial catalog ceiling; Refresh Library includes later imports. Select an item to inspect its metadata and optionally associate an existing corpus-local paper ID. An association means you selected that record, not that resmon verified the publication.
Read text shows exact retained TXT/MD as literal UTF-8 with line numbers, local find and previous/next matches. CRLF and CR display as LF without changing retained bytes. Reading is limited to 256 KiB, 5,000 logical lines and a 2 MiB serialized response. Markdown, HTML, links and embedded images are never executed. For PDFs, Read PDF in Evidence carries the selected file/version to Evidence; choose a project and explicitly add it to open the reader (an existing membership is reused). Larger text remains retained. Open externally verifies the selected stored version and requests the existing OS opener. An accepted Open request does not establish that another application rendered the document. The Library reader adds no PDF extraction, OCR or AI analysis; the separate Evidence workspace provides bounded PDF pages and saved passages.
Imports are limited to 64 MiB per file, 20 files processed sequentially per selection, 1 GiB of retained bytes and 10,000 items per vault. Missing or mismatched storage, unexpected files and stale import locks produce a refusal; there is no automatic adoption, repair, relocation, removal or replacement. Filesystem operations require no-follow, descriptor-relative primitives and fail visibly where unavailable.
Export complete JSON inventory downloads recorded metadata for every catalog item, including items beyond the visible page. Its 8 MiB limit refuses oversized inventories without truncation. Review original filenames before sharing. The inventory contains no retained file bytes and is not a backup or fresh integrity scan. A database backup alone does not preserve the vault's files, and no automatic Library cloud backup is added.
Existing settings resets and corpus erasure retain Library catalog entries and files. Deleting a paper removes only its Library associations, so a reused local ID cannot inherit them. See the Library storage and API contract for identity, migration and verification details.
Completed exports appear in Downloads at the bottom of the app, with filename, actual saved path and Show in folder. Saving, cancelled and interrupted items remain distinct from completed files. This is the last 20 downloads in the current app session; the save dialog and existing export formats are unchanged.
From a Library item, choose Open in Evidence / add to project, create or select a project, and explicitly add that exact retained version. Choose from Library adds other files; the import link returns through the same explicit file selection. Names never infer paper or version identity.
Evidence displays a bounded PDF page alongside canonical text, or literal TXT/MD in one logical page. Find searches only the current canonical page. Select a passage there and save it with an optional note; plain notes need no passage. Reopening checks the same version, page, extraction contract, text hash and Unicode codepoint range. A missing or changed source leaves the saved quote/body visible and unresolved. Body edits never move the saved anchor. Conflicts preserve unsaved text for an explicit refresh/retry.
PDF reading is limited to 16 MiB, 200 physical pages and 20 seconds per requested page; text extraction allows 200,000 codepoints. Encrypted, malformed, image-only, unsupported and over-limit pages are explicit. No OCR or full-text background processing. Equations, tables, columns and images may not survive text extraction; a rendered page is not a scientific accuracy or completeness claim. TXT/MD retain the Library 256 KiB/5,000-line bounds. The visual canvas is bounded to four million pixels/16 MiB, with reduced resolution when necessary. Parser limits and cleanup do not prove a portable hard memory or OS-confinement guarantee.
Remove a collection member without deleting its originals, managed bytes, paper provenance or notes. Re-add that exact identity to reconnect its saved records. Projects and notes survive existing resets and corpus erasure. Limits are 100 projects, 1,000 current members and 5,000 saved records per project; note bodies and selected passages are each at most 20,000 codepoints.
Export selected evidence creates a ZIP for 1–20 explicitly selected current members and all their saved notes in that project. Metadata-only is the default; optional retained-file mode rehashes selected bytes before offering a complete archive. Limits are 256 MiB of retained files and 4 MiB each for manifest and notes. Review names, notes and included originals before sharing. This is not an encrypted backup/restore format, an AI briefing or a claim of redistribution rights. Existing Library platform limitations remain; no new Windows support or assistant/MCP tool is introduced. See the Evidence contract.
Evidence also offers selected-evidence questions and manual structured briefings. Explicitly choose up to 5 files, 12 pages or 24 passages and, optionally, up to 8 individual saved note bodies. Inspect the exact content, coverage and requested connection/model/effort in the preview. Each Send creates one saved answer using the existing CLI or API connection with tools off and no earlier conversation. Source text is limited to 48 KiB and 24,000 Unicode codepoints; nothing is silently truncated. Citation checks establish exact selected-text identity; they do not establish semantic support, research quality or truth. Stop requests cancellation; remote completion, retention and billing can remain unknown. Saved answers and selected-text ZIPs remain available without the original files or a model call. Export HTML separately saves one readable offline document with frozen citations and return links, source excerpts, selected local notes, coverage and requested/reported settings. Open the downloaded file in a browser after closing resmon. It contains no scripts, remote assets, PDFs or current-original links; URLs in source text stay literal. Partial and unknown states stay explicit. The existing ZIP action and its three members are unchanged. See the portable briefing guide for navigation and limits. See the selected-answer contract.
Every page has a ✦ Ask button in the corner. It opens a panel that can do what you could do in the interface — set up and adjust routines, run a sweep, find papers in the corpus you already have, read what a run found and why, export references, change settings — by being asked in words.
It runs the claude command you already installed and signed into, so it draws on
your existing plan rather than a metered key, and resmon never sees your credential. If
the command is not there, or is not signed in, the panel says so and says where to fix
it; it does not quietly disappear.
The panel has an opaque reading surface and a visible message field. When available, opening it puts keyboard focus in the composer; closing it returns focus to Ask. Enter sends, Shift+Enter adds a line, and Stop remains below the scrolling conversation. Earlier conversations reopen from the header; Allow and Deny stay explicit on each permission card.
Choose Connection, Model and, for Claude Code, Effort before sending. Choices are fixed for that conversation; Change choices · new empty conversation starts with no copied messages or native session. Global Settings remain defaults for future chats, and changing them cannot retarget an existing conversation or active turn. Claude suggestions are aliases, not an account-verified compatibility list. Blank CLI model/effort omits that flag and leaves the native default unknown. API effort is not supported by the current adapter. Codex and Ollama assistant adapters are unavailable. Readiness describes local prerequisites; authentication and model acceptance are checked only when you send. A refusal does not switch to another model or connection.
Older chats have unknown historical choices. Their first continuation asks for explicit confirmation: an API conversation sends saved user/assistant text to the chosen future provider, whose predecessor is unknown; Claude starts a fresh native session without sending the earlier local messages. Those messages remain readable and exportable. Changing runtime kind starts an empty conversation. Requested choices and literal model reports are shown separately in Ask, Chats and exports; reports do not prove execution, effective effort, completion or billing. See the choices contract.
Chats in the sidebar finds saved conversations beyond the Ask history drawer. Browse newest-created pages or filter saved titles; Refresh includes new chats. Select a transcript, then Continue in Ask to open that same local conversation without sending anything. While Ask answers, other chats remain readable/exportable; finish or stop the active answer before continuing another chat.
Export Markdown and Export JSON explicitly download the selected persisted transcript. These are point-in-time copies, possibly incomplete: live fragments and pending approval cards are excluded, historical completion is unknown, and recorded metadata may be absent. Recorded cost does not establish billed cost. Includes saved messages and tool data; review before sharing. Each format refuses output above 8 MiB without truncation. Reading/exporting saved chats does not require an available CLI. See the conversation read/export contract.
Four things about it are structural rather than promised:
- Everything it tells you came from a tool call it made. It has no other source. When a tool returns nothing, "nothing was returned" is the answer, with the recorded reason or a statement that none was recorded — the same distinction the rest of resmon draws.
- Anything that changes something waits for you. A write is shown as a card with the exact call on it and does not run until you press Allow. That is enforced outside the model: the assistant is given the read tools and no others, and every other call goes through a permission tool resmon serves and the model cannot invoke.
- It cannot reach a credential. No tool returns an API key, and no tool can name one. Delete, erase, factory reset, credential writes, service install and cloud linking are absent from its tools entirely; asked for one, it tells you to do it in the app.
- A turn has a spending ceiling the CLI enforces on itself, set from measuring ten canonical requests. A runaway answer is stopped and says so rather than quietly costing a usage window.
Paper text — titles, abstracts, anything fetched from the internet — is data it reports on, never an instruction it follows. Its own rules arrive above the conversation on the CLI's system channel rather than beside the text, and a test asserts that at the boundary the command is actually built.
Conversations are kept, so closing resmon and coming back opens them where you left off. If the CLI itself has lost a conversation — you cleared its history, or restored resmon's database onto another machine — the next thing you send is answered in a fresh session and the panel says so in place; your earlier messages stay on screen, and the assistant can no longer see them.
Codex is not offered for the assistant: resmon can give a Codex session its own tools but cannot take away Codex's shell, and there is no way for you to approve a command before it runs. Codex remains available for summarising papers, where it is given no tools at all.
No CLI? Use a key instead. Under Settings → AI → Assistant you can run the assistant on an API key of your own — Anthropic, OpenAI, Google, xAI, Together, DeepSeek, Alibaba, or a custom OpenAI-compatible endpoint. It is the same panel, the same tools, the same confirmation card, and the same rules on the same system channel; resmon runs the tool loop itself rather than handing it to a CLI. Three things differ, and resmon says all three:
- It reports tokens, not money. A provider API reports token counts and resmon does not maintain anyone's price list, so a turn shows what it consumed and not what it cost. A computed figure would be a number nobody measured.
- A turn stops after eight tool steps or 100,000 tokens. The CLI enforces a dollar ceiling on itself; here the ceiling is on the two things resmon can actually count. Both numbers are twice the largest of the ten requests measured for v2.0.0.
- It remembers what was said, not what the tools returned. A provider API keeps no session, so resmon replays the conversation — your messages and its replies. The raw tool output behind an earlier answer is not sent again, so a long conversation does not re-send a corpus and text from a paper does not follow you from turn to turn.
Whether each provider supports tool calling at all is recorded with the evidence that established it, and a provider resmon could not establish an answer for says so rather than guessing — the same three states the embedding lane uses.
resmon ships an MCP server, so a harness you already work in — Claude Code, Codex, or
anything else speaking the Model Context Protocol — can read and control resmon without you
leaving it. The full tool surface is specified in
docs/api-contract/mcp.md.
python3 resmon_scripts/mcp_server.pyIt speaks MCP over stdio and is a client of the running resmon backend, reaching it over
127.0.0.1. It never opens the database directly: the backend owns its connections and its
scheduler, and going through the API means the tool surface cannot drift from what the app
itself does. resmon must be running. If it is not, every tool returns one clear error
rather than an empty result — a harness told "you have no papers" because the app is closed
would repeat that to you as fact.
To register it with Claude Code:
claude mcp add resmon -- python3 /full/path/to/resmon_scripts/mcp_server.pyTwenty-five tools cover search (keyword and semantic), similar papers, sources, routines,
executions, match transparency, paper lifecycle, analytics, the watchdog and reference
export, plus seven that change something: run_sweep, create_routine,
create_watch_profile, run_routine, activate_routine, deactivate_routine and
update_settings.
create_routine takes an optional intent — the sentence the coverage audit compares
against — and get_routine returns that audit's summary alongside the configuration.
Every tool says whether it needs a person. tools/list carries
requires_confirmation on all twenty-five — true on the seven above, false on the
eighteen reads — so a harness knows which calls to put in front of you before running
them, rather than inferring that an unmarked tool is safe.
Nothing destructive is exposed — no delete, no erase, no factory reset, and no tool
that reads or writes an API key. A routine created through a tool is created
inactive; putting something on a schedule on your machine is not a side effect a
tool call gets to have, which is why turning one on is its own confirmed tool.
update_settings can change seven groups of preferences and cannot name a credential:
a key called anything like api_key, token, secret or password is refused before a
request is even built, because resmon's keys live in your system keychain and are not
settings.
Finding the right resmon matters more than it sounds: the server checks RESMON_PORT, then
the port file the backend writes beside its database, and only falls back to the default port
when neither names one. A port that was named and is not answering is reported as
unavailable rather than widened to the default — otherwise a harness can silently attach to a
different resmon installation and answer truthfully about the wrong corpus.
The backend asks every caller for its local API token (2.2), and the MCP server reads it
from the owner-only file the backend writes beside its daemon lock, api-token-<port> in
resmon's state directory. Nothing needs configuring for the installed app. For a development
instance with its own state directory, set RESMON_STATE_DIR for the MCP server too; a named
port whose token cannot be found is reported as unavailable, naming that instance. See
docs/local-api-security.md.
If you run the MCP server from a separate checkout, update it in the same sitting as the
app. A pre-2.2 MCP server sends no Authorization header, and no route is exempt — not
even /api/health — so every call it makes to a 2.2 backend is refused with
401 token_missing.
POST /api/routines/{id}/run runs a saved routine immediately, outside its schedule. It is a
thin wrapper over the same dispatcher the scheduler uses, so a manual run and a scheduled fire
cannot drift apart, and the run is recorded against the routine like any other.
An inactive routine does run, and the response says so. is_active controls whether the
scheduler fires a routine on its own; it is not a statement that the routine may never run.
resmon queries third-party scholarly APIs. Some are open; some need your own API key, which you register for yourself and which resmon stores in your operating system's keyring and sends only to that source.
That distinction matters more than it looks. When a source runs on your key, the provider's terms bind you, not resmon. resmon can decline to ship an integration it believes puts users in breach — and has: IEEE Xplore was withdrawn in v1.8.1 for exactly that reason — but it cannot accept a license on your behalf, and it does not monitor whether your use stays within one.
What resmon does do:
- Checks terms before adopting a source, against the provider's own words, and records the
reasoning in
docs/source-landscape.mdso you can read it. - Honours field-level conditions. Where a source permits storing a field only under conditions, resmon checks them — INSPIRE-HEP abstracts are kept only where the abstract's own source is arXiv or CERN, which drops about a fifth of them.
- Displays required attributions, unconditionally, on the Repositories page.
- Says when it does not know. Eleven shipped sources publish terms that neither clearly grant nor forbid what resmon does. They are active, and that assessment is recorded rather than dressed up as a clearance.
What it does not do, and you should assume for yourself:
- It does not give legal advice, and the assessments in this repository are not legal advice.
- It does not verify that your use of a keyed source complies with the agreement you accepted when you registered for that key.
- It does not re-check a provider's terms after adoption. Terms change; the survey records the date it was made.
- Under the MIT license resmon is provided as is, without warranty of any kind. That covers the software. It does not and cannot extend to the third-party services you point it at.
If you add a source yourself — directly, or by pointing your own AI harness at this repository —
that source's terms are yours to read. docs/adding-a-source.md
opens with the four questions to ask, because asking them last has cost this project real work
twice.
Every catalog entry carries a Date Filtering row on the Repositories page saying the finest date precision resmon's own query to that source can express. Most take exact dates. NASA ADS takes whole months. DataCite, DBLP, ERIC, Open Library and Semantic Scholar take whole years.
Two of those refuse rather than widen. ERIC and Open Library expose only a publication year, so a window shorter than one whole calendar year cannot be answered at all — a record returned for a two-week window would have unknowable in-window status, and resmon does not widen a window you did not ask it to widen. Those searches come back empty by construction, and since 1.8.6 the run says exactly that instead of leaving you to guess.
Six of the sources resmon queries make a credit a condition of reuse, not a courtesy: CORE, NDL Search, NIST RMM, OpenAIRE, PLOS and Semantic Scholar. resmon shows those credits at the top of the Repositories & API Keys page, unconditionally — a credit that only appears when you expand a card is not displayed in the sense the obligation means.
Sources that merely ask for a credit — arXiv, DataCite, DBLP, Europe PMC, HAL, OSTI — are listed on their own row instead, labeled as requested rather than required. The distinction is deliberate: OpenAIRE's Graph metadata is CC BY, so attribution is a license condition, while arXiv publishes an acknowledgement sentence it would like you to use. Rendering both the same way would overstate one and understate the other.
Each credit records the clause that imposes it, so any of them can be re-checked later.
IEEE Xplore was an active source through v1.8.0 and is no longer queried. The client worked; the reason is the IEEE Xplore API Terms of Use, which limit the grant to non-commercial activity within the licensee's own institution, forbid using a "site search/retrieval application" against the content (4(c)), forbid retaining it in bulk (4(f)), and require deleting it on termination (12).
resmon is a retrieval application that keeps a corpus indefinitely and can back it up to Google Drive, so no setting reconciles it with those clauses — and because each user registers for their own IEEE key, continuing to ship the integration would put the account holder in breach of terms they accepted personally. Withdrawing it was the honest option.
Existing routines that name ieee keep running; that source is reported as unavailable with the reason above, and the rest of the routine is unaffected. Any IEEE key you stored is no longer used. The client is retained in the source tree, so a written license from IEEE would restore the source unchanged.
INSPIRE-HEP was affected differently and was fixed rather than withdrawn: its terms permit reuse of an abstract only where INSPIRE reports that abstract's own source as arXiv or CERN, so resmon now stores an abstract only in that case. Records carrying only a publisher-supplied abstract are still indexed, without one — about a fifth of them in a 100-record sample.
The full terms review behind both changes, covering every shipped source, is in docs/source-landscape.md.
resmon is a hybrid Python + Electron application, so both runtimes must be available on the host machine before installation.
- Python 3.10, 3.11, or 3.12 — required by the FastAPI backend (
resmon_scripts/resmon.py) and its dependencies. Verify withpython3 --version. All three are covered by CI on every push. - Node.js 22.13 or newer — required to build the React renderer with Webpack and to run the Electron shell; declared as
engines.nodeinresmon_scripts/frontend/package.jsonand pinned fornvm/fnmin.nvmrc. Verify withnode --version. - npm 10 or newer — bundled with Node.js 22.13 and later; used to install frontend dependencies and invoke the build/start scripts. Verify with
npm --version. - Git — required to clone the repository.
- Platform — macOS, Linux, or Windows. The packaged desktop app is built with
electron-builderand the headless-daemon split supports launchd (macOS),systemd --user(Linux), and Task Scheduler (Windows).
Clone the repository, create an isolated Python virtual environment at the project root, and install the pinned backend dependencies:
git clone <repository-url> resmon
cd resmon
python3 -m venv .venv
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\activate.bat # Windows (cmd)
# .venv\Scripts\Activate.ps1 # Windows (PowerShell)
pip install --upgrade pip
pip install -r requirements.txtOptionally, install NLTK's sentence-tokenizer data:
python -m nltk.downloader punkt_tabThis is only used by AI summarization, to split abstracts into sentences before chunking. resmon attempts the download automatically on first import and falls back to a simpler regex splitter if it is unavailable, so the step is not required — but installing it explicitly gives more accurate chunk boundaries, and is worth doing if the machine will be offline or behind a firewall.
The backend reads its SQLite database from resmon.db at the project root on first launch and creates the resmon_reports/ subtree (markdowns/, pdfs/, figures/, latex/, logs/, info_docs/) automatically.
Install the Node dependencies and produce the production renderer and Electron-main bundles:
cd resmon_scripts/frontend
npm install
npm run buildnpm run build chains two steps: webpack --mode production compiles the React renderer into resmon_scripts/frontend/dist/renderer/, and tsc --project tsconfig.electron.json transpiles the Electron main process into resmon_scripts/frontend/dist/electron/. Re-run npm run build whenever frontend or Electron-main source files change. During active development, npm run dev:renderer runs Webpack in watch mode against the renderer sources only.
To build an installable macOS app:
cd resmon_scripts/frontend
npm run distnpm run dist builds for the platform it runs on:
- macOS —
release/resmon-<version>-arm64.dmg(Apple Silicon) orresmon-<version>-x64.dmg(Intel), ~190 MB. Open it and drag resmon to the Applications shortcut inside. Local builds produce your machine's architecture; CI builds both. - Windows —
release/resmon-<version>-setup-x64.exe, a standard installer. SmartScreen will warn on an unsigned installer — choose More info → Run anyway. - Linux —
release/resmon-<version>-x86_64.AppImage. Mark it executable (chmod +x) and run it.
CI builds all three through .github/workflows/release.yml on every version tag.
Auto-update: Windows and Linux installs check GitHub Releases shortly after launch and every six hours, download in the background, and apply on restart (a dialog offers restart-now). macOS cannot self-update while builds are unsigned — Gatekeeper's update machinery validates signatures — so macOS users install new versions manually until code signing lands.
The bundle is self-contained: scripts/prepare-backend.js stages the Python sources and
builds a virtual environment inside Contents/Resources/backend/, so an installed resmon
does not depend on which Python the machine has, or on the user ever having run pip. It
also pre-fetches NLTK's punkt_tab data, so AI summarization works offline on first run.
The staged environment is roughly 900 MB and takes a few minutes on the first build; set
RESMON_REUSE_VENV=1 to reuse an already-built environment on subsequent builds.
Releases are currently unsigned, and on modern macOS a downloaded unsigned app is blocked with “resmon.app” is damaged and can’t be opened. You should move it to the Trash. The app is not damaged — this is the standard Gatekeeper refusal for unsigned software downloaded from the internet, and for unsigned apps macOS often shows this dead-end dialog rather than any “Open Anyway” option.
The reliable fix is one command after dragging resmon to Applications:
xattr -dr com.apple.quarantine /Applications/resmon.appThen open resmon normally; macOS never asks again. (If your macOS version shows a “cannot verify the developer” dialog instead, System Settings → Privacy & Security → Open Anyway, or right-click → Open on macOS 14 and earlier, also works.)
The desktop experience is a three-process composition: the Electron main process spawns the Python FastAPI backend as a child process (or attaches to an already-running headless daemon), hosts the renderer window, and bridges OS-level capabilities through a contextBridge-exposed window.resmonAPI surface. All communication between the renderer and the backend is fetch-over-localhost JSON plus Server-Sent Events for live progress — there is no direct IPC from the renderer to Python.
To launch the app for local development or everyday use:
# From the project root, with the Python virtual environment active:
source .venv/bin/activate
# Launch Electron — it will spawn the Python backend and open the main window.
cd resmon_scripts/frontend
npm startnpm start runs npm run build followed by electron ., which loads dist/electron/main.js and opens the renderer at the / route. The backend binds to 127.0.0.1:8742 by default; the renderer discovers the port through window.resmonAPI.getBackendPort().
The renderer uses a HashRouter (the packaged app is loaded from file://, where push-state history is unreliable), so every page is reachable under a #/… fragment:
| Path | Page |
|---|---|
#/ |
Dashboard |
#/dive |
Deep Dive |
#/sweep |
Deep Sweep |
#/routines |
Routines |
#/calendar |
Calendar |
#/results |
Results & Logs |
#/configurations |
Configurations |
#/monitor |
Monitor |
#/repositories |
Repositories & API Keys |
#/settings/* |
Settings (nested router) |
#/about-resmon/* |
About resmon (Tutorials / Issues / Blog / About App) |
A Sidebar + Header + MainContent layout wraps every route, and a FloatingWidget monitor overlay sits outside MainContent so it survives route transitions and continues to pulse while any execution is running.
On a brand-new install the Dashboard opens with a Getting started card. It lists
three optional things — an agent CLI you already subscribe to, an AI provider key, and a
key for one of the few sources that wants one — with a link to where each is set, and a
mark showing what resmon can already see. None of them is required: resmon searches all
the unkeyed sources with no AI and no keys at all. The card reports found and configured
and never working, because resmon cannot tell whether a command is signed in or a key
accepted until the first paper goes through it. Skip puts it away permanently, and it
retires itself the moment you have run anything.
The following walkthroughs cover the three core workflows. Each flow assumes the app is running and that any required API keys for keyed repositories (CORE, NASA ADS, Springer Nature) are either stored under Repositories & API Keys or provided as ephemeral per-execution keys.
A Deep Dive runs a targeted one-off query against a single repository.
- Open the Deep Dive page at
#/dive. - In the repository selector (single-select), choose one source.
- Optionally restrict the date range with Date Range — note that the date range is intentionally not saved as part of a configuration and must be picked fresh for every run.
- Enter one or more keywords in the chip-style Keywords input; keywords are joined with spaces to form the query.
- Adjust the Max Results slider (range 10–500, step 10, default 100).
- Optionally toggle Enable AI Summarization. When enabled, the Override AI settings for this run disclosure offers per-execution overrides for length, tone, and model; empty overrides fall back to the app defaults.
- If the selected repository requires a credential that is not already stored in the OS keyring, enter an ephemeral key in the inline Key Status panel. Ephemeral keys are sent only in the request body and are never persisted.
- Click Run Deep Dive. The page sends
POST /api/search/dive, registers the returned execution id withExecutionContext, and the floating widget begins streaming progress. When the execution reaches a terminal state, an Execution #N result card is rendered inline with counts and a View Report link to#/results?exec=<id>.
A Deep Sweep runs a broad one-off query across multiple repositories in parallel, then normalizes and de-duplicates the combined result set by DOI and by title + first author.
- Open the Deep Sweep page at
#/sweep. - In the repository selector (multi-select), choose every source to include.
- Enter one or more keywords; the same query string is issued to every selected repository.
- Optionally restrict the date range (again, not persisted by Save Configuration).
- Adjust the Max Results slider — the cap applies per repository, so the merged result set can be up to
repositories.length × max_resultsbefore de-duplication. - Optionally toggle Enable AI Summarization and its per-execution overrides.
- For each selected repository that requires a credential, enter an ephemeral key in the inline Key Status panel if the key is not already stored in the keyring.
- Click Run Deep Sweep. The page sends
POST /api/search/sweepand registers the execution withExecutionContext. The backend first calls an admission controller; if the concurrent-execution cap is reached, the server returns HTTP429with aRetry-After: 5header and a message that points to Settings → Advanced — this surfaces verbatim as the form error.
A Routine is a scheduled Automated Deep Sweep: the same multi-repository pipeline, bound to a saved configuration and a 5-field cron expression.
- Open the Routines page at
#/routinesand click New Routine to open the create modal. - Enter a Name and choose a Cron expression (5-field
m h dom mon dow, default0 8 * * *). Any configuration-related fields can be pre-populated by loading a savedroutineconfiguration through the inline ConfigLoader — this restores everything except the date range. - Select repositories, enter keywords, and set an optional date range and a per-repository Max Results cap.
- Set the per-routine flags as needed:
- AI — enable AI summarization for each fire.
- Email — email the report on completion (the global SMTP settings must be configured under Settings → Email).
- Results-in-Email — include the AI summary inline in the notification email.
- Notify-on-Completion — raise a desktop notification when a fire finishes.
- Submit the modal. The routine is persisted via
POST /api/routines, and APScheduler registers a job under the routine's id. - Manage the routine from its row in the routines table: Activate/Deactivate preserves the DB row while adding or removing the APScheduler job; the inline Email, AI, and Notify column toggles patch a single flag each; and if a fire is currently running, a Cancel Run button appears on the row and stops the live execution.
resmon is a local-first desktop application composed of two cooperating processes: an Electron main process and a Python FastAPI backend. The Electron main process launches the Python backend as a child process (or attaches to an already-running headless daemon managed by daemon.py + service_manager.py) and hosts the React renderer window. The renderer never calls Python directly — it talks to the backend exclusively over fetch-based JSON on http://127.0.0.1:<port> (default 8742, discovered via window.resmonAPI.getBackendPort()) and over Server-Sent Events for live progress.
The backend is a single FastAPI application constructed at module load in resmon_scripts/resmon.py. A shared sqlite3.Connection backs every request, the database path defaults to resmon.db at the project root, and the schema is owned by implementation_scripts/database.py with a version-tracked migration path on startup. Binding to 127.0.0.1 is not a boundary on its own — any web page in any browser on the machine can send requests to loopback — so since 2.2 the outermost ASGI layer (LocalApiGuard) refuses every request, on every route, that does not name a loopback Host on the backend's own port, come from the app's own renderer origin when it has an Origin, and carry the backend's per-instance token in Authorization: Bearer. CORS answers the renderer's exact origin and nothing else, and Access-Control-Allow-Private-Network is sent only on a preflight from that origin that asks for it. What this defends and what it does not: docs/local-api-security.md. All SQL is parameterized; all credentials flow through a single credential_manager.py module that owns OS-keyring access.
The core pipeline is SweepEngine (implementation_scripts/sweep_engine.py), which orchestrates query → normalize → dedup → link → report → summarize → finalize for both manual and routine-fired runs. Per-source API clients (27 repositories) live under implementation_scripts/api_*.py and are registered through api_registry.py. Results are normalized by normalizer.py, deduplicated by DOI and by (title, first author), and rendered by report_generator.py into Markdown, with optional PDF and LaTeX exports through report_exporter.py.
The renderer is a React 18 + TypeScript single-page application bundled with Webpack and served into an Electron window (electron/main.ts, dist/electron/main.js). Routing uses react-router-dom HashRouter because the packaged app is loaded from file:// where push-state history is unreliable. ExecutionProvider wraps every route to hold live multi-execution state. All HTTP access is centralized in two API-client wrappers (api/client.ts, api/repositories.ts), each of which sets Cache-Control: no-store and unwraps FastAPI's {detail: "..."} error envelope into a single readable message. A FloatingWidget overlay is mounted outside MainContent so it survives route transitions and continues to pulse for any running execution.
AI summarization is optional and fully bring-your-own-key. implementation_scripts/llm_factory.py (build_llm_client_from_settings) reads the persisted ai_* settings and returns either a RemoteLLMClient (OpenAI, Anthropic, Google, xAI, Meta, DeepSeek, Alibaba, or a user-defined custom provider), a LocalLLMClient backed by a local ollama instance over its REST API (implementation_scripts/llm_local.py), or None when the provider is unset — "AI unconfigured" is a silent no-op, never an exception. The custom-provider base URL is validated to be HTTPS unless the host is loopback, both in the Settings → AI panel and in the factory. Token-aware chunking and provider-agnostic summarization live in summarizer.py with prompt scaffolding in prompt_templates.py. API keys are never logged, never included in exception messages, and never returned by any GET endpoint; key lookup honors an ephemeral per-execution stack that takes precedence over the keyring value.
Routine firing is driven by ResmonScheduler (implementation_scripts/scheduler.py), a thin wrapper over APScheduler's BackgroundScheduler with a SQLAlchemyJobStore backed by sqlite:///resmon.db, so jobs persist across app restarts. Cron parsing is strict 5-field (minute hour day month day_of_week). The scheduler is decoupled from FastAPI via a dispatcher indirection: set_dispatcher(fn) installs the callback invoked on each fire, and the APScheduler worker thread can never raise because a missing dispatcher is logged and returned. On startup, _init_scheduler_on_startup re-registers every active routine from get_routines(conn); on shutdown, the scheduler is gracefully stopped.
implementation_scripts/admission.py (ExecutionAdmissionController) is the single global gate on concurrent executions. max_concurrent is clamped to [1, 8] (default 3) and the routine-fire queue is clamped to [1, 64] (default 16). Manual admission is reject-or-pass: if no slot is free, REST endpoints raise HTTP 429 with a Retry-After: 5 header that the Deep Dive / Deep Sweep forms surface verbatim. Routine admission falls through to a bounded FIFO queue that drains as slots free; overflow past queue_limit is dropped with a logged error. Live limits are reloaded from the settings table at startup and updated through PUT /api/settings/execution (Settings → Advanced panel).
Live progress is streamed through ProgressStore (implementation_scripts/progress.py), a thread-safe in-memory bus between pipeline worker threads and the SSE endpoint. Each exec_id owns its own event list, lock, cancel-flag threading.Event, and completion boolean. The backend exposes three surfaces on top of it:
GET /api/executions/active— a cheap{active_ids: [...]}payload polled byExecutionContextevery 3 seconds as a safety net that attaches background-initiated runs and detects dropouts.GET /api/executions/{id}/progress/events— the full event list, used by the 1-second per-execution poller that drivesExecutionContextand thus the Monitor page and FloatingWidget.GET /api/executions/{id}/progress/stream— a true SSE endpoint emittingevent: progressframes withid: <cursor>headers,~300 msheartbeats (: heartbeat\n\n),last_event_idresumption, and a post-terminal drain that replays any persisted-but-unread events before closing.
Standard SSE headers are set (Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no) and PrivateNetworkMiddleware does not buffer the stream. After an execution ends, buffered events are persisted into the execution_progress table so the Monitor and Results pages can reconstruct the stream after a process restart. Cancellation is cooperative: POST /api/executions/{id}/cancel sets the cancel flag, the pipeline's 2-second heartbeat notices, a partial report is flushed, and the execution finalizes as cancelled.
Cancellation is the orderly way a run stops, and it is the only one the paragraph above describes. A backend that is force-quit, loses power, or is killed by Electron on the way out does not get to flush anything, and the run's row used to sit at running for ever: it appeared in no active list, could not be cancelled, and claimed in Results & Logs to still be searching.
executions.status has a fifth value for that: interrupted. It is deliberately not failed — nothing went wrong with the search, the process it was running inside went away, and saying failed was an overclaim resmon made about itself for several releases. The row records interrupted_reason, one of:
| Reason | What resmon observed |
|---|---|
daemon_restart |
resmon watched itself stop and wrote the row on the way out. |
owner_dead |
A later start found the process that owned the row gone. |
unknown |
Neither could be established. |
Each run records the pid and runtime id of the backend that owns it (owner_pid, owner_runtime_id) and stamps last_seen_at_utc at every pipeline stage boundary and every 30 seconds inside a long one. On startup, before the scheduler starts, _reconcile_executions_on_startup adopts every running row whose owner can be established to be gone. Everything else is left running and counted in the log: a pid that has been reused, or one belonging to another user, reads as alive, because telling a user that a live run has died is the worse mistake.
Rows written before schema 19 recorded no owner at all, so there is no liveness fact to read and the only remaining evidence is the clock. Those are adopted after 24 hours. That number is a judgement rather than a measurement: resmon puts no ceiling on how long a sweep may take and the admission queue makes a routine fire wait for a slot with no timeout, so there was no shorter number in the code to borrow. It goes stale on its own as pre-2.3 history ages out.
From an interrupted, failed or cancelled row, Restart (POST /api/executions/{id}/restart → 202) begins a fresh execution with the source's search terms, databases and date window, carrying restarted_from back to it; GET /api/executions/{id} reports both that link and the restarted_into ids. The source row is never modified — it is a record of what happened. Restart is not resume: progress events are persisted once, at the end of a run, so an interrupted run has nothing durable to carry forward. It reproduces the search and the AI on/off choice (read from whether the source has execution_ai lane rows), not the provider, model or credential of the day — parameters never held those, and reconstructing them would be a guess. A restart goes through the same manual admission cap as a Deep Sweep, and cancelling a row that is not running now answers 409 with a sentence naming its state.
Three doors lead into a routine fire — the scheduler's callback, POST /api/routines/{id}/run, and the admission queue draining a fire it had held — and until 2.3 none of them knew about the others. A routine that ran longer than its own period would be fired again on top of itself, and a person who clicked Run now while a scheduled sweep was in flight got a second one.
A fire is now admitted only after an atomic claim on the routine, taken under a lock before the worker thread starts and released in that thread's finally. It is deliberately not a SELECT ... WHERE status = 'running': the execution row does not exist yet when the second fire arrives, so that query is too late — and after a force-quit it is far too long, because rows nobody owns would refuse the routine for ever. (Those rows are schema 19's business, above.) The claim lives in the memory of the backend that owns the scheduler; a second fire while it is held is answered 409 naming the live execution id, on all three doors, and the MCP run_routine tool passes that id through as {"conflict": "routine_already_running", "execution_id": N}.
POST /api/search/dive, POST /api/search/sweep and POST /api/executions/{id}/restart accept an optional client request_id. Two requests carrying the same id are one submission: the second is answered with the execution the first started, as {"execution_id": N, "duplicate": true}, and no second row is ever written — executions.request_id carries a partial UNIQUE index over it. The renderer mints one id per in-flight request and disables the submit control while that request is running, so a second click cannot become a second run and a retry of the same submission is answered with the run already going. The id is never minted at page load: fixed for the life of the page, it would answer a user's genuine second search with their first one's results.
Finally, missed fires. A routine due at 07:00 on a morning the laptop stayed shut was not skipped loudly — it was skipped invisibly, because re-adding routines on startup recomputes each job's next fire from the present moment and overwrites the overdue time before anything can read it. resmon now reads the jobstore's persisted next_run_time before starting the scheduler and before the re-add, and writes a row into routine_missed_fires for every active routine whose fire had already passed. GET /api/routines/{id} reports missed_fires (a count and the last overdue time) and the detail; the Routines page says "missed N fires while resmon was closed; last due …", and routine-health analytics carries the count beside the run series — a routine can look healthy by every other measure while quietly running a third as often as its owner believes.
What that row claims is exactly what was observed: the next fire was in the past when resmon looked. It is not a claim that the run did not happen — APScheduler's one-hour misfire grace means a fire found a few minutes late can still run — and nothing is run to catch up. Whether resmon should quietly perform a weekend of missed sweeps the moment a laptop opens is a decision for its owner, not a default chosen by a migration; the disposition column exists to record that policy when there is one.
Until schema 21 a routine had exactly one destination — an email, sent inline from the execution worker — and resmon recorded nothing about it. A mail server that had started refusing connections, a password that had been rotated, a laptop with no network: each produced a line in a log file nobody reads, and the user's completion emails simply stopped. For an application whose subject is telling you when your monitoring has broken, that was the wrong way round.
A routine now has a list of destinations (routine_delivery_targets), and every attempt to reach one is a row in deliveries: which run, which destination, the state, how many attempts, the reason it has not arrived, and when the next attempt is due. Four channels ship:
- Email — the existing sender, now behind a channel adapter. The bundle it attaches (when Results in Email is on) is the one
POST /api/executions/exportbuilds, search-record companions included; the old hook quietly left those out, so a mailed bundle was poorer than the same user's downloaded one. The SMTP password stays in the OS keyring and never reaches a table, a setting or an error message. - Folder — the same bundle, unpacked into a directory you choose, plus a
delivery.jsonnaming the run and the report's sha256. It is written to a temporary name and renamed into place, so a sync client watching the folder never uploads a half-written report. This is what makes iCloud, Dropbox, OneDrive or a shared drive a delivery destination with no code of ours on the wire. A directory that is not there — an unmounted drive, a folder that is not signed in — is refused with that said, and the reason is recorded rather than the delivery being dropped. - Webhook — an
https://address you own, which receives a signed JSON envelope: the routine and run, when it finished, how many results and how many were new, the read-time coverage line, the report's sha256, a link to the search record, and the report bundle. The bundle travels as a 24-hour signed link the receiver fetches from resmon, or inline as base64 when you tick Send the bundle inside the envelope. Every envelope is signed with a secret you set on the destination and resmon holds in the OS keyring; it will not send an unsigned one. Plainhttp://is refused except for a receiver on127.0.0.1. A non-2xx answer or a timeout is a recorded failure carrying the status code — never the address. The receiver's side of the contract is docs/delivery.md. - Feed — an Atom 1.0 file at
<folder>/resmon/<routine>/feed.xml, rewritten whole and renamed into place on each delivery, holding the newest 50 runs newest-first. One entry per run, linking to the folder channel's bundle directory when the routine has one and carrying the summary alone when it does not. Every string in it is escaped, there is no script, no tracker and no secret-bearing link — a feed file gets copied into shared folders and read by software resmon has no relationship with, so the signed bundle link deliberately never appears in one.
Each destination is automatic or waits for review. A review destination's delivery is recorded as awaiting_review and sits there, through restarts, until a person releases it; nothing promotes it, ever. Delivery, skip and retry are the three decisions the queue is deliberately not allowed to make.
The completion hook no longer sends anything: it enqueues one row per enabled destination and wakes a drain thread, which claims a due row by compare-and-swap, delivers it, and writes back. That matters beyond tidiness — the old hook talked to a mail server on the execution worker's thread, still holding that run's admission slot. Failures back off 1 minute, 5 minutes, 25 minutes and then stop with the reason on the row; UNIQUE(execution_id, target_id) plus the CAS is what makes "delivered exactly once" a fact the database enforces. A row left mid-delivery by a backend that was force-quit is re-queued on the next start, and only where that owner can be established to be gone — resmon would rather send a report twice than never.
B4, kept by the migration: every routine that had email_enabled = 1 gets one automatic email target, whose blank address means the recipient in Settings → Email. An existing user's completion emails keep arriving with nothing to edit, and email_enabled is still the switch the Routines page toggles — an email destination on a routine with the switch off is not queued.
What resmon claims is what it did: that it handed the report to your mail server, wrote it into the folder, or got a 2xx from your receiver. Whether the message reached an inbox, whether the folder finished syncing, and what the receiver did with the envelope after answering, it cannot see, and the panel says so.
implementation_scripts/cloud_storage.py wraps the Drive v3 API with the least-privilege drive.file OAuth 2.0 scope. The token is stored in the OS keyring, and uploads are triggered by SweepEngine._maybe_auto_backup when the cloud_auto_backup setting is on. Configured under Settings → Cloud Storage. It is the only way any report leaves the machine, and it is off by default.
| Category | Component | Purpose |
|---|---|---|
| Backend | Python 3.10+, FastAPI 0.135, Starlette 1.0, Uvicorn 0.44, Pydantic 2.12 | HTTP server, request validation, ASGI runtime |
| Backend | httpx 0.28, lxml 6.0, beautifulsoup4 4.14 | Repository API clients, XML/HTML parsing |
| Backend | cryptography 46, keyring 25.7 | OS-keyring credential storage, envelope encryption helpers |
| Backend | google-api-python-client 2.194, google-auth-oauthlib 1.3 | Google Drive artifact backup (OAuth 2.0, drive.file scope) |
| Backend | nltk 3.9, tiktoken 0.12 | Text normalization and token-aware chunking for summarization |
| Frontend | Electron 41, Node.js 22.13+, npm 10+ | Desktop shell, main-process bridging |
| Frontend | React 19, React Router 7 (HashRouter), TypeScript 6 |
Renderer SPA and routing |
| Frontend | Webpack 5, ts-loader 9, css-loader 7, style-loader 4, html-webpack-plugin 5 | Build pipeline |
| Frontend | FullCalendar 6 (@fullcalendar/react, daygrid, timegrid, interaction) |
Calendar page rendering |
| Frontend | electron-builder 26 |
Packaged-app distribution |
| Database | SQLite 3 (stdlib sqlite3), SQLAlchemy 2.0 |
Local state, schema migrations, APScheduler job store |
| LLM — Remote (BYOK) | openai 2.31, anthropic 0.95, plus provider-agnostic httpx clients for Google, xAI, Meta, DeepSeek, Alibaba, and custom HTTPS endpoints |
Remote summarization backends |
| LLM — Local | ollama REST API (/api/generate, /api/tags) via llm_local.py |
On-device open-weight model inference (Llama, Gemma, Qwen, etc.) |
| Scheduling | APScheduler 3.11 (BackgroundScheduler + SQLAlchemyJobStore) |
Routine cron jobs, persistent across restarts |
| Service integration | launchd (macOS), systemd --user (Linux), Task Scheduler (Windows) via service_manager.py |
Optional headless-daemon OS service |
stdlib smtplib (via email_notifier.py + email_sender.py), python-multipart 0.0.26 |
Transactional SMTP notifications and attachment handling | |
| Testing | pytest 9.0, pytest-timeout 2.4 | Backend unit and integration tests under resmon_scripts/verification_scripts/ |
| Testing | TypeScript tsc --noEmit (npm run typecheck) |
Frontend type checking |
resmon/
├── README.md # This file.
├── LICENSE # MIT license.
├── requirements.txt # Pinned Python dependencies.
├── credentials.json # Google OAuth 2.0 client secrets (user-supplied, gitignored).
├── resmon.db # SQLite database (created on first launch).
│
├── resmon_scripts/ # All application source code.
│ ├── resmon.py # FastAPI app entrypoint (routes, startup/shutdown hooks).
│ │
│ ├── implementation_scripts/ # Backend modules.
│ │ ├── admission.py # Concurrent-execution admission controller.
│ │ ├── analytics.py # Corpus analytics queries (thin-corpus policy).
│ │ ├── explorer.py # Corpus-wide search, faceting, keyset pagination.
│ │ ├── ai_models.py # Provider model-catalog probing.
│ │ ├── api_*.py # Per-repository API clients (27 active sources).
│ │ ├── api_base.py # Shared rate limiter + HTTP client base class.
│ │ ├── api_registry.py # Slug → client dispatch table.
│ │ ├── citation_graph.py # Citation and context graphing.
│ │ ├── cloud_storage.py # Google Drive backup (drive.file scope).
│ │ ├── config.py # APP_NAME, paths, defaults.
│ │ ├── config_manager.py # Configuration JSON export/import.
│ │ ├── credential_manager.py # OS-keyring credential vault + ephemeral stack.
│ │ ├── daemon.py # Headless-daemon lock file, state dir, attach/spawn logic.
│ │ ├── database.py # Schema, migrations, parameterized queries.
│ │ ├── email_notifier.py # Email templating and dispatch.
│ │ ├── email_sender.py # SMTP transport.
│ │ ├── lifecycle.py # Retractions, preprint→published, version tracking.
│ │ ├── llm_factory.py # Provider whitelist + client construction.
│ │ ├── llm_local.py # ollama REST client.
│ │ ├── llm_remote.py # Remote-BYOK client (OpenAI, Anthropic, etc.).
│ │ ├── logger.py # Rotating app logger + per-execution TaskLogger.
│ │ ├── match_explain.py # Keyword match transparency + per-keyword contribution.
│ │ ├── normalizer.py # Cross-source metadata normalization + dedup.
│ │ ├── progress.py # ProgressStore (SSE/poll event bus + cancel flag).
│ │ ├── reading_queue.py # Saved-paper membership: To read / Read, over document ids.
│ │ ├── prompt_templates.py # Summarization prompt scaffolding.
│ │ ├── repo_catalog.py # Repository metadata catalog.
│ │ ├── report_generator.py # Markdown report composition.
│ │ ├── reference_export.py # BibTeX / RIS / CSV / JSON reference exports.
│ │ ├── report_exporter.py # PDF / LaTeX export pipeline.
│ │ ├── scheduler.py # ResmonScheduler (APScheduler wrapper).
│ │ ├── search_record.py # PRISMA-shaped reproducible search record.
│ │ ├── service_manager.py # launchd / systemd / Task Scheduler integration.
│ │ ├── summarizer.py # Token-aware chunking + provider-agnostic summarization.
│ │ ├── sweep_engine.py # End-to-end query → dedup → report → summarize pipeline.
│ │ ├── utils.py # Shared helpers.
│ │ ├── watchdog.py # Silent-failure detection over execution history.
│ │ └── assets/ # Static backend assets (templates, figures).
│ │
│ ├── frontend/ # Electron + React renderer.
│ │ ├── package.json # Node dependencies and build scripts.
│ │ ├── webpack.config.js # Renderer bundle config.
│ │ ├── tsconfig.json # Renderer TypeScript config.
│ │ ├── tsconfig.electron.json # Electron-main TypeScript config.
│ │ ├── electron/ # Electron main-process sources (preload, IPC bridge).
│ │ ├── src/ # Renderer SPA.
│ │ │ ├── index.tsx # React root.
│ │ │ ├── App.tsx # HashRouter + layout + providers.
│ │ │ ├── api/ # Typed HTTP client wrappers.
│ │ │ ├── components/ # Shared components (Sidebar, Header, FloatingWidget, …).
│ │ │ ├── context/ # ExecutionContext.
│ │ │ ├── hooks/ # useExecutions, useRepoCatalog.
│ │ │ ├── pages/ # One component per route (DashboardPage, AnalyticsPage, ExplorerPage, …).
│ │ │ ├── styles/ # Global stylesheets.
│ │ │ ├── types/ # Shared TypeScript types.
│ │ │ └── __tests__/ # Renderer unit tests.
│ │ └── dist/ # Build output (renderer/, electron/).
│ │
│ ├── service_units/ # launchd plists, systemd units, Task Scheduler XML.
│ └── verification_scripts/ # pytest-based backend verification suite.
│
└── resmon_reports/ # All user-facing outputs (REPORTS_DIR).
├── markdowns/ # Per-execution Markdown reports.
├── pdfs/ # Exported PDF reports.
├── latex/ # Exported LaTeX reports.
├── figures/ # Static figures embedded in reports.
├── logs/ # resmon.log (5 MB × 3 backups) + per-execution TaskLogger logs.
└── info_docs/ # 11 implementation-grounded info documents.
OS-scoped state (daemon lock file, service-unit-managed logs) lives outside the repository, under ~/Library/Application Support/resmon on macOS, $XDG_STATE_HOME/resmon on Linux, and %LOCALAPPDATA%\resmon on Windows (resolved by daemon.state_dir()).
resmon persists reusable search parameters as named rows in the configurations table and treats them as the primary unit of reproducibility for Deep Dive, Deep Sweep, and Routine runs. A configuration is a saved bundle of parameters — repository (or repository set), keywords, per-repository result cap, AI toggle and AI settings, email toggles, storage settings, and (for routines) schedule and execution-location fields. Date ranges are deliberately not persisted; they are chosen fresh per run or per routine fire so a saved configuration always produces a time-current window.
Every row carries a config_type constrained by a CHECK constraint to one of three values:
config_type |
Produced by | Consumed by | Purpose |
|---|---|---|---|
manual_dive |
Deep Dive page's Save Configuration action | Deep Dive page's ConfigLoader | Reusable single-repository query preset. |
manual_sweep |
Deep Sweep page's Save Configuration action | Deep Sweep page's ConfigLoader | Reusable multi-repository query preset. |
routine |
Routines page's create/edit modal | Routines page (restores everything except the date range) and the Calendar popover | Scheduled Automated Deep Sweep bound to a cron expression. |
Rows are keyed by an auto-increment integer id; names are not uniquely constrained, so multiple configurations with the same name are permitted. The Configurations page at #/configurations opens on the Routine Configs tab by default and exposes a Manual Configs tab that shows manual_dive and manual_sweep entries together.
The parameters column stores a JSON document whose shape matches the search page that produced it. Representative (non-exhaustive) field sets:
manual_dive—repository(single slug),keywords,max_results,ai_enabled, optionalai_overrides(length,tone,model).manual_sweep—repositories(array of slugs),keywords,max_results_per_repo,ai_enabled, optionalai_overrides.routine— everymanual_sweepfield pluscron_expression,email_enabled,results_in_email,notify_on_completion, and alinked_routine_idpointing at the row in theroutinestable that APScheduler registers a job under.
Deleting a configuration whose config_type is routine cascades to the linked routine row when the stored parameters contain a valid linked_routine_id pointing at an existing routine; the delete-confirmation dialog surfaces the cascade count before the user confirms.
The Configurations page supports a round-trip JSON workflow for moving presets between machines:
- Select one or more rows via the per-row checkboxes or the select-all header checkbox.
- Click Export Selected — the frontend sends
POST /api/configurations/exportwith{ ids: [...] }; the backend writes a ZIP archive of individual JSON files throughconfig_manager.export_configsand returns{ "path": "<absolute path>" }. - The success banner shows the archive path for 10 seconds; on desktop a Reveal in Finder (macOS) / Reveal in File Explorer button is rendered when the Electron preload has exposed
window.resmonAPI.revealPath. - On the receiving machine, click Import and pick one or more
.jsonfiles through the native file picker. Each file is validated — any file whose name does not end in.jsonshort-circuits the entire batch with an inline error — and surviving files are POSTed asmultipart/form-datato/api/configurations/import. The backend writes each upload to a temporary.jsonfile and hands it toconfig_manager.import_configs, which validates the payload and inserts a new configuration row. The response carries{ "imported": <n>, "errors": [] }. - Click Delete Selected to open the confirmation dialog (which counts how many of the selected rows are routines so the cascade impact is visible). Confirming issues one
DELETE /api/configurations/{id}per selected row; per-row failures are swallowed so the batch proceeds.
Imports are always additive: a new row is inserted with a fresh integer id. Nothing is overwritten, so round-tripping is safe to repeat.
The machine-readable contract is docs/api-contract/. openapi.json there describes every one of the 184 routes — method, path, parameters, request body — and http.md indexes them against the user journeys they serve, with each route's auth class, whether it streams, and a one-line purpose. Both are generated from the running app and a test fails when they drift, so they cannot describe a version of resmon that no longer exists. The prose below is the guided tour of the same surface; where the two differ, the contract is the one that was checked. No route declares a response model, so the contract describes what each route accepts and this section is where you find what it answers with.
All REST endpoints are served by the local FastAPI daemon (resmon_scripts/resmon.py) bound to 127.0.0.1:8742 by default. Every request needs the backend's local API token in Authorization: Bearer <token> and a loopback Host; refusals are 401 token_missing / 401 token_invalid / 403 host_refused / 403 origin_refused (security model). Requests and responses are JSON unless noted; error responses use FastAPI's standard {"detail": "..."} envelope and the shared frontend API client (api/client.ts) unwraps it into a single readable message. The groups below are summarized from the Backend sections of the corresponding info docs under resmon_reports/info_docs/.
GET /api/health— cheap readiness probe used by the Electron main process to decide when to open the renderer window. Returns process identity, the app version, andembeddings: {extension, reason}— whether this backend can load the vector extension, and why not when it cannot. The extension load is attempted per call rather than read from a cached probe, because a health endpoint answering from memory would keep reporting a capability the process had lost; measured at 0.076 ms per repeat load on one connection.
Manual execution dispatch. Each endpoint registers a new execution with the admission controller, starts the SweepEngine pipeline on a worker thread, and returns the assigned execution id.
POST /api/search/dive— single-repository Deep Dive. Body carriesrepository,keywords, optionaldate_range,max_results,ai_enabled, per-executionai_overrides, and an optional ephemeral API key.POST /api/search/sweep— multi-repository Deep Sweep. Body carriesrepositories(array of slugs),keywords, optionaldate_range,max_results(applied per repository),ai_enabled, per-executionai_overrides, and a map of ephemeral per-repository keys. Returns HTTP429withRetry-After: 5when the admission controller has no free slot.GET /api/search/repositories— repository catalog surface consumed by the search pages to render the repository selector.
CRUD, activation, and cancel control for scheduled Automated Deep Sweeps.
GET /api/routines— list of local routines; used by the Routines and Calendar pages.POST /api/routines— create a local routine; the response includes the row'sid, which is then stored aslinked_routine_idinside the matchingroutineconfiguration row.PUT /api/routines/{id}— patch routine fields (including the inline Email / AI / Notify column toggles on the Routines page, which each patch a single flag).- Both take an optional
intent— the sentence the coverage audit compares the routine's results against, stored inroutines.intent. It is never defaulted from the keywords: an intent copied from a query would make every routine claim its owner stated one. A field absent from the body leaves the stored value alone; an empty string clears it. DELETE /api/routines/{id}— delete a routine; also removes the APScheduler job.POST /api/routines/{id}/activate/POST /api/routines/{id}/deactivate— register or unregister the APScheduler job without deleting the DB row. Invoked from the Routines table and from the Calendar popover's routine toggle.POST /api/routines/{id}/run— run once, now, outside the schedule. It also marks that routine'srecordedmissed firesran_late: a run was started after those fires came due, which is what happened, and not that each missed fire produced its own run. The Run now button beside the missed-fire line on the Routines page is this endpoint.GET / POST /api/routines/{id}/delivery-targetsandPUT / DELETE .../{target_id}— the routine's delivery destinations.channelis one of the schema's four:email,folder,webhook,feed. A destination resmon can already tell will not work — a webhook that is nothttps://and not on the loopback, a feed with no folder — is refused at the route with that said. A target's channel cannot be edited, because the deliveries already recorded against it were attempted over it.GET /api/deliveries/{id}/bundle?exp=&sig=— the bundle a webhook envelope linked to. The one route resmon serves without its local API token, because a webhook receiver is not resmon and should not hold a credential that opens every route; it carries a per-destination HMAC over the delivery id and an expiry instead, and the Host and Origin checks still apply. A missing, wrong or expired signature gets the same 403 as a delivery that was never a webhook. See docs/local-api-security.md and docs/delivery.md.GET /api/routines/{id}/deliveriesandGET /api/executions/{id}/deliveries— the delivery record, either way round.POST /api/deliveries/{id}/deliver— release one that was waiting for review. The only promotion out ofawaiting_reviewthere is.POST /api/deliveries/{id}/skip— decide not to send one.POST /api/deliveries/{id}/retry— a failed or skipped delivery back into the queue with the attempt counter reset: the human overruling the backoff, which is a different decision from the backoff's own retry and so a different route.
History, reporting, log and progress surfaces, export, delete, and cancel control for every execution (Deep Dive, Deep Sweep, Automated Deep Sweep).
GET /api/executions/{id}— execution metadata row (type, status, query, counts, timestamps, routine id, etc.).GET /api/executions/{id}/report— rendered Markdown report.GET /api/executions/{id}/log— raw per-execution log text (written byTaskLogger).GET /api/executions/{id}/progress/events— historical progress event list. Returns the liveprogress_storeevents if the execution is still registered in memory and falls back to persisted events from theexecution_progresstable after cleanup.GET /api/executions/{id}/progress/stream— SSE progress stream (used byExecutionContextand the Monitor page) with~300 msheartbeats,last_event_idresumption, and a post-terminal drain.GET /api/executions/active— cheap{active_ids: [...]}payload polled every 3 seconds as a safety net that attaches background-initiated runs.POST /api/executions/export— body{ "ids": [...] }; the backend assembles a zip bundle of the selected executions' reports, logs, and artifacts and returns{ "path": "<absolute path>" }. When the Storage tab'sexport_directoryis set, the zip lands there; otherwise a temp file is used.POST /api/executions/{id}/cancel— cooperative cancel: sets theProgressStorecancel flag, the pipeline's 2-second heartbeat observes it, a partial report is flushed, and the execution finalizes ascancelled.GET /api/executions/{id}/documents?limit=&offset=&only_new=— one page of the papers the run stored, newest publication first, each carrying its corpus documentidand itsqueue_status(to_read,read, ornullwhen it is not saved).limitdefaults to 50 and is capped at 200; an unknown execution is a 404 and an out-of-rangelimitor a negativeoffsetis a 400. This is what the Papers tab reads — an execution id is not a paper id, and the report is prose.DELETE /api/executions/{id}— delete an execution row and its artifacts. Papers the run found stay in the corpus, and any reading-queue entries pointing at them stay too.
See Configuration for the full round-trip workflow.
GET /api/configurations— list all rows (optionalconfig_typefilter).parametersis JSON-decoded into a dict per row when possible, otherwise returned as the raw string.POST /api/configurations— create a configuration from aConfigCreatebody (name,config_type,parameters). Response:{ "id", "name", "config_type" }.PUT /api/configurations/{id}— update a configuration from aConfigUpdatebody (name?,parameters?); 404 if the id is unknown.DELETE /api/configurations/{id}— delete a configuration; cascades to the linked routine whenconfig_type == 'routine'and the parameters carry a validlinked_routine_id.POST /api/configurations/export— body{ "ids": [...] }; writes a ZIP archive of per-config JSON files and returns{ "path": "<absolute path>" }.POST /api/configurations/import— accepts a multipartfiles: list[UploadFile]of.jsonfiles; response{ "imported": <n>, "errors": [...] }.
Read-only analysis of the locally stored corpus. Every endpoint reads the documents,
execution_documents, executions, and routines tables — no external requests are made,
so these work offline and consume no repository quota.
| Method | Path | Returns |
|---|---|---|
| GET | /api/analytics/overview |
All four sections plus the corpus summary, in one round trip. Used by the Analytics page. |
| GET | /api/analytics/summary |
Corpus headline counts: papers, distinct papers after de-duplication, distinct authors, sources used, completed executions, DOI coverage. |
| GET | /api/analytics/source-contribution |
Per source: total papers, papers unique to that source, and papers that also arrived elsewhere. Identity is DOI, falling back to a normalized title. |
| GET | /api/analytics/discovery-lag |
Per source: median, fastest, and slowest days between publication_date and first_seen_at. |
| GET | /api/analytics/routine-health |
Per routine: new results per run, consecutive runs with nothing new, and a healthy / stale / insufficient_data status. |
| GET | /api/analytics/publication-volume |
Papers per publication month. ?group_by=source|category (default source), ?months=N (default 12). |
| GET | /api/analytics/keyword-contribution |
Per keyword: papers matched, papers no other keyword matched, and papers no keyword accounts for. ?execution_id=N scopes to one run; omitted, it pools every keyword ever searched against the whole corpus. Reads every candidate document once, so it is cached and is not part of /overview. |
Thin-corpus contract. Every payload carries sample_size. Any derived statistic also
carries sufficient; when it is false the value is null and insufficient_reason
explains why. Counts are always reported — a percentage of an empty corpus is null, not
0.0, because a proportion of nothing is undefined rather than zero. The thresholds live
in implementation_scripts/analytics.py (MIN_SAMPLE_FOR_LAG, MIN_RUNS_FOR_HEALTH,
STALE_RUN_THRESHOLD) and are deliberately low: they exist to stop a single data point
being presented as a trend, not to withhold information from a small corpus.
The complete, dated account of one search. Reads only what the run already recorded.
| Method | Path | Returns |
|---|---|---|
| GET | /api/executions/{id}/search-record |
JSON: search parameters, per-source identification counts, deduplication figures with their PRISMA mapping, and the caveats. |
| GET | /api/executions/{id}/search-record?format=markdown |
The same as a text/markdown download, shaped for a methods section. |
Why each figure carries a prisma field.
| resmon figure | PRISMA 2020 box | Note |
|---|---|---|
Per-source records_identified |
Records identified from databases | Maps cleanly. |
cross_source_duplicates |
Duplicate records removed before screening | resmon does not remove them. It flags the overlap and keeps both rows. Reported as duplicates found. |
discarded_unusable |
Records marked as ineligible by automation tools | A data-quality discard (no title, no identifier) — not a relevance judgement. |
records_added |
Records screened | Records newly added to the corpus and therefore available for screening. |
already_held |
none | Records a repeating search re-encountered. PRISMA describes a single search; this has no box, and the record says so. |
cross_source was computed on every run since the beginning but never stored. Runs
predating schema 8 report it as null — not recorded — rather than 0, because "we did
not measure this" and "there were none" are different claims and a methods section must not
make the second on the first's behalf. Schema 8 promotes all five deduplication figures out
of the progress-events blob into columns and backfills the four that history can supply.
Match transparency for one paper. Reads only stored metadata; makes no external requests.
| Method | Path | Returns |
|---|---|---|
| GET | /api/documents/{id}/why |
Per keyword: whether it appears, and in which of title / abstract / categories / authors. Plus a verdict, a headline, the source's documented keyword semantics, and what_resmon_cannot_see. ?execution_id=N scopes to one run's keywords; omitted, the union across every run that returned the paper is explained. |
What it will not claim. resmon cannot know why an upstream source returned a paper.
Most sources are relevance-ranked — they score documents against the whole
query and return the best matches, so a paper can legitimately come back containing none
of the terms literally. resmon also stores only title, abstract, authors and categories,
never full text, so a match may exist somewhere it cannot see. Both limits ship in
what_resmon_cannot_see on every response, including ones where every keyword matched:
a title match makes a paper plausible, which is not the same as knowing the upstream's
reasoning, and the difference matters to anyone defending a search strategy.
The verdict grades the explanation, never the paper:
| Verdict | Meaning |
|---|---|
resmon_filtered |
For bioRxiv and medRxiv, resmon filtered date-range API records locally instead of relying on an upstream result ranking. |
local_evidence |
At least one keyword is verifiably present. The paper is a plausible match. |
no_local_evidence |
Nothing matched in what resmon stores. Normal on a relevance-ranked source. |
no_keywords_recorded |
The run stored no keywords, so there is nothing to check against. |
Matching is word-boundary and case-insensitive, phrase-aware for quoted keywords, and
tolerant of terms that end in punctuation (C++, cs.LG). Keywords containing AND /
OR / NOT are flagged, not parsed — several upstreams forward operators verbatim,
and implementing a boolean engine whose semantics differed from the upstream's would be
another way of over-claiming.
What has happened to papers since resmon found them. The only part of resmon that makes outbound requests without the user starting a search, which is why the check is explicit rather than automatic.
| Method | Path | Returns |
|---|---|---|
| GET | /api/lifecycle |
Recorded events, severity counts, coverage, and the state of any running check. |
| POST | /api/lifecycle/check |
Starts a bounded background check over the least recently checked papers. {"limit": N}, default 200. 409 if one is already running. |
| POST | /api/lifecycle/for-documents |
Events for up to 500 document ids in one round trip — what the Explorer uses to badge a page of results. |
| GET | /api/documents/{id}/lifecycle |
Events for one paper, plus whether it has ever been checked. |
Never assert alone. record_lifecycle_finding raises MissingNoticeError when a
finding arrives without a resolvable notice_url, so a lifecycle event that a reader
cannot open cannot be stored, let alone displayed. A false retraction flag is defamatory
and "we inferred it from the metadata" is not a defense. The upstream label is kept
verbatim — resmon does not translate "Correction" into "Retraction" or soften
"Retraction" into "Update", because the wording is the claim — and provider_source
records whether Crossref got the notice from Retraction Watch or from the publisher.
| Severity | Used for |
|---|---|
critical |
retraction, withdrawal, removal, partial retraction — the paper was withdrawn from the record |
caution |
expression of concern — explicitly not a retraction |
informational |
correction, erratum, new version, preprint reaching a journal |
An unrecognised Crossref update type is recorded as informational with its label intact,
never promoted to a retraction.
Coverage is part of the answer. The payload reports how many papers were checked, how many carry no DOI or supported identifier and therefore cannot be checked, how many errored, and how many remain — because an empty findings list over an unchecked corpus is "nobody looked", not "nothing has been retracted", and the interface must be able to tell the two apart. Papers whose Crossref batch failed are left unchecked rather than stamped clean; a DOI Crossref simply has no record of is a real answer and is marked checked, so one unknown DOI cannot stall the walk through the corpus. Papers are re-checked after 30 days.
Batching. DOIs go to Crossref forty at a time and arXiv ids fifty at a time, so a corpus of fifteen thousand papers is a few hundred requests rather than fifteen thousand. The first draft of this feature queried arXiv one paper per request with a per-run default of 200, which meant covering a real corpus would have taken seventy-nine presses of a button — bounded, but not usable. It was fixed before 1.7.0 shipped.
Silent-failure detection over the same local data. Reads execution_sources, executions,
and routines; makes no external requests.
| Method | Path | Returns |
|---|---|---|
| GET | /api/watchdog |
findings, counts, not_enough_data, watching, and the thresholds used. |
| POST | /api/watchdog/mute |
Acknowledge one finding by finding_key. It stays listed and stops counting. |
| POST | /api/watchdog/unmute |
Return a finding to the alarm total. |
Each finding carries a stable key, a severity of broken / unusual / advice, the
scope it concerns, a title, a detail, a what_to_do, and the evidence behind it.
The severity contract. broken is only ever used for conditions resmon recorded
happening — never for an inference. unusual is a departure from a baseline the user's own
history established, and its payload always carries that baseline and its sample size.
advice is not an alarm and is excluded from counts.alarms. Thresholds live in
implementation_scripts/watchdog.py and are deliberately conservative: an alarm that is
wrong gets muted, and a muted watchdog misses the failure it was built for.
Per-source execution record. The execution_sources table stores what each source did
on each run — ok, error, skipped_missing_key, or cancelled, with the raw result
count and any error text. The sweep engine writes a row as each repository finishes.
Databases created before this table are backfilled once from the progress events already
stored on each execution, so the watchdog is useful on an existing install immediately
rather than after several more runs. Failing to write a health row never fails a sweep.
Per-lane AI record. The execution_ai table is the same idea for AI (schema 9). One
row per lane per execution records what was tried — provider, model, and the alias of the
credential slot used, never its value — alongside docs_attempted / docs_succeeded and,
on failure, a classified error kind. ok, partial and failed are held apart on purpose:
partial is a normal run with one awkward abstract, failed is a lane that did not work.
Before this table an AI failure was a line in the task log, which meant a run whose every summary failed still completed and looked healthy. Failures are now classified as lane-fatal (a rejected key, an exhausted quota, a model that does not exist — the lane is finished for this run) or document-local (an abstract past the context window, content the provider declined — this paper failed and the lane did not). That distinction is what fallback chains will act on. As with the per-source record, failing to write an AI row never fails a sweep.
Corpus-wide search. POST rather than GET because the filter set is a structure with
repeated values, not a flat query string.
| Method | Path | Returns |
|---|---|---|
| POST | /api/explorer/search |
One page of matching papers plus next_cursor. Body: query, sources[], authors[], categories[], date_from, date_to, cursor, limit, sort (newest | similarity). With similarity, query is the ranking phrase rather than a text filter — the structured filters still apply and are unchanged by the sort, so the two sorts can return different sets. Carries distance per paper, ranked_count, unranked_count and model; reports the sort it actually served, and similarity_unavailable when it could not rank. used_full_text_index is always false in this mode, because the phrase never goes through FTS. |
| GET | /api/documents/{id}/similar?k= |
The nearest papers to this one, with distances and sources, self excluded. Costs one index query and nothing at any embedding provider — the vector is already stored. An empty list carries reason. |
| GET | /api/documents/{id}/links |
Records that look like the same work as this one, each with the method that found it (shared_doi or vector+title) and a rendered label. |
| POST | /api/links/for-documents |
Links for a page of results in one round trip — what the Explorer uses to badge them. |
| POST | /api/links/collapse-preview |
Which of these ids a collapse would fold. Returns a grouping; folds nothing. |
| GET/POST | /api/links/status, /api/links/scan, /api/links/scan/cancel |
The corpus-wide scan: how many links are stored by method, start, and stop after the document in flight. ~4 minutes on 15,000 papers. |
| GET | /api/routines/{id}/coverage |
The coverage audit: the two lists, the distribution the cutoff came from, and cannot_see. Each list is capped at 25 and carries its total (off_target_total, missed_in_corpus_total); the missed total comes from a bounded index query, so missed_in_corpus_total_is_lower_bound says when it is a floor rather than a count. |
| POST | /api/explorer/facets |
Available filter values with counts, for the current filters. A facet excludes its own filter so alternatives stay selectable. |
| POST | /api/explorer/export |
Everything matching the filters as bibtex, ris, csv, or json — not just the current page. |
Configuring the embedding model and embedding the corpus. All eight endpoints are no-ops on an install that has not set one up, which is the state every install starts in.
| Method | Path | Returns |
|---|---|---|
| GET | /api/settings/embeddings |
The stored settings, the resolved lane, the capability, the backfill status, and a can-embed answer for every provider resmon lists — including the ones that cannot, each with its reason and the live observation that reason rests on. |
| PUT | /api/settings/embeddings |
Stores them. Refuses a provider that cannot embed with HTTP 400 and the sentence, at configuration rather than at backfill: a user who picks Anthropic learns it when they pick it, not after waiting for a run that was never going to produce anything. A model change rebuilds the index from the stored vectors. |
| POST | /api/embeddings/probe |
Asks the configured — or a supplied, unsaved — lane to embed one short string, and reports the width it answered with. This is the only thing that turns a claim about a provider into a fact about this machine: a local server can list its models happily and still refuse to embed, because a chat model is not an embedding model, and the probe says exactly that. |
| GET | /api/embeddings/status |
N of M embedded with model X, the running backfill, the index state, and the extension state — read from the database rather than from run counters, so the numbers are right after a restart. |
| GET | /api/embeddings/estimate |
What a backfill would cost, computed from the documents that will actually be sent. A provider with no price on record reports cost_usd: null, never 0 — zero reads as free. |
| POST | /api/embeddings/backfill |
Embeds every document lacking a vector for the active model, on a worker thread. Refused before it starts if the lane cannot embed. |
| POST | /api/embeddings/backfill/cancel |
Stops after the batch in flight. Vectors already written are kept. |
| POST | /api/embeddings/rebuild |
Rebuilds the vector index from the canonical table — the one action that fixes a database copied from a machine that could not load the extension. |
Resume is a query, not a bookmark. The work remaining is rows lacking a vector for the
active model, which is a fact about the database rather than about the last run, so it is
correct after a cancel, a crash, a model change and a corpus that grew in between. A stored
cursor would be wrong after any of those and would look right. The write is idempotent on
(document_id, model), so a re-run costs a wasted call and never a duplicate row.
Scale contract. The design target is 100,000 papers, and three choices follow from it, each measured rather than assumed:
- Free text uses FTS5, declared external-content so abstracts are not stored twice, with
triggers keeping it in step.
LIKE '%term%'has no usable prefix and reads every abstract. Where FTS5 is unavailable the query degrades toLIKEandused_full_text_indexreports it. - Authors and categories are read from
document_authorsanddocument_categories, indexed copies of the comma-joined strings ondocuments, which remains the source of truth. - Pagination is keyset, on the generated column
pub_sort. SQLite will not match a row-value comparison against an expression index, so the sort key is a real column. At row 90,000 of 100,000:SEARCHin 0.30 ms, against 1.66 ms forLIMIT/OFFSETat the same depth.pub_sortisCOALESCE(publication_date, '')— against a realNULLa row-value comparison yieldsNULL, which would silently drop every undated paper out of pagination.
Totals are capped at COUNT_CAP (10,000) and reported as total_is_capped; counting an
unbounded match set is the one query that cannot be bounded, and an exact five-digit number
changes no decision.
Membership over the corpus: which papers the user saved, and whether they have read them. Nothing here deletes a paper.
| Method | Path | Returns |
|---|---|---|
| GET | /api/reading-queue?status=&limit=&offset= |
One page of saved papers, each with its membership row and the stored document. status is to_read, read, or all / omitted; limit defaults to 50 and is capped at 200. Carries total for the current filter and counts for all three. Order is saved_at then document_id, both descending, so a paper cannot appear on two pages. |
| POST | /api/reading-queue |
Save {"document_id": n}. Idempotent: a paper already saved is returned unchanged, including its original saved_at and a read status it already had. 201 either way; 404 for a document the corpus does not hold. |
| PUT | /api/reading-queue/{document_id} |
{"status": "to_read" | "read"}. Sets read_at when a paper becomes read and clears it when it stops being read. A request that changes nothing writes nothing, so updated_at records state changes rather than requests. 404 when the paper is not in the queue; 400 for an unknown status. |
| DELETE | /api/reading-queue/{document_id} |
Removes the membership row only. The document, its authors, its provenance and every run that found it are untouched. 404 when there was nothing to remove. |
The queue exports through the existing POST /api/export/references with document_ids; there is no
separate export route, and the formats and citation-key allocation are the ones documented below.
Erasing the corpus from Settings → Advanced deletes queue rows along with the papers, by
ON DELETE CASCADE — a saved paper that no longer exists would be an entry nobody could open.
| Method | Path | Returns |
|---|---|---|
| GET | /api/executions/{id}/references |
The execution's papers as ?format=bibtex|ris|csv|json. JSON accepts ?include_ids=true to add existing corpus document IDs. ?only_new=true restricts to papers that were new at the time of the run. Responds with a Content-Disposition attachment header. |
| POST | /api/export/references |
The same formats for {"document_ids": [...], "format": "..."} or {"execution_ids": [...], "format": "..."}. Execution selections union stored document IDs and render once; unknown executions fail with 404. Nonempty document IDs cannot be combined with execution IDs. |
GET /api/calendar/events?start=<iso>&end=<iso>— combined payload of historical executions (fromget_executions(conn, limit=500)) and expanded upcoming routine fires.start/endare parsed withdatetime.fromisoformat(...)(trailingZreplaced with+00:00). Defaults clamp the window to[now, now + 90 days];window_startis forced tonowso past fires are never synthesized from cron. Each event carriesid,title,start,end,color,execution_id,routine_id,type,status,query,total_results,new_results. Scheduled expansion is capped atMAX_PER_ROUTINE = 200fires per routine per request.
Read/write surface for the eight Settings panels. Each slice is a keyed subset of the settings table, written through set_setting.
GET /api/settings/email/PUT /api/settings/email—smtp_server,smtp_port,smtp_username,smtp_from,smtp_to.POST /api/settings/email/test— sends a test email using the saved SMTP settings and the storedsmtp_passwordcredential.GET /api/settings/ai/PUT /api/settings/ai—ai_provider,ai_model,ai_local_model,ai_summary_length,ai_tone,ai_temperature,ai_extraction_goals,ai_custom_base_url,ai_custom_header_prefix.GET /api/settings/cloud/PUT /api/settings/cloud—cloud_auto_backupand related toggles.GET /api/settings/storage/PUT /api/settings/storage—pdf_policy,txt_policy,archive_after_days,export_directory(policy values are constrained tosave/archive/discard).GET /api/settings/notifications/PUT /api/settings/notifications— desktop-notification toggles.GET /api/settings/execution/PUT /api/settings/execution— admission-controller tunables (max_concurrentclamped to[1, 8], routine queue clamped to[1, 64]); reloaded live by the admission controller.- Diagnostics:
GET /api/scheduler/jobs(APScheduler job snapshot consumed by Settings → Advanced),POST /api/service/installand related/api/service/*routes for OS-service integration viaservice_manager.py.
Presence-only surface over the OS keyring through credential_manager.py. Secrets are never returned; GET responses only report whether a named credential exists.
GET /api/credentials— map of{credential_name: present_boolean}.PUT /api/credentials/{name}— store a credential (Email panel usessmtp_password; AI panel derives names from the provider, e.g.openai_api_key,anthropic_api_key,custom_llm_api_key).DELETE /api/credentials/{name}— remove a credential.POST /api/credentials/validate— validate a remote LLM key (Test key button on the AI panel).POST /api/ai/models— list the per-provider model catalog using either the freshly typed key or the stored credential (Load models button on the AI panel).
Google Drive backup — see Google Drive Backup.
GET /api/cloud/status—{ is_linked, api_ok, api_reason }describing the Google Drive link state.POST /api/cloud/link— triggers the Google Drive OAuth installed-app flow; the resulting token is stored in the OS keyring.POST /api/cloud/unlink— removes the stored token.POST /api/cloud/backup— ad-hoc backup of the report tree to the linked Drive folder; returns{ uploaded, total_files, folder_name, web_view_link }.
AI summarization is optional and fully bring-your-own-key. When the provider is unset, every pipeline call is a silent no-op and the Markdown report falls back to plain abstract extraction. Settings live under Settings → AI (#/settings/ai) and are persisted through PUT /api/settings/ai; the API key for the active provider is stored separately in the OS keyring through PUT /api/credentials/{name}.
The AI panel enforces the following provider whitelist (IMPL-AI5 / AI9 / AI10); each provider has a suggested model placeholder for the ai_model field:
ai_provider |
Kind | Credential name | Suggested model placeholder |
|---|---|---|---|
openai |
Remote (BYOK) | openai_api_key |
gpt-4o-mini |
anthropic |
Remote (BYOK) | anthropic_api_key |
claude-3-5-haiku-latest |
google |
Remote (BYOK) | google_api_key |
gemini-2.5-flash |
xai |
Remote (BYOK) | xai_api_key |
grok-4 |
meta |
Remote (BYOK) | meta_api_key |
meta-llama/Llama-3.3-70B-Instruct-Turbo |
deepseek |
Remote (BYOK) | deepseek_api_key |
deepseek-chat |
alibaba |
Remote (BYOK) | alibaba_api_key |
qwen-plus |
local |
Local (ollama) | — | llama3 (set through ai_local_model) |
custom |
Remote (BYOK, user-defined HTTPS endpoint) | custom_llm_api_key |
user-specified |
- Open Settings → AI.
- Select a provider from the Provider dropdown (e.g.
openaioranthropic). The Model placeholder updates to the provider's suggested id. - Enter the model id in Model (free text; the Load models button can populate a picker from the provider's catalog via
POST /api/ai/models). - Enter the API key in the password field; press Store key to write it to the OS keyring as
<provider>_api_key. The key is never returned to the frontend — subsequent loads only see a presence boolean fromGET /api/credentials. - Click Test key to issue
POST /api/credentials/validate, which performs a minimal round-trip against the provider. - Tune
ai_summary_length,ai_tone,ai_temperature, andai_extraction_goalsto shape the prompt scaffolding inprompt_templates.py. - Click Save (
PUT /api/settings/ai).
The custom provider targets any HTTPS endpoint that accepts an OpenAI-compatible chat-completions request shape. Two extra fields apply:
ai_custom_base_url— the endpoint root. The Save button is disabled unless the URL is HTTPS or the host is loopback (localhost,127.0.0.1,::1), enforced both by the frontend guardvalidateCustomBaseUrland by the backendllm_factoryas a hard check.ai_custom_header_prefix— the auth-header prefix applied to the storedcustom_llm_api_keycredential (e.g.Bearer).
The local provider bypasses the key flow entirely and dispatches through implementation_scripts/llm_local.py against a local ollama daemon over its REST API (/api/generate, /api/tags).
- Install ollama and start it (
ollama serve). - Pull at least one model (for example
ollama pull llama3,ollama pull gemma3, orollama pull qwen2). - In Settings → AI, set Provider to
local. - Enter the model tag (e.g.
llama3) in the Local model field — this writes to the separateai_local_modelkey introduced by IMPL-AI11 so the model id cannot drift intoai_tone. A one-shot migration heuristic (looksLikeModelId) moves a misplaced value fromai_toneintoai_local_modelon first load. - Click Load models to pull the available-models list from the running ollama instance, or enter the tag manually.
- Click Save.
No API key is required for local; Test key and Store key are hidden for this provider.
One provider failing no longer costs you the run. Under Settings → AI, the
If that fails, try… section adds ordered fallback providers beneath the one you
selected. Leave it empty and resmon behaves exactly as it did before chains existed —
the stored chain is written only once you add a fallback, and a single-provider
configuration is read as a one-lane chain.
What separates a chain from a retry loop is that not every failure means the same thing:
| Failure | Examples | What resmon does |
|---|---|---|
| Lane-fatal | rejected key, exhausted quota, model does not exist, provider unreachable | Retires that provider for the rest of the run and moves to the next |
| Document-local | abstract past the context window, content declined, a one-off 5xx |
Falls through for that paper only; the provider stays primary |
Both mistakes are expensive in opposite directions. Re-presenting a rejected key once per paper burns an entire run rediscovering the same fact; abandoning a working provider because one abstract was too long silently downgrades every summary after it.
Every attempt lands in execution_ai — one row per lane, with the provider, the model,
the credential alias (never the value), how many papers it summarized, and the
classified reason it stopped. A lane that was never reached is recorded as skipped
rather than omitted, because "not needed" and "not configured" are different facts. The
report header names the provider that actually produced the summaries, not the one
configured first.
resmon can run the agent CLI you already installed and signed into, so AI work is billed
to your existing Claude Max or ChatGPT plan instead of a metered API key. Add
Claude Code (your Claude plan) or Codex (your ChatGPT plan) in the If that fails, try…
chain. resmon drives the command; it never embeds provider sign-in, never sees your
credential, and never authenticates on your behalf. If the CLI is not signed in, the lane
reports that and stands down — it is not worked around.
Where resmon looks for the command, in order:
- A full path you set in the lane's command-path box. Always wins.
- Known install locations for your platform —
~/.local/bin/claude, and for Codex the copy insideChatGPT.app. PATH, last.
That order is deliberate and the reason is worth stating. A macOS app launched from the
Finder inherits /usr/bin:/bin:/usr/sbin:/sbin, because launchctl getenv PATH is unset
— and neither CLI is there. claude lives under a user-local prefix and codex lives
inside an application bundle, on no PATH at all. A PATH-first search works perfectly
in a terminal and fails in the installed app, so PATH is the last resort rather than the
first. Settings → AI shows which of the three found your command, and lists the paths it
searched when it found nothing.
Two things to know before you select it.
It is the recommended route as of 1.8.5, because batching made it affordable. Papers go five at a time in one call rather than one session each, and measured against the same abstracts one at a time, a paper costs 0.33× as much and 0.23× the input-side tokens — the constitution and the prompt scaffold are about 5,600 tokens paid once per call instead of once per paper.
It still spends the same usage window you use for your own work, so the lane carries a
per-run limit of 50 papers by default, editable per lane. Reaching the limit is not an
error: the lane stands down, the remaining papers go to the next lane, and execution_ai
records the cap as the reason. A 200-paper sweep is still worth routing somewhere else.
Summaries are extracted through each CLI's structured output — --output-format json for
Claude Code, -o for Codex — rather than by scraping console prose. A batched call adds a
schema (--json-schema for Claude Code, --output-schema for Codex) describing one entry
per paper. The schema deliberately does not pin the number of entries: both CLIs
enforce an exact count by making the model invent the missing summary, which was
measured rather than assumed. resmon checks the count and the numbering itself, and a
paper the batch did not answer for is simply re-sent on its own. If the numbering is
inconsistent — the same paper answered twice, or a number outside the batch — the whole
batch is discarded and re-sent one paper at a time, because a summary attached to the
wrong paper is a quieter failure than none at all.
When the structured route yields nothing usable, resmon says the CLI returned something we could not use and moves on. It never stores a salvaged fragment as though it were a real summary.
Each call runs in a fresh empty directory with tools switched off (--tools "") or the
sandbox pinned read-only (-s read-only). Abstracts are untrusted text fetched from the
internet and an agent CLI can run commands, so the summarizer is given nothing to run and
nothing to read.
Choosing a model and an effort level. A subscription lane offers both, and
the two commands can honestly offer different things. codex debug models
reports a real catalog — the models it lists, and per model the reasoning
levels that model supports — so resmon shows that catalog and offers only the
levels codex says each model takes. claude has no models-listing command, so
what resmon offers is the aliases its --help documents (fable, opus,
sonnet, haiku), labelled as exactly that: names the command accepts, not a
list of models your account can reach, which resmon has not checked. Both
dropdowns keep a saved value reachable even when the catalog does not list it,
because either command may accept names resmon cannot enumerate.
Effort is offered only where effort exists. The two agent CLIs take a
reasoning-effort level (--effort for Claude Code, model_reasoning_effort
for Codex). None of the eight API-key providers has an equivalent, so no
effort control is shown for them — a knob that silently did nothing for most
providers would be worse than no knob. Where a provider does have a thinking
control, it will be offered per provider once each has been verified. Leaving
effort unset means the command's own default, which for Codex is whatever your
~/.codex/config.toml sets: resmon will not override a preference you set for
yourself.
"Where is the command?" is behind Advanced, and opens by itself when
detection failed — which is the only time it matters. Browse… beside it
opens a file picker rather than asking you to type a path from memory. It shows
hidden files and lets you open an application bundle, because that is where both
commands live: claude under ~/.local, which the Finder hides, and codex
inside ChatGPT.app, which macOS treats as a single file.
Ollama is the natural last lane: it needs no key and costs nothing. Note honestly that resmon has no summarizer beyond the lanes you configure. If every lane fails, those papers have no AI summary and the execution says why — there is no hidden extractive fallback underneath.
A routine can carry its own chain through the chain key in its per-execution AI
override, exactly like provider or model.
Deep Dive and Deep Sweep expose an Override AI settings for this run disclosure that accepts per-execution overrides for length, tone, and model. Empty override fields fall through to the persisted ai_* settings. Overrides flow through the request body of POST /api/search/dive / POST /api/search/sweep and are consumed by SweepEngine for that one execution only.
Email notifications are transactional SMTP messages emitted by email_notifier.py + email_sender.py on routine completion. They are opt-in at two levels: the global SMTP configuration lives under Settings → Email, and each routine owns its own per-routine Email and Results-in-Email toggles that gate whether that specific routine notifies on completion and whether the AI summary is inlined in the body.
Open Settings → Email (#/settings/email) and fill in the transport fields:
| Field | Purpose |
|---|---|
smtp_server |
Outbound SMTP host (e.g. smtp.gmail.com, smtp.office365.com). |
smtp_port |
Port (587 for STARTTLS, 465 for implicit TLS, 25 for plain). |
smtp_username |
Authenticating username. |
smtp_from |
From: address on outgoing notifications. |
smtp_to |
Default recipient address used by routines. |
Press Save to persist the transport fields through PUT /api/settings/email.
The SMTP password is stored separately from the transport fields, in the OS keychain under the credential name smtp_password:
- Type the password into the password field.
- Click Store password. The frontend strips whitespace from the input before sending (so a Gmail App Password — which Google's UI presents as four space-separated groups — becomes the raw 16-character secret) and issues
PUT /api/credentials/smtp_password. - The UI afterwards sees only a presence boolean from
GET /api/credentials; the password itself is never returned. - Use Remove password (
DELETE /api/credentials/smtp_password) to clear it.
Click Send test email to issue POST /api/settings/email/test. The backend composes a minimal test message, binds to the stored SMTP server, authenticates with the stored credential, and returns a success or Error: status line that is surfaced inline in the panel.
When SMTP is configured, a routine emits notifications only when its Email toggle is on. Two additional per-routine flags refine the behavior:
- Results-in-Email — inlines the AI summary inside the email body.
- Notify-on-Completion — independent desktop notification raised on fire completion; it does not depend on SMTP.
The execution bundle produced by the shared export pipeline (/api/executions/export) is attached to the email when the total size is within the configured limit.
Settings → Storage → Backup and restore. A backup is one folder holding everything resmon keeps for you locally, and a restore puts it back — on this machine or another one.
A bundle (resmon-backup-<UTC stamp>/) contains:
resmon.db— your database, produced with the SQLite backup API from the live connection rather than copied. resmon runs SQLite in WAL mode, so a file copy would miss whatever is still inresmon.db-wal; the snapshot is consistent by construction and has no sidecars.vault/— every byte in your Library vault, plus itsvault.jsonmarker. Each file is re-hashed on the way out and compared with the hash the catalog recorded. A file that no longer matches stops the backup, naming the file: the database and the vault are a pair, and a bundle that certified a corrupt vault would be worse than no bundle.reports/— the report tree, if you leave the checkbox on.manifest.json— the app and database-schema versions that wrote it, the vault id, a row count per table, every file with its size and SHA-256, and the list of what was deliberately left out.
What a backup does not contain: no credential value, ever. Not your SMTP password, not a provider API key, not a webhook signing secret, not the Google Drive token. The manifest records their names so that after a restore resmon can tell you exactly which ones to enter again; the values stay in your OS keyring and nowhere else. The daemon lock, the port file and the API token are not in a backup either — they describe a running process, not your work.
Restoring takes a restart. Restore from backup… checks a folder — every hash recomputed, the schema version compared with this app's, the vault compared with the one you have — and shows you the report. Only if you then choose Restart to restore is anything scheduled, and even then nothing changes until resmon next starts. On that start, before the database is opened, resmon moves your current database aside into restore-undo/ (it is kept until you delete it yourself, from Settings → Storage — nothing removes it on its own, and taking another backup does not), puts the bundle's database in place, restores the vault bytes, resets the rows that describe a process that no longer exists, rebuilds the search index, runs any migrations the bundle needs, and checks integrity. A Library vault the restore replaces is moved into that same folder rather than deleted, so any failure puts your old database and your old vault back — both halves of the pair, and one Delete undo copies removes both. Where there was no vault at the destination, a failed restore removes the one it had just written, so the folder you chose is left as empty as it was found. The single exception is a copy between drives interrupted while it was removing the original: resmon then keeps the complete copy it had made, says where it is, and leaves it for you rather than deleting it — so after a failed restore, read what Settings shows before you delete an undo copy. A vault on a different drive from resmon's state folder is copied rather than moved, which needs room for a second copy of your retained files while the restore runs.
The verify report says where your Library vault would go — the folder the backup's own database recorded — and whether that folder exists and is writable on this machine. It usually is not, on a different machine, so Restore the vault somewhere else… lets you choose a folder here instead; resmon puts the vault under it, keeping the resmon-library-<id> name, and updates the database to match. It refuses a folder that is missing, is a file, cannot be written to, is inside the backup itself, or already holds a different vault, and it refuses it while you are still choosing rather than on the next start.
resmon refuses, with the reason: a bundle whose files do not match its manifest; a bundle written by a newer resmon than the one you are running (update first); and a vault directory that belongs to a different vault — it will not overwrite someone else's retained files.
If your database contains references to rows that are not there, the backup still runs and records them in the manifest; the verify report lists them and asks you to tick Restore anyway, keeping these rows as they are before the restore can be staged. The count is of references rather than rows, because that is what SQLite's own check reports — one row with two broken links is counted twice — and the row count is shown beside it, so you can see how many rows that actually is. A backup taken by an earlier resmon has no row count in it, and resmon shows the reference count alone rather than inventing one. Refusing to back such a database up would leave you with no backup at all, which is the worse failure.
A backup from an older resmon restores too: the migrations run on the restored database before anything else touches it, so a bundle written by v2.2.0 comes forward to today's schema on the way in.
After a restore, Settings → Storage shows a one-time card listing the credentials the backup recorded and this machine does not have. Webhook signing secrets are bound to a delivery target's row id, so a target that came back under a different id has no secret until you set one; the card says so.
The bundle format is documented in docs/backup.md for anyone restoring by hand.
resmon has exactly one way to send data off the machine, and it is off until the user turns it on: optional Google Drive backup of the report tree, under Settings → Cloud Storage. It is not the backup described above: it uploads resmon_reports/ only, and has no restore counterpart.
This surface is a thin wrapper over the Drive v3 API using the least-privilege drive.file OAuth 2.0 scope, meaning resmon can only see files it created itself. The OAuth client secrets are user-supplied and live in credentials.json at the project root (gitignored).
- Create an OAuth 2.0 client of type Desktop app in Google Cloud Console, enable the Drive API, and download the client secrets JSON to the project root as
credentials.json. - Open Settings → Cloud Storage (
#/settings/cloud). - Click Link Google Drive. The frontend issues
POST /api/cloud/link; the backend runs the OAuth installed-app flow (google-auth-oauthlib), prompts for consent in the default browser, receives the access + refresh tokens, and stores them in the OS keyring.GET /api/cloud/statusthen returns{ is_linked: true, api_ok: true }. - Toggle Auto-backup to persist
cloud_auto_backupthroughPUT /api/settings/cloud. When on,SweepEngine._maybe_auto_backupuploads the execution bundle to the linked Drive folder at the end of every fire. - Click Back up now (
POST /api/cloud/backup) for an ad-hoc backup. The response{ uploaded, total_files, folder_name, web_view_link }powers the banner and the "open in Drive" link. - Click Unlink (
POST /api/cloud/unlink) to revoke the stored token.
Drive API errors are surfaced with targeted hints via API_REASON_HINTS for accessNotConfigured, insufficientPermissions, and no_token, so the user can resolve Google Cloud Console / scope-consent issues directly from the panel.
resmon is released under the MIT License. Copyright (c) 2026 Ryan Kamp. The full license text is available in LICENSE at the project root.
In short, the MIT License permits use, copying, modification, merging, publication, distribution, sublicensing, and sale of the software, subject to the condition that the copyright notice and permission notice be included in all copies or substantial portions of the software. The software is provided "as is," without warranty of any kind.
resmon's installers redistribute one component whose published package carries no licence file of its own — sqlite-vec 0.1.9, the loadable SQLite extension behind semantic search. Its MIT and Apache-2.0 texts are reproduced verbatim in docs/third-party-notices.md, because a wheel that ships a native binary and no notice would otherwise be redistributed without one. Every other dependency ships its own licence inside its own distribution and the installer copies it along with the package.
Contributions are welcome — see CONTRIBUTING.md for the short version, docs/adding-a-source.md for adding a scholarly source, and AGENTS.md if you are working this repository with an AI harness. resmon is a single-maintainer project, so the workflow below is deliberately lightweight; please open an issue before starting substantial work so the scope can be scoped and any related changes in the .ai:/prep/ planning documents can be coordinated.
The fastest path is the in-app About resmon → Issues tab, which builds either a pre-populated mailto: link to the maintainer or a pre-populated GitHub issue deep link from a single form (no credentials are stored or transmitted by the app — the user reviews and sends in their own email client or on GitHub). Alternatively, open a GitHub issue directly at https://github.com/ryanjosephkamp/resmon/issues/new with:
- A short, specific title.
- The resmon version (git commit hash or packaged build number), host OS, Python version (
python3 --version), and Node.js version (node --version). - Reproduction steps — the page, the operation, the exact form input, and the observed error.
- Relevant log excerpts. Per-execution logs live under
resmon_reports/logs/and the rotating app log isresmon_reports/logs/resmon.log. Redact any API keys or personal data before pasting. - A screenshot of the UI state when the bug is visual.
Security-sensitive reports (credential handling, SQL injection, OAuth flow, keyring access) should not be filed as public issues. Email the maintainer directly instead.
Per-update release notes are republished verbatim to the public resmon blog at https://ryanjosephkamp.github.io/resmon/ (source under docs/_posts/, served by GitHub Pages). The same posts are also readable inside the app from the About resmon → Blog tab, which fetches the Atom feed and renders each post in an origin-locked Electron <webview>.
The Settings → Advanced → Danger Zone section centralizes every destructive maintenance action behind a two-tier confirmation gate: the two API-key wipes (repository keys, AI provider keys) use a simple OK / Cancel modal because they are recoverable by re-entering the keys, while the six data / settings destructions (delete all executions, delete all saved configurations, delete all routines, reset all settings, full reset, factory reset) require typing the literal word CONFIRM into a text input before the destructive button enables.
- Fork the repository and create a topic branch off
main(feat/<short-slug>,fix/<short-slug>, ordocs/<short-slug>). - Keep the change focused — one logical unit per pull request.
- Match the existing code style. Python follows PEP 8 with 4-space indentation; TypeScript/TSX follows the repository's Prettier defaults. Do not reformat unrelated files.
- Run the verification suites before pushing:
- Backend:
pytest resmon_scripts/verification_scripts/ - Frontend type check:
cd resmon_scripts/frontend && npm run typecheck - Frontend renderer tests:
cd resmon_scripts/frontend && npm test - Journey suite (the real app, one test per user journey):
cd resmon_scripts/frontend && npm run build && npm run journeys. It can also be pointed at another build; seeresmon_scripts/frontend/journeys/README.md.
- Backend:
- If the change touches a repository client, add or update the corresponding test under
resmon_scripts/verification_scripts/and the matching fixture. New repositories additionally require a row inrepo_catalog.pyand a registration inapi_registry.py. - If the change alters user-visible behavior, update the affected page info document under
resmon_reports/info_docs/and, where relevant, this README. - Open the pull request against
main. The description must explain what changed, why it changed, and how the change was verified. Link the originating issue.
Commit messages follow a concise, imperative style (Add OpenAlex rate-limit fallback, not Added or Adds). Multi-line bodies are encouraged for non-trivial changes; reference the issue number in the body rather than the subject line.
Be respectful and constructive. Focus feedback on the code and the technical trade-offs, not the contributor.
resmon is built on top of a broad ecosystem of open-access scholarly repositories and open-source software. The project gratefully acknowledges:
The scholarly sources registered in the repository catalog, whose public APIs make automated literature surveillance possible:
- arXiv — Cornell University / arXiv.org, for the Atom XML API and the decades-long commitment to open preprint distribution in physics, mathematics, computer science, quantitative biology, statistics, electrical engineering, and economics.
- bioRxiv and medRxiv — openRxiv, for the date-range JSON API serving the life- and health-sciences communities.
- CORE — The Open University / Jisc, for the aggregated open-access JSON API spanning tens of thousands of repositories worldwide.
- CrossRef — Crossref, for the DOI-indexed REST API and the "polite pool" that rewards well-behaved clients with priority rate limits.
- DataCite — DataCite, for the public DOI metadata REST API. Metadata from DataCite.
- DBLP — Schloss Dagstuhl / University of Trier, for the computer-science bibliography REST API.
- Dryad — Dryad, for the dataset search API and its ORCID-bearing author records.
- DOAJ — Directory of Open Access Journals / Infrastructure Services for Open Access C.I.C., for the journal- and article-level JSON API covering open-access journals across disciplines.
- ERIC — Institute of Education Sciences, U.S. Department of Education, for the public education-research API.
- EuropePMC — EMBL-EBI on behalf of the Europe PMC Consortium, for the biomedical and life-sciences REST API.
- GovInfo — U.S. Government Publishing Office, for the GovInfo Search Service and its official bibliographic fields.
- HAL — CCSD / CNRS, for the Solr-backed multi-disciplinary JSON API.
- INSPIRE-HEP — CERN and the INSPIRE collaboration, for the curated high-energy-physics literature API.
- NASA ADS — Smithsonian Astrophysical Observatory / NASA Astrophysics Data System, for the Solr-backed astronomy, astrophysics, and planetary-science API.
- NDL Search — National Diet Library, Japan, for the SRU open-metadata API. Powered by NDL Search API. Metadata provided by the National Diet Library and its contributing databases and institutions.
- NIST Resource Metadata Management — National Institute of Standards and Technology, for the RMM paper-search API. Data created by NIST (National Institute of Standards and Technology).
- OAPEN Library — OAPEN Foundation, for the DSpace REST search API over open-access books and chapters, under OAPEN's CC0 metadata dedication.
- OpenAIRE — the OpenAIRE partnership, for the multi-disciplinary scholarly-graph Search API.
- OpenAlex — OurResearch, for the free, comprehensive scholarly-works REST API and the mailto-based polite-pool rate tier.
- Open Library — Internet Archive, for the public work-metadata search API.
- OSTI.GOV — Office of Scientific and Technical Information, U.S. Department of Energy, for the public research-record API. Courtesy of OSTI.GOV, U.S. Department of Energy.
- PLOS — Public Library of Science, for the Solr-backed JSON API over the PLOS journal family.
- PubMed / NCBI E-utilities — U.S. National Library of Medicine / National Center for Biotechnology Information, for the E-utilities suite that underpins biomedical literature retrieval.
- Semantic Scholar — Allen Institute for AI (AI2), for the cross-disciplinary scholarly-graph REST API.
- Springer Nature — Springer Nature, for the Meta API covering STM, humanities, and social-sciences content.
- Zenodo — CERN and OpenAIRE, for the multi-disciplinary research-output records API.
resmon depends on and is grateful for the following open-source projects (non-exhaustive): Python, FastAPI, Starlette, Uvicorn, Pydantic, httpx, lxml, BeautifulSoup, SQLAlchemy, APScheduler, cryptography, keyring, NLTK, tiktoken, pytest, Electron, Node.js, React, React Router, TypeScript, Webpack, FullCalendar, electron-builder, and ollama. The maintainers and contributors of these projects make a desktop-class literature surveillance tool buildable by a single developer.
resmon relies on the DOI system administered by the International DOI Foundation and the ORCID identifier system — both of which underpin the deduplication and citation-graphing pipeline.
