ref-manager

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

NeedWhy
Claude CodeEvery /ref:* command is a Claude Code slash command. Commands that need judgment (extraction, synthesis) start subagents inside your session.
ref-manager pluginThe 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.11The 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

Fictional papers. The three papers below (PMIDs 9900000199000003) 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.
  1. Create the library with /ref:init

    You pick the location. The plugin never picks a default. /ref:init creates the folder skeleton and saves the root in ~/.config/ref-manager/config.json. After that, no other command needs a --repo path: 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.py creates. Every folder starts empty, and log.md contains 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.

  2. Check it with /ref:status

    This 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:ask rebuild it before every query.

  3. Add papers with /ref:add

    Give 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 99000003 has 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, or failed. 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, and raw/<sha256>/response.json, a copy of the metadata exactly as received that is never modified.

  4. 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, and read. 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-ch3 lists the whole queue.

  5. Get full text with /ref:fetch or /ref:attach

    Full text is optional, and you get it one paper at a time. /ref:fetch tries these sources in order: open-access PMC text from the PubMed connector, then Unpaywall (only if the config has an unpaywall_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_text path, 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 new versions/<id>/source.md next to its manifest.json.
    • abstract_only isn'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) and failed.

    /ref:attach reports attached, duplicate_noop (those exact bytes are already stored), refused, or failed. refused means neither the DOI nor a run of title words appeared in the PDF's first two pages. Add --force only if you're sure the PDF belongs to that PMID. The thesis-chapter tutorial shows real attach output.

  6. Extract claims with /ref:extract

    Extraction needs reading judgment, so Claude starts one ref-extractor subagent 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.py then 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_tier is 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, marked superseded_by, not deleted.
    • Retraction status is saved on meta.json with its source and checked_at. If the PubMed check fails or is ambiguous, it's saved as unknown, never as none.
  7. Find things with /ref:search and /ref:ask

    /ref:search is 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:ask answers 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.py then 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-synthesizer subagent writes the answer from those candidates, citing each sentence as [^pmid]. validate_citations.py then 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:ask doesn't save anything. If you want a saved answer you can refresh later, use /ref:brief.

  8. Cite while writing, export the bibliography

    /ref:cite gives you a paper's inline citekey. It just reads meta.json.

    output from a scratch library

     /ref:cite 99000001
    @moreno2021cortical

    /ref:export writes BibTeX and CSL-JSON for any selection of papers. The --batch label names the folder exports/<label>/. The list of papers is fixed when the batch is first created: running the same label again reuses that list, and only --refresh rebuilds 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.bib now 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.

  9. Keep your own thoughts with /ref:note

    papers/<pmid>/notes.md belongs to you. /ref:note is 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 notes finds 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.

~/.config/ref-manager/config.json outside the library · library_root 1 ~/Research/library/ papers/, index/, exports/ created by step 1 -- rest created lazily papers/<pmid>/ meta.json · authorship.json · funding.json raw/<sha256>/response.json raw/<sha256>/source.plain_text versions/<id>/source.md · manifest.json versions/<id>/claims.json · manifest.json claim_registry.json · current.json notes.md yours · append-only 3 5 6 9 projects/thesis-ch3/ project.yaml papers.yaml 4 index/ citekeys.json (3) catalog.sqlite (7) 7 exports/thesis-ch3-v1/ references.bib · references.csl.json manifest.json frozen PMID set 8 not created -- these steps never used them people/ labs/ grants/ reports/ studies/ graph/ okf/ queries/
written by a step (number shown) your own content not created yet
Files are the source of truth. 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