Bibcheck
Academic research workflow for Claude Code: 14 skills (paper reading, lit review, bib auditing, preregistration, referee reports, replication packages, LaTeX, TikZ) plus a Quarto reveal.js slide system with render-time quality gates. Entirely AI-generated with Claude Code as a proof of concept.
npx -y skills add ericluo04/claude-academic-workflow --skill bibcheckAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 5 stars5 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Audit an existing .bib file entry by entry against canonical metadata (Crossref, OpenAlex, arXiv, Zotero) to catch silent citation errors that pass spell-check and kill peer review, including wrong years, mis-cited authors, wrong journal or volume, swapped title-author pairs, dead DOIs, and hallucinated entries from LLM-assisted drafting. TRIGGER on "/bibcheck", "audit my .bib", "verify my references", "check my bibliography", "are any of these citations fake", "did Claude hallucinate a citation", "pre-submission bib audit", or before shipping a manuscript or R&R to Marketing Science, JMR, JCR, or Management Science. Read-only: writes a report and a separate corrected.bib, never touches the source.
SKILL.md
16.8 KB, as published. Nobody here has run it
bibcheck
Given a .bib, spawn subagents over batches of 5 entries to verify each entry against canonical
metadata, then emit a PASS/WARN/FAIL report and a corrected.bib the user reviews and merges. The
source .bib is never modified.
One agent asked to audit 200 entries in a single pass drifts: early entries get careful treatment, late entries get pattern-matched. Batches of 5 stay short enough that the cliff never arrives, and the batch contract in phase 1 polices the same drift inside each batch.
Per-entry verification against canonical metadata is adapted from Scott Cunningham's MixtapeTools.
Hallucinated citations have a recognizable signature: plausible authors, plausible title, a DOI that 404s, and no match in Crossref, OpenAlex, or arXiv. Surfacing that pattern is the point.
Inputs and scoping
Take a .bib path as the argument. With no argument, Glob in this order and ask if ambiguous:
references.bib,refs.bib,bibliography.bib, then*.bibin the cwd.~/Library/CloudStorage/Dropbox*/Apps/Overleaf/*/*.bib(this setup assumes Overleaf projects sync here via Dropbox; adjust to your machine).
With several .bib files in one project, grep the .tex for \bibliography{} / \addbibresource{}
to pick the one actually loaded. Skip anything under _archive/ or Old/ unless asked.
Scope before dispatching. Zotero-exported libraries carry far more entries than the manuscript cites (one real project here: 345 entries, 170 cited), and auditing an uncited entry is wasted work. Collect the cited keys and offer to restrict to them:
grep -ohE '\\[a-zA-Z]*[Cc]ite[a-zA-Z]*\*?(\[[^]]*\])*\{[^}]*\}' /abs/path/to/*.tex \
| grep -oE '\{[^}]*\}$' | tr -d '{}' | tr ',' '\n' | tr -d ' ' | sort -u
Report both counts and let the user choose. Default to cited-only above 100 entries.
Phase 0: parse
Read the .bib in full. Split on ^@\w+\{ boundaries and capture the entry type, citation key, and
every field = {value} pair (braced and quoted forms, escaped braces, multi-line values). Malformed
entries (unbalanced braces, missing key, duplicate key) go straight to FAIL with no subagent.
Triage by entry type, because the checks that apply differ:
- Indexed types (
@article,@inproceedings,@book,@incollection,@phdthesis): full check set. - Non-indexed types (
@misc,@online,@software,@dataset, and@unpublishedwith no arXiv id): blog posts, docs pages, and repos legitimately have no DOI and appear in no index. Check theurl/howpublishedlink resolves (curl -sSI -o /dev/null -w '%{http_code}') and thatyearlooks sane. Never assign the fabrication verdict to these.
Create <bib_dir>/bibcheck_<YYYYMMDD_HHMM>/ containing entries/ (one .bib fragment per entry,
subagent input) and reports/ (one .json per entry, subagent output), plus input.bib as a
verbatim copy of the source for provenance.
Phase 1: dispatch
Group the well-formed entries into batches of 5, preserving file order; the last batch holds the
remainder. Launch one subagent per batch with the Agent tool, 8 per message, so 40 entries are in
flight per wave. Wait for the wave to return, then send the next 8. 170 cited entries is 34 batch
subagents in 5 waves. Respect --max-parallel N if the user passes it; drop to 2 when
rate-limited.
Each subagent gets its batch's raw entry texts, the absolute entries/ paths, one absolute output
path reports/<citekey>.json per entry, and the check list below. Subagent working directories
reset between Bash calls, so every path handed to a subagent must be absolute.
The batch contract: the subagent MUST return the full per-entry output schema for every one of its entries, each verified independently against the lookup tools, with no verdict inferred from a batchmate. State in the subagent prompt that the failure mode to guard against is skimping on the later entries in the batch, and that entry 5 gets the same treatment as entry 1.
Give each subagent a 60-second lookup budget per entry. On expiry it writes
{"verdict": "WARN", "diagnostic": "lookup_timeout"} for that entry and moves to its next one.
Raise the budget on a cold cache; paper.py disk-caches responses for 30 days, so reruns are fast.
For a long run, dispatch waves of background subagents with the Agent tool and act on their
completion notifications, sending the next wave as each one finishes.
Phase 2: per-entry checks
The two lookup tools
paper.py is the resolver from the reading-papers skill. Read
~/.claude/skills/reading-papers/SKILL.md if unfamiliar.
~/.claude/skills/reading-papers/scripts/paper.py resolve "<query>" --json
It returns a JSON array. Interpreting it is the whole game:
- One element with
title,authors,year,venue,is_oapopulated: a confident canonical match. - One element with only
{"doi": ..., "title": null}: the identifier exists as a string but no index recognizes it. Exit code is still 0, so test the payload, not$?. - Several elements carrying only
{doi, title, venue}: no confident match, just relevance-ranked candidates. Treat as "not found" unless one clears the 0.85 title threshold below.
It does not return volume, issue, or pages. Crossref does, and Crossref is free and unmetered.
Replace [email protected] with your real address in both Crossref calls in this file: the
mailto= parameter joins Crossref's polite pool, and Crossref asks for a real contact.
curl -sS -w '\nHTTP:%{http_code}' "https://api.crossref.org/works/<doi>[email protected]" \
| python3 -c 'import sys,json; b=sys.stdin.read(); code=b.rsplit("HTTP:",1)[1].strip()
if code!="200": print(json.dumps({"crossref_status":code})); raise SystemExit
m=json.loads(b.rsplit("\nHTTP:",1)[0])["message"]
print(json.dumps({k:m.get(k) for k in ("DOI","title","container-title","short-container-title","volume","issue","page","issued","type","author","ISSN")},ensure_ascii=False))'
A dead DOI returns HTTP 404 with the non-JSON body Resource not found., so test the status code,
never $? alone (--fail exits 56 here, not the documented 22). Use WebFetch on the same URL only
if Bash is unavailable; it summarizes through a model and drops fields.
Cost discipline. OpenAlex is metered: an identifier lookup costs 1 credit, anything routed through
search (including title search) costs 10. Spend identifiers before strings. Order per entry:
Crossref by DOI (free) → paper.py resolve by DOI or arXiv id (1 credit) → Crossref
query.bibliographic (free) → paper.py resolve by title (up to 10 credits), reaching the last rung
only when the entry has no identifier at all.
The checks, in order, stopping once the verdict is settled
-
DOI resolution. With a
doifield, run the Crossref call above for volume, issue, pages, ISSN, andissued, thenpaper.py resolve "<doi>" --jsonfor the authoritative author spellings and venue. Crossref 404 plus"title": nullfrompaper.pyis a dead DOI, so go to check 4. -
arXiv resolution and preprint versus published. When the entry carries
eprint,archivePrefix = {arXiv}, orarXiv:innote, runpaper.py resolve "<arxiv_id>" --json. A hit returnsarxiv_id,title,authors,year,abstract, anddoi(null when arXiv-only). Confirm the title matches the entry. If the entry has nodoi, resolve the exact title once to see whether a journal version now exists; a single full record with a non-arXivvenuemeans it does, and that is a WARN suggesting the published@articlein place of the preprint@misc. Skip this probe for entries that already carry a published DOI, since it is the expensive rung. -
Title match, last resort only. With no DOI and no arXiv id:
curl -sS "https://api.crossref.org/works?query.bibliographic=<urlencoded+title>&rows=5&select=DOI,title,container-title,volume,issue,page,issued,author&[email protected]"Crossref ranks by relevance, and the preprint often outranks the version of record (a real case: the SSRN copy of a Marketing Science paper comes back first). Pick by container: prefer a journal container over
SSRN Electronic Journal,arXiv,Research Square, orSocArXiv. Confirm the winner withpaper.py resolve "<winning doi>" --jsonat 1 credit. Only if Crossref returns nothing plausible, fall back topaper.py resolve "<title>" --json.Title match rule: lowercase, strip LaTeX braces and accents, strip punctuation, drop the stopwords {a, an, the, of, on, in, for, and, to}, tokenize on whitespace, and compute Jaccard overlap of the token sets. Accept at ≥ 0.85.
-
Fabrication test. FAIL with
fabricated? DOI does not resolve and no index recognizes the titlewhen all of these hold: the entry is an indexed type; adoiis present and both Crossref andpaper.pyreject it (or nodoiis present at all); and the title clears 0.85 against nothing in Crossref, OpenAlex, or arXiv. All three conditions are required. A missing DOI alone is normal for older and non-English work. -
Author normalization. Compare surname sequences after NFKD normalization, combining-mark removal, lowercasing, and punctuation stripping.
- Clear surname mismatch, or a different number of named authors where the source lists fewer: FAIL.
- Initials against full given names: no flag, that is a style choice.
- Accent loss (
PenaforPeña) or a dropped tussenvoegsel (Van Den Bulteforvan den Bulte): WARN. Strip leading particles {van, van der, van den, de, de la, del, della, di, du, von, zu, ter, ten, la, le} before the surname comparison, then compare the particle on its own. - Author-list truncation (
and others, missing trailing authors): WARN. - Judge accents against
paper.py/OpenAlex, never against Crossref. Crossref frequently stores the stripped form (it returnsYildirimwhere OpenAlex returnsYıldırım), so a Crossref diff alone is not evidence of an error in the entry.
-
Year reconciliation against Crossref
issued.date-parts[0][0]. With an arXiv id present and the entry year matching the arXiv submission year, WARN (entry cites preprint year <y1>; published version is <y2>), because citing the preprint year is a legitimate choice. Otherwise FAIL. -
Journal canonicalization. Compare
journalagainst Crossrefcontainer-title. WARN on abbreviation drift (J. Mark. Res.forJournal of Marketing Research,Mark. Sci.forMarketing Science,Mgmt. Sci.forManagement Science,Quant. Mark. Econ.forQuantitative Marketing and Economics). FAIL on a different journal entirely, confirmed by a disjointISSNset. Apply the same logic tobooktitleon@inproceedings(ICML, NeurIPS, KDD and their full names). -
Volume, issue, and pages against Crossref
volume,issue,page. Normalize page ranges before comparing:831--847,831-847, and831–847are the same range, and BibTeX's double hyphen is correct style. WARN on one field disagreeing. FAIL on two or more, which usually means a swapped entry, the title of paper A glued to the volume and pages of paper B. -
Zotero cross-check, optional. For a Better BibTeX shaped key, call
mcp__zotero__zotero_search_items(query="<title>", qmode="titleCreatorYear", limit=3)and pullmcp__zotero__zotero_get_item_metadataon a hit. A Zotero item with a different DOI than the entry is a WARN. Zotero reads work only while the desktop app is open, so any error meanschecks_skipped: ["zotero"], never a WARN.When a Zotero call fails, tell the user directly, in the chat reply and not only in the report: Zotero looks closed, open the Zotero desktop app and this can be re-run to add the library cross-check. Say it once, up front, rather than burying it in per-entry output. A closed Zotero is the usual cause and it is a five-second fix, so it is worth interrupting for. Do not stop the audit over it; the other eight checks do not need Zotero.
Subagent output
One file per entry at reports/<citekey>.json:
{
"citekey": "liu2022implications",
"verdict": "PASS | WARN | FAIL",
"diagnostic": "one line",
"field_findings": [
{"field": "year", "status": "FAIL", "entry_value": "2023",
"canonical_value": "2022", "source": "crossref:10.1287/mksc.2022.1361"}
],
"canonical_source": "crossref | openalex | arxiv | zotero | none",
"checks_skipped": ["zotero"],
"suggested_replacement": "<full corrected BibTeX entry, or null>"
}
Phase 3: aggregate and report
Read every JSON in reports/. Bucket by FAIL, then WARN, then PASS; sort alphabetically by citekey
within each bucket so runs diff cleanly. Write
<bib_dir>/bibcheck_<YYYYMMDD_HHMM>/bibcheck_report.md:
# Bibliography audit: <bib filename>
Date: YYYY-MM-DD HH:MM
Source: <absolute path>
Entries audited: N parsed, M malformed, K uncited and skipped
## Summary
| Verdict | Count |
|---|---|
| PASS | a |
| WARN | b |
| FAIL | c |
| Possibly fabricated | d (subset of FAIL) |
## FAIL (verify before submission)
| Citekey | Diagnostic | Source |
|---|---|---|
## WARN (preprint status, field drift, style)
| Citekey | Diagnostic |
|---|---|
## PASS
<one line each>
## Per-entry detail
<the JSON for every FAIL and WARN>
## Next steps
1. Review each FAIL row against corrected.bib.
2. `git diff --no-index <source>.bib corrected.bib`
3. Move corrected.bib into place yourself; this skill never overwrites the source.
Also write corrected.bib: the full source with FAIL and WARN entries replaced by their
suggested_replacement, PASS entries copied byte for byte, comments and entry order preserved. An
entry with no confident correction stays as-is under a leading
% bibcheck:FAIL <diagnostic> comment line.
Verdict tolerances
PASS: a canonical source resolves and title, authors, year, venue, and volume/issue/pages all agree
within style tolerance. For a non-indexed @misc, PASS means the URL resolves.
WARN: at most one of accent or tussenvoegsel drift, journal abbreviation drift, preprint year
against published year, a single volume/issue/page disagreement, a preprint entry whose published
version now exists, a Zotero DOI mismatch, or lookup_timeout.
FAIL: a dead DOI, a wrong surname, a wrong year with no preprint explanation, a wrong journal confirmed by ISSN, two or more volume/issue/page disagreements, a malformed or duplicate-key entry, or the fabrication signature.
Output layout
<bib_dir>/bibcheck_<YYYYMMDD_HHMM>/
input.bib verbatim copy of the source
entries/<key>.bib subagent inputs
reports/<key>.json subagent outputs
bibcheck_report.md consolidated report
corrected.bib candidate replacement for the user to diff and merge
Failure modes
.bib not found: ask for the path, do not invent one.
Every entry returns lookup_timeout: a network or rate-limit problem. Retry once at
--max-parallel 2, then surface the error. Do not mark a whole bibliography FAIL on this.
A wave of year FAILs: usually preprint versus published. Widen the arXiv detection in check 2 and leave the year tolerance alone.
Title match confidence below 0.7 across the board: the bibliography may be non-English or in a venue neither Crossref nor OpenAlex indexes. Say so, ask for one entry known to be correct, and recalibrate against it.
OpenAlex credits exhausted (1,000/day anonymous, 10,000 with the key in
~/.claude/secrets/scholar.env): fall back to Crossref-only checking, which still covers checks 1
and 6 through 8, and mark author-accent findings as skipped.
Out of scope
Do not add entries. Hand a DOI to mcp__zotero__zotero_add_by_doi, or use the reading-papers
skill for anything else.
Do not overwrite the source .bib under any circumstances.
Do not claim a citation supports a textual claim. This skill verifies that an entry's metadata matches a real paper and nothing more.
Do not bulk-reformat. A PASS entry is copied verbatim into corrected.bib even where its style
differs from the rest of the file.