paper-hunt
Find papers in computer science and AI/ML/LLM. Prove they exist. Get the text. Know what you already have.
Before anything else
SKILL=".claude/skills/paper-hunt" # or wherever this skill is installed
python3 "$SKILL/scripts/doctor.py" --network
Reports what works here and the exact command to fix what does not. Only Python is required —
every other dependency buys one capability and its absence disables just that. Fresh-install
walkthrough, local-embedding setup, and the degradation matrix: references/setup.md.
Invocation: every scripts/… path below is relative to the skill directory, so call them by
full path from wherever you are. The scripts work out the project root themselves — they climb out
of .claude/ if run from inside the installed skill, then look for .paper-hunt/ or .git/.
doctor.py prints the root it resolved; check that line first if results look wrong. Override
with PAPER_HUNT_ROOT=/path/to/project when working on a project from elsewhere.
The ordering matters: existence before citation. Measured on one corpus of 46 LLM-surfaced citations: 28% hallucinated (13 of 46 did not exist) and a further 13% (6 of 46) were real identifiers attached to the wrong paper — arXiv IDs resolving to unrelated physics and biology work. Any title that arrives from a model, including from this one, is a claim to check, not a fact.
Non-negotiables
- Check whether your collection already has it, before searching for it.
python3 scripts/index_corpus.py --have <doi|arxiv|title>— free, offline, instant. Skipping this is how the same paper ends up stored twice under two different names, observed once on a corpus of ~110 papers. - Never write a title into a
.bib, a note, or a report without running the existence check. Two API calls. There is no acceptable reason to skip it. - Never treat a fuzzy search hit as confirmation. Crossref's
query.bibliographicalways returns something — measured: it scored 36.2 on a completely unrelated paper for a real query, and 27.9 / 25.8 for two titles that do not exist. Ranking is not existence. - Never parallelize onto one scholarly host. These APIs are run by universities and nonprofits on small budgets. Fanning subagents onto arXiv or DBLP earns a 429 within seconds and can earn an IP ban that outlasts the session. Serialize, and sleep between calls.
- Sci-Hub, LibGen, and credential sharing are out of scope. The access ladder in
references/access.mdis entirely legitimate and gets most CS papers anyway.
Existence check
The decision rule, validated 8/8 against a hand-verified ground-truth set:
EM="your@email.tld"
T=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$TITLE")
# Oracle 1 — OpenAlex autocomplete. FREE (see metering note below) and higher recall
# than the metered filter=title.search: form.
curl -s "https://api.openalex.org/autocomplete/works?q=$T"
# Oracle 2 — arXiv, if oracle 1 is empty. Sleep 3s first: 1 request per 3 seconds.
sleep 3
curl -s "https://export.arxiv.org/api/query?search_query=ti:%22$T%22&max_results=3"
# Oracle 3 — Semantic Scholar, the tie-breaker. Returns an explicit 404 for a
# title that does not exist, rather than an empty result.
curl -sL --get "https://api.semanticscholar.org/graph/v1/paper/search/match" \
--data-urlencode "query=$TITLE" --data-urlencode "fields=title,externalIds"
| Oracle 1 | Oracle 2 | Verdict |
|---|---|---|
| results ≥ 1, title matches | — | CONFIRMED — take the identifier from the response, never from the model |
| results ≥ 1, title does not match | — | NOT CONFIRMED — that is a different paper |
| empty | total ≥ 1 | CONFIRMED (preprint) — record the arXiv ID |
| empty | total = 0, and oracle 3 returns 404 | NOT FOUND — presume hallucinated |
| empty | oracle unusable (429 / unreachable) | NO VERDICT — re-run later; see below |
An oracle that did not answer is not an oracle that said no. arXiv answers a rate-limit
rejection with 429 and an empty body, so a parser that reads only the body cannot
distinguish "does not exist" from "you were throttled" — and will report a real paper as
fabricated. Never let a failed call produce a NOT_FOUND. arXiv also reports errors in band: a
bad sortBy comes back as HTTP 200 containing <summary>sortBy must be in: …</summary>.
Phrasing matters as much as the endpoint: for one measured title, the quoted-phrase form returned 0 results where the same terms ANDed together returned 5. A zero from a quoted query is weak evidence.
Oracle 2 is not optional either. MAAD (arXiv 2507.21382) is real and confirmed, and the
metered filter=title.search: form returns zero for it.
Separately verify any identifier a model supplies. A plausible arXiv ID is trivial to
fabricate and will resolve to some real paper. Fetch the ID and compare the returned title to
the claimed one — that is how the 6 wrong-paper identifiers above were caught, and how an audit of
one already-written document found 21 of its 72 cited sources needed correction.
references/verification.md has the ID-checking calls and the batch protocol for a whole .bib.
Routing
Pick the source by intent, not by habit:
| Intent | Source | Why |
|---|---|---|
| Do we already have this? | scripts/index_corpus.py --have | Free, offline, first |
| Topic sweep (new papers) | scripts/find_papers.py | 3 sources merged, owned papers marked |
| What do MY papers say about X? | scripts/search_corpus.py | BM25 + local embeddings, fused |
| What is my corpus about? | scripts/index_corpus.py --topics | Coverage map by topic and tier |
| Add a paper to the corpus | scripts/ingest.py | Download, name, register, scaffold |
| What does a paper I own cite that I lack? | scripts/explore_citations.py | Subtracts the corpus automatically |
| Does this exact title exist? | OpenAlex → arXiv | The check above |
| I have a DOI or arXiv ID → metadata | OpenAlex | One endpoint resolves both |
| Broad topic sweep | OpenAlex + arXiv category listing | Coverage vs. recency |
| Newest preprints in a subfield | arXiv cat:cs.SE etc. | Days ahead of every index |
| Who cites this? / What does it cite? | OpenAlex | Free citation graph, no key |
| Authoritative CS venue + year metadata | DBLP (uni-trier mirror) | Curated by humans, no junk |
| NLP/ACL papers, full text | ACL Anthology | Fully open, bulk BibTeX |
| Get the PDF | references/access.md ladder | Ordered cheapest-first |
| Abstract for a paywalled paper | scripts/get_abstract.py | Paywalled ≠ opaque — cascades OpenAlex → S2 → Crossref → arXiv |
| Everything by an author | OpenAlex author ID | Disambiguated |
"Tier" is whatever vocabulary your project uses. The tools copy it verbatim out of your notes'
YAML frontmatter into the index and never impose a scheme of their own; --tier filters on the
values you already wrote. Full source matrix with endpoints, auth and rate limits:
references/sources.md.
Non-English queries need the dense leg. Measured with Spanish queries against an
English-indexed corpus: BM25 alone missed relevant papers the embedding leg retrieved — a keyword
index has no cross-language bridge. The fused BM25 + embedding ranking in search_corpus.py
exists for this case; do not disable the dense leg to save time on a non-English query.
Verified working, as of last check
# OpenAlex — no key, no limit worth worrying about with mailto=
https://api.openalex.org/works?search=QUERY&per-page=25&mailto=$EM
# arXiv — https ONLY (http returns 301); 1 req / 3 s
https://export.arxiv.org/api/query?search_query=cat:cs.SE+AND+all:diagram&max_results=20
# Crossref — for DOI metadata and BibTeX, NOT for existence
https://api.crossref.org/works/10.1145/3597503.3623301?mailto=$EM
# DBLP — dblp.org and dblp.dagstuhl.de were down; the mirror served fine
https://dblp.uni-trier.de/search/publ/api?q=QUERY&format=json&h=10
# Unpaywall — legal OA copy for a DOI
https://api.unpaywall.org/v2/DOI?email=$EM
DBLP venue keys are case-sensitive. Measured: MODELS returned 3 hits where MoDELS returned
54 for the same conference. A near-empty DBLP venue result usually means the wrong casing, not a
quiet venue.
Route-level traps. Two major sources are neither alive nor dead — one route works and its neighbour is blocked. Testing the wrong route writes off a good source:
| Source | Blocked route | Working route |
|---|---|---|
| Semantic Scholar | /graph/v1/paper/search → 429, cold, unauthenticated | /graph/v1/paper/search/match → 200, and /paper/{id} lookups |
| OpenReview | /notes → 403 ChallengeRequiredError (route-level, any params) | /notes/search → 200, ratelimit-policy: 20;w=60 (5;w=60 on some routes) |
/paper/search/match returns 404 {"error":"Title match not found"} for a fabricated title
rather than an empty result set — an explicit not-found beats a zero count. It still 429s
perhaps a quarter of the time unauthenticated, so use it as tie-breaker, not primary oracle.
Known bad, do not reach for:
- The
semantic-scholarMCP server — distinct from the API above. Its client hardcodesSemanticScholar()with no key and no env support, hits the blocked/paper/searchroute, retries ~7 minutes, then fails — and the wrapper catches the exception and returns it as a successful result, so the caller reads failure as "no papers found". Use rawcurl. - Papers with Code — sunset.
paperswithcode.com/api/v1/...returns HTTP 200 with Hugging Face HTML, so a status-code health check reports it healthy. Its successor ishttps://huggingface.co/api/papers/search?q=(arXiv ID is nested atresults[i].paper.id). - CORE — unreachable from the machine this was measured on; hangs past 25s even with the sandbox off. Retest before writing it off on your own network.
- Google Scholar — scraping violates its ToS. Use it manually in a browser, or set up Scholar alerts; do not automate it.
Always assert the content type, never just the status code. paperswithcode.com and
papers.nips.cc/api/v1/papers both return 200 with unrelated HTML. Check that the body parses
as JSON before believing a 200.
Budgets and etiquette
OpenAlex has been USD-metered since February 2026 — a daily dollar budget, not a call count.
Measured from live x-ratelimit-* headers:
| Call | Cost |
|---|---|
| /works/{id}, /works/doi:… single entity | $0 — unmetered |
| /autocomplete/works | $0 |
| /works?filter=… | $0.0001 |
| /works?filter=*.search: or ?search= | $0.001 |
Anonymous budget is $0.10/day — roughly 100 title searches, or unlimited autocomplete and
identifier lookups. A free key from openalex.org/settings/api raises it to $1.00/day and is
passed as the query param api_key=. Build on the free endpoints and the budget never binds.
mailto= no longer buys anything on OpenAlex — verified by comparing rate-limit headers with
and without it. It is still worth sending to Crossref, where a mailto User-Agent moves you from
x-api-pool: public-array at 1 req/s to polite-array at 3 req/s, and to Unpaywall as email=.
Sleep 3 seconds between arXiv calls; the lockout runs 5–7 minutes with no Retry-After. When
export.arxiv.org/api is throttled, arxiv.org/abs/{id}, arxiv.org/pdf/{id} and
oaipmh.arxiv.org keep serving — curl -o /dev/null -w '%{http_code}' https://arxiv.org/abs/ID
is a cheap 200-vs-404 existence check that routes around the ban entirely.
Run scholarly lookups serially from one process. The fastest way to lose arXiv and DBLP is to fan out agents that each believe they are the only caller — 10 parallel arXiv queries returned 429 ten times over.
Institutional access
If your institution runs an EZproxy, paywalled papers are one URL rewrite away: replace the dots
in the publisher host with dashes and append the proxy domain — link.springer.com/… becomes
link-springer-com.<proxy-host>/…. Log in once in a browser; the session cookie carries. Worked
example: Uruguay's Portal Timbó (ANII) proxies Springer, Elsevier, IEEE and Nature this way. This
is an entitlement, not a given — check what your institution actually licences. Full ladder:
references/access.md.
After you find it
Finding is the cheap part. A backlog stalls because nothing gets dropped, not because reading is slow — so triage is what unblocks the work, and it needs written criteria and three outcomes (KEEP / DROP / DEFER), not two.
The single highest-value habit: when a paper comes from someone's reference list, read the sentence that cites it before fetching anything. The citing author has already said what it contributes.
Snowball in order, not in parallel. Wohlin's comparison: backward snowballing run to
completion before forward snowballing yielded 38 papers, against 23 when both directions ran in
parallel from the same seed set. Order is free recall. Full procedure — 5-minute triage,
false-quality signals, reading passes, snowballing with its stopping rule, and the LLM delegation
boundary — in references/reading.md.
References
references/verification.md— ID cross-resolution,.bibaudit protocol, what a corrupted citation looks likereferences/sources.md— the full source matrix: preprints, aggregators, publisher indexes, venue proceedings, discovery toolsreferences/access.md— the access ladder from "exists" to "PDF on disk", including how to detect a paywall page masquerading as a PDFreferences/reading.md— triage, reading passes, snowballing, note structure, and what may safely be delegated to a modelreferences/setup.md— fresh install, dependencies, local embeddings, first runreferences/corpus.md— the index of what you already have: how it is built, how to ask whether a paper is already in it, and how to walk citations out of it
This skill stops at retrieval and verification. Bibliography format, corpus layout, tier
vocabulary, and note conventions belong to your project — declare them once in
.paper-hunt/config.json:
{
"library": [{"path": "papers", "expect_notes": true},
{"path": "papers/background", "expect_notes": false}],
"notes": {"dir": "notes", "template": "notes/_TEMPLATE.md"},
"summaries": {"dir": "paper-summaries"},
"bibliography": {"files": ["refs.bib", "refs-pending.bib"], "default": "refs-pending.bib"},
"trackers": [{"file": "reading-list.md", "row": "| P0 | — | {title} ({year}) | {link} |"}],
"stem": "author-year-topic"
}
python3 scripts/config.py --init writes a detected layout you can edit; --show prints what is
in effect.