Tutorial · about 20 minutes
Get started
Create a library, add a few papers, pull in full text, extract claims, then search, cite, and annotate them. Every step shows the command you type in Claude Code and what comes back.
Prerequisites
| Need | Why |
|---|---|
| Claude Code | Every /ref:* command is a Claude Code slash command. Commands that need judgment (extraction, synthesis) start subagents inside your session. |
| ref-manager plugin | The repo ships .claude-plugin/plugin.json (name ref-manager, version 0.1.0). The repo doesn't document an install method or a marketplace, so install it as a Claude Code plugin from this folder. The commands then show up as /ref:<name>. |
| Python ≥ 3.11 | The scripts are standard-library-only, single-file Python (PEP 723 headers declare requires-python = ">=3.11"). The command files run them as python3. |
| PubMed MCP connector | /ref:add resolves metadata through mcp__claude_ai_PubMed__get_article_metadata. /ref:fetch tries get_full_text_article, and /ref:extract uses the metadata call again to check retraction status. You can't ingest papers without it. |
| pandoc (optional) | Converts genuine JATS XML to Markdown (pandoc -f jats -t gfm). If pandoc is missing, that step reports unavailable. |
| uv (optional) | Publisher HTML goes through trafilatura, which runs in a separate worker under uv run. That keeps the dependency out of the scripts' own environment. |
| anydoc (optional) | Converts PDFs for /ref:attach. If anydoc is missing, the PDF is still stored and reported as PDF conversion unavailable. No substitute converter runs. |
| pdftotext (optional) | From poppler. /ref:attach uses it to read a PDF's first two pages and check the PDF matches the paper. Without it, the check can't pass, and attaching needs --force. |
PLAN D16 names firecrawl as a fallback for JavaScript-heavy publisher pages. convert.py doesn't call it, though. If you want to use it, the /ref:fetch command file suggests running the firecrawl skill yourself to get the HTML.
About the examples
99000001–99000003) are made-up fixtures with numbers outside PubMed's range. In real use, /ref:add gets metadata from PubMed. To show real output offline, this walkthrough gave add.py the same JSON envelope the command builds from the MCP response. Output marked output from a scratch library comes from actually running the scripts, with HOME pointed at a temporary folder. The library path is shortened to ~/Research/library. Output marked illustrative output depends on PubMed, the network, or a subagent, and follows the format in the script source.-
Create the library with
/ref:initYou pick the location. The plugin never picks a default.
/ref:initcreates the folder skeleton and saves the root in~/.config/ref-manager/config.json. After that, no other command needs a--repopath: they read it from the config, and they stop with an error if no library is configured.output from a scratch library
› /ref:init ~/Research/library library initialized at ~/Research/library config written to ~/.config/ref-manager/config.json
This is exactly what
init_repo.pycreates. Every folder starts empty, andlog.mdcontains just its heading.library/ ├── exports/ │ └── papers/ # Papers.app handoff batches ├── grants/ ├── graph/ # concepts.jsonl, relations.jsonl ├── index/ │ ├── .locks/ # per-PMID mutation locks │ └── aliases/ ├── labs/ ├── log.md ├── okf/ # generated OKF views ├── papers/ # one folder per PMID ├── people/ ├── projects/ ├── queries/ # saved PubMed searches ├── reports/ └── studies/
Running it a second time is refused (
a library is already configured at …) unless you pass--force. That protects you from repointing every command at a different folder by accident. -
Check it with
/ref:statusThis command only reads. It prints the configured root, how many papers the SQLite catalog holds, and how many projects exist.
output from a scratch library
› /ref:status library: ~/Research/library catalog: not built yet (run /ref:index --rebuild) projects: 0
The catalog is a projection you can rebuild from the files, so it's normal for it not to exist yet. Retrieval commands such as
/ref:askrebuild it before every query. -
Add papers with
/ref:addGive it one or more PMIDs. Claude fetches their metadata from the PubMed connector in a single call, normalizes each article into a JSON envelope (title, abstract, authors in published order, journal, year, DOI, PMCID, indexed grants), and passes the envelope to
add.py.output from a scratch library
› /ref:add 99000001 99000002 99000003 99000001: added (citekey=moreno2021cortical) 99000002: added (citekey=moreno2021longitudinal) 99000003: added (citekey=lindqvist2022commentary) — no abstract available — metadata-only record, not fabricated (§3a)
- The PMID is the paper's identity. Each paper gets
papers/<pmid>/, and duplicates are detected by PMID only. If a new PMID has the same DOI as a paper you already have, both records are kept and a warning is printed. They are never merged. - Citekeys are built from first author, year, and first title word (
moreno2021cortical). They're assigned under a library lock, get a suffix if they collide, and never change afterwards, so manuscripts citing them won't break. - Abstract tier. A paper with an abstract gets
extraction_tier: "abstract". Claims can be extracted from the abstract alone, without full text. - Metadata-only. Paper
99000003has no abstract, so its tier is"unavailable". The record is still valid, and nothing is invented to fill the gap. - One result per PMID. Each line reads
added,already_present, orfailed. One paper failing doesn't stop the others, and re-adding a paper is harmless:
output from a scratch library
› /ref:add 99000001 99000002 99000003 99000001: already_present (citekey=moreno2021cortical) 99000002: already_present (citekey=moreno2021longitudinal) 99000003: already_present (citekey=lindqvist2022commentary)
Each paper folder now holds
meta.json,authorship.json(authors in their original order),funding.json, andraw/<sha256>/response.json, a copy of the metadata exactly as received that is never modified. - The PMID is the paper's identity. Each paper gets
-
Create a project and queue papers
Projects hold the context around papers: your research questions, why each paper matters, and how far you've read it. The same paper can be in several projects, and each project keeps its own relevance, priority, and reading state for it.
output from a scratch library
› /ref:project create thesis-ch3 --scope "Cortical thickness in autism" { "slug": "thesis-ch3", "scope": "Cortical thickness in autism", "questions": [] } › /ref:project add-question thesis-ch3 --id q1 --text "Is cortical thickness altered in autistic adults?" › /ref:project add-paper thesis-ch3 99000001 --relevance "Core adult evidence" --priority 1 { "pmid": "99000001", "relevance": "Core adult evidence", "priority": 1, "reading_status": null, "why_saved": null, "screening": null, "added_at": "2026-09-15T10:15:59.793233+00:00" } › /ref:project add-paper thesis-ch3 99000002 --reading-status to_read
Now update where you are with a paper. Reading states are
to_screen,to_read,reading, andread. Only you can set them: fetching full text or extracting claims never marks a paper as read.output from a scratch library
› /ref:queue set --project thesis-ch3 --pmid 99000001 --status reading --why "Largest adult sample" { "added_at": "2026-09-15T10:15:59.793233+00:00", "pmid": "99000001", "priority": 1, "reading_status": "reading", "relevance": "Core adult evidence", "screening": null, "why_saved": "Largest adult sample" } › /ref:queue set --project thesis-ch3 --pmid 99000003 --status to_read error: pmid '99000003' is not a member of project 'thesis-ch3' (add it first via /ref:project)
Project slugs are names you choose: lowercase letters, digits, and hyphens. If a slug is already taken, the command refuses and tells you which project has it, rather than adding a suffix, because you can just pick another name.
/ref:queue show --project thesis-ch3lists the whole queue. -
Get full text with
/ref:fetchor/ref:attachFull text is optional, and you get it one paper at a time.
/ref:fetchtries these sources in order: open-access PMC text from the PubMed connector, then Unpaywall (only if the config has anunpaywall_email), then publisher HTML that Claude fetches, and finally abstract-only. If you already have a PDF, use/ref:attach <pmid> <path>instead.output from a scratch library
› /ref:fetch 99000001 99000003 99000001: acquired (source=plain_text) diagnostic: source has no markup (plain extracted text); section/heading structure and figures are not recoverable 99000003: abstract_only -- no full text available from any source (§6) -- abstract-only stays first-class
- The PubMed full-text tool returns plain text, not JATS XML. So it goes through the
plain_textpath, and the diagnostic tells you section structure was lost. You get a note instead of a silently mangled conversion. - The downloaded source is saved unchanged under
raw/<sha256>/. The converted Markdown goes into a newversions/<id>/source.mdnext to itsmanifest.json. abstract_onlyisn't an error. The paper keeps its abstract-tier claims, clearly labelled as such.- Other possible results are
oa_location_found(Unpaywall found a PDF you can download and pass to/ref:attach) andfailed.
/ref:attachreportsattached,duplicate_noop(those exact bytes are already stored),refused, orfailed.refusedmeans neither the DOI nor a run of title words appeared in the PDF's first two pages. Add--forceonly if you're sure the PDF belongs to that PMID. The thesis-chapter tutorial shows real attach output. - The PubMed full-text tool returns plain text, not JATS XML. So it goes through the
-
Extract claims with
/ref:extractExtraction needs reading judgment, so Claude starts one
ref-extractorsubagent per PMID, all running in parallel. Each one reads the full text if it exists, or the abstract otherwise. It returns a study type and a list of claims: population, intervention, comparator, outcome, timepoint, direction, effect value and measure, uncertainty interval, study design, cohort, and adjustment context. Each claim includes a locator and a word-for-word quote. Any field the text doesn't state is set to"unknown"rather than guessed.extract.pythen validates the claims, assigns stable IDs, and saves each paper as its own version.output from a scratch library · claims written by hand in the subagent's output shape
› /ref:extract 99000001 99000002 # spawns 2 ref-extractor subagents in parallel, then: 99000001: extracted (study_type=imaging, claims=1) 99000002: extracted (study_type=cohort, claims=1)
- The tier only goes up. 99000001 had full text, so its
extraction_tieris now"full". A later run with less text won't downgrade it. 99000002 stays"abstract". - Claim IDs stay stable. When you re-extract, an unchanged claim keeps its
claim_id. A claim whose content changed gets a new ID, and the old one is kept, markedsuperseded_by, not deleted. - Retraction status is saved on
meta.jsonwith its source andchecked_at. If the PubMed check fails or is ambiguous, it's saved asunknown, never asnone.
- The tier only goes up. 99000001 had full text, so its
-
Find things with
/ref:searchand/ref:ask/ref:searchis a plain substring search. With--scope evidence(the default) it checks title, abstract, and journal.output from a scratch library
› /ref:search --q "cortical thickness" { "query": "cortical thickness", "scope": "evidence", "results": [ { "kind": "evidence", "pmid": "99000001", "citekey": "moreno2021cortical", "field": "title", "snippet": "Cortical thickness in autistic adults: a multi-site MRI study" }, { "kind": "evidence", "pmid": "99000002", "citekey": "moreno2021longitudinal", "field": "title", "snippet": "Longitudinal change in cortical thickness across adolescence in autism" } ] }
/ref:askanswers a question from the claims and passages you've saved. There are no embeddings, so Claude first thinks of 2–4 related terms (synonyms, MeSH terms) and passes them as--expand.ask_retrieve.pythen rebuilds the SQLite FTS5 catalog and returns ranked candidates, limited to the papers you selected. This retrieval half ran for real:output from a scratch library · retrieval step
› /ref:ask "Is cortical thickness altered in autistic adults?" --project thesis-ch3 # retrieval: ask_retrieve.py --expand "cortical thinning" "autism spectrum disorder" --project thesis-ch3 "candidates": [ { "kind": "claim", "pmid": "99000001", "claim_id": "c-d85223adbc65", "locator": "Results", "text": "Autistic adults showed increased cortical thickness in frontal regions (Cohen's d = 0.41, 95% CI 0.15-0.67).", "evidence_tier": "full", "citekey": "moreno2021cortical", "retraction_status": "none" }, { "kind": "claim", "pmid": "99000002", "claim_id": "c-2b31957bd938", "locator": "abstract", "text": "cortical thinning was accelerated in the autism group", "evidence_tier": "abstract", "citekey": "moreno2021longitudinal", "retraction_status": "unknown" } ], "report": { "candidate_budget": 30, "token_budget": 6000, "total_matches_before_limits": 2, "returned": 2, "truncated_by_candidate_budget": false, "dropped_for_token_budget": 0, "insufficient_coverage": false, "selector_constrained": true }
Next, a single
ref-synthesizersubagent writes the answer from those candidates, citing each sentence as[^pmid].validate_citations.pythen checks that every citation points to a PMID in the candidate set. Any citation that doesn't gets reported to you, not quietly dropped.illustrative output
One multi-site study reports thicker frontal cortex in autistic adults [^99000001]; an adolescent cohort reports accelerated thinning [^99000002], which is a different age group and outcome. {"cited_pmids": ["99000001", "99000002"], "resolved": ["99000001", "99000002"], "unresolved": [], "all_resolved": true}
/ref:askdoesn't save anything. If you want a saved answer you can refresh later, use/ref:brief. -
Cite while writing, export the bibliography
/ref:citegives you a paper's inline citekey. It just readsmeta.json.output from a scratch library
› /ref:cite 99000001 @moreno2021cortical
/ref:exportwrites BibTeX and CSL-JSON for any selection of papers. The--batchlabel names the folderexports/<label>/. The list of papers is fixed when the batch is first created: running the same label again reuses that list, and only--refreshrebuilds it and reports what was added or removed.output from a scratch library
› /ref:export --project thesis-ch3 --batch thesis-ch3-v1 { "status": "created", "batch": "thesis-ch3-v1", "manifest": { "batch": "thesis-ch3-v1", "selector_expression": "--project thesis-ch3", "pmids": ["99000001", "99000002"], "citekeys": ["moreno2021cortical", "moreno2021longitudinal"], "resolved_at": "2026-09-15T10:16:01.112303+00:00", "report": { "count": 2, "by_extraction_tier": { "abstract": 1, "full": 1, "unavailable": 0, "missing_record": 0 }, "by_human_verification_state": { "not_yet_tracked": 2 }, "by_retraction_errata_status": { "none": 1, "unknown": 1 } } } }
exports/thesis-ch3-v1/references.bibnow contains:@article{moreno2021cortical, title = {Cortical thickness in autistic adults: a multi-site MRI study}, author = {Moreno, Ana and Baptiste, Luc}, journal = {Example Journal of Neuroimaging}, year = 2021, doi = {10.0000/example.1}, pmid = {99000001} } @article{moreno2021longitudinal, title = {Longitudinal change in cortical thickness across adolescence in autism}, author = {Moreno, Ana and Okafor, Chidi}, journal = {Example Developmental Neuroscience}, year = 2021, doi = {10.0000/example.2}, pmid = {99000002} }The report splits the papers by tier and retraction status, so you can see what backs the bibliography before you send it anywhere.
-
Keep your own thoughts with
/ref:notepapers/<pmid>/notes.mdbelongs to you./ref:noteis the only command that writes to it, it only ever appends dated entries, and nothing that regenerates files will overwrite it.output from a scratch library
› /ref:note 99000001 "Frontal effect only; check whether the adolescent cohort replicates it." appended to ~/Research/library/papers/99000001/notes.md › /ref:note 99000001 --show --- 2026-09-15T10:16:00.668445+00:00 Frontal effect only; check whether the adolescent cohort replicates it.
Notes are kept out of the evidence index.
/ref:search --scope notesfinds them and labels them as personal, so they can never be mistaken for a published finding:output from a scratch library
› /ref:search --scope notes --q "replicates" { "query": "replicates", "scope": "notes", "results": [ { "kind": "personal_note", "pmid": "99000001", "snippet": "---\n2026-09-15T10:16:00.668445+00:00\n\nFrontal effect only; check whether the adolescent cohort replicates it." } ] }
What you built
Run /ref:status again and it reports papers indexed: 3 and projects: 1. The figure shows which parts of the library each step wrote to. Folders these steps didn't use are faded.
index/catalog.sqlite is a projection that /ref:index --rebuild recreates. Downloaded sources in raw/ are never edited, and every conversion or extraction is saved as a new version next to them.What's next
/ref:* command with its arguments and the files it writes.