Snowball¶
Status: keyword search (multi-term AND / --or, trailing * stem
expansion), multi-DOI / multi-ORCID / collection seeds, --seeds-file,
hybrid (keyword hits, then one hop), refs, cited-by, and
OpenAlex keywords, depth up to 5 under caps, gates
dry-run / approve-each / approve-batch / auto, overlap ranking,
--fetch-pdfs, --dedupe-scope per invocation, optional --dedupe-after,
opt-in author-site preflight / snowball packs promote, and pull-only watch
are implemented, plus a frontier digest on a saved queue or watch.
Optional stopping rules are off until you set them: one hop,
then Keep/Skip, then snowball continue.
Config honors dedupe_scope, tag_prefix, default_tags,
types, oa_only, venues, listed_in, languages, min_seed_citations,
note_provenance, and backends. --refine writes query suggestions when
[llm] is on and does not create items. expand = cited_authors stays off.
Phases live in ROADMAP.
Snowball grows a library outward from a keyword (or several), one or more DOIs,
one or more people (ORCID), or an existing collection. It proposes works, then
creates items under an explicit gate. run fills PDFs for items
that already exist. fetch_pdfs can do that in the same process, limited to the
keys just created. How far one command reaches is drawn below.
paperful snowball search "area based management tools" --year-from 2018
paperful snowball search "marine spatial planning" --listed-in doaj,medline
paperful snowball search "marine spatial planning" "offshore renewable energy"
paperful snowball search msp ore --or
paperful snowball doi 10.1038/s41586-021-03819-2 --depth 2 --direction both
paperful snowball doi 10.1038/s41586-021-03819-2 10.1126/science.aao5646
paperful snowball orcid 0000-0002-9162-9618
paperful snowball orcid 0000-0002-9162-9618 0000-0002-1825-0097 --depth 1
paperful snowball collection "Inbox/Seeds" --direction refs
paperful snowball run --profile doi-refs-gated
paperful snowball apply <run-id> -C "Inbox/Snowball"
paperful snowball hybrid "high seas EIA" --hybrid-seeds 5 --direction refs
paperful snowball search "high seas EIA" --gate auto --fetch-pdfs fast -C "Inbox/Snowball"
paperful snowball orcid 0000-0002-9162-9618 --gate auto --fetch-pdfs full -C "Snowball/0000-0002-9162-9618"
paperful snowball search "high seas EIA" --gate dry-run --format json
paperful snowball apply <run-id> -C "Inbox/Snowball" --format json
paperful snowball watch save bbnj --profile keyword-scout
paperful snowball watch run bbnj --digest
paperful snowball watch run bbnj --refresh
paperful snowball watch digest bbnj
paperful snowball digest --run-id <run-id>
paperful snowball watch briefing bbnj
paperful snowball doi 10.1038/s41586-021-03819-2 --depth 3 --stop-rule marginal_yield
paperful snowball continue <run-id>
--format json on crawl (search / hybrid / doi / orcid / collection),
run, resume, continue, and apply prints one
paperful.agent.json.v1 object on stdout (run path +
summary; progress on stderr). Mixed apply writes exit 3. Prefer that
over MCP for shell agents.
search, hybrid, doi, orcid, and collection are seeds under one verb.
search and hybrid take one or more keyword terms (AND by default; --or
matches any). doi and orcid take one or more seeds in a single crawl
(shared caps / gate / -C). hybrid is the keyword-then-hop job. There is no
separate top-level harvest, crawl, or discover. watch re-runs a saved
profile on a schedule you choose; see Watch.
Watch¶
A watch is a saved snowball profile plus a seen-set on disk. You run it when
you want. Paperful does not schedule it (your own launchd or cron may call
watch run). The first run records the current frontier and proposes nothing.
Later runs write only works that were not in that set. Creating items stays on
snowball apply (or a separate writing-gate crawl). Watch always forces
gate = dry-run and fetch_pdfs = off, so a profile with gate = auto cannot
create parents or download PDFs from a watch.
People you follow (ORCID lists, no hop) are authorwatch, not
this profile watch.
paperful snowball watch save bbnj --profile keyword-scout
paperful snowball watch run bbnj # first time: baseline, inbox empty
paperful snowball watch run bbnj --digest # later: N new, then digest.md
paperful snowball watch run bbnj --refresh # skip last-run date cursor; rewrite responses.json
paperful snowball watch show bbnj
paperful snowball watch rebaseline --all # dry run; --apply once after the Oct 2026 OpenAlex topic change
paperful snowball watch digest bbnj # ranked rollup; --apply -C files a note
paperful snowball digest --run-id <run-id>
paperful snowball watch briefing bbnj # thin markdown; same note tag
paperful snowball briefing --run-id <run-id>
paperful snowball apply <run-id> -C Inbox/Snowball # only if you want parents
Oct 2026 topic change. OpenAlex reassigned work topics (and keywords) in
October 2026, so a keyword watch baselined earlier can see old works reappear as
“new”. snowball watch rebaseline (dry run by default; --apply) clears
baseline_at for keyword-based watches baselined before 14 Oct 2026, so the
next watch run absorbs the current hits as seen and proposes nothing. It
keeps seen.json, applied.json, and the inbox, and marks the watch
(rebaselined_for) so it is done once; --force repeats it. Every snowball
run’s summary.json now records openalex_run_date and openalex_topic_epoch
(pre-2026-10 or 2026-10), because keyword hops before the change cannot be
reproduced.
Ledger under state/snowball/watches/<name>/:
File |
Role |
|---|---|
|
|
|
Identities already recorded ( |
|
Append-only proposed |
|
|
|
Thin markdown export ( |
|
Frontier digest ( |
Every crawl summary.json (and snowball apply / snowball watch run
command reports) also carries additive PRISMA-style counts:
identified, screened, kept, added. Counts only — no recall
percentage. Watch runs add watch_drift (new / unchanged /
metadata_changed / reassigned_id) from responses.json. --refresh
skips the last-run from_created_date cursor and rewrites that cache;
Paperful still does not schedule watches.
export-proposals reads inbox.jsonl here (or candidates.jsonl on a crawl
run dir). See BibTeX / RIS from the queue.
paperful snowball briefing --run-id writes thin markdown next to
candidates.jsonl. paperful snowball digest --run-id writes digest.md
there: new / exists / version / deferred, overlap detail on the top 25 new
rows (score, why, hop, overlap), a suggested -C from
[snowball].target_collection (or the watch profile’s target_collection),
and copy-paste paths for the queue, apply, and resume. A watch digest
reads the last run queue. After watch run that file holds only the new
proposals, so an exists split is empty there; already seen on the summary
is the overlap with the prior frontier. If OpenAlex stopped early,
summary.json still says deferred and the digest points at resume.
snowball digest --run-id on a normal crawl queue still splits new / exists
/ version / deferred. The inbox count is separate, because the inbox is
new-only and keeps older proposals. Neither command creates library items. --apply with
-C files a collection note tagged paperful:frontier-briefing. OA / grey
stamps already on the row (oa:…, grey:…, is_oa, oa_status) are
printed; there is no extra API call. The alert loop (poll → ingest parents
→ briefing) and weekly what-cites digest are keepup. Standalone
inbox apply:
paperful snowball watch apply bbnj -C Inbox/Snowball # dry-run
paperful snowball watch apply bbnj -C Inbox/Snowball --apply # parents + ingested.md
Schedule it yourself¶
Paperful does not run a timer. Prefer keepup run for the full
loop. For watch-only digest:
Operators (Compose), from the repo:
15 7 * * 1 cd /path/to/paperful && docker compose run --rm paperful keepup run field --apply
Contributors, on a host checkout:
15 7 * * 1 cd /path/to/library && uv run paperful keepup run field --apply
launchd and systemd below are the host-checkout form (uv). A Compose
operator uses the cron line above (docker compose run).
launchd (~/Library/LaunchAgents/paperful.keepup.field.plist):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>paperful.keepup.field</string>
<key>WorkingDirectory</key>
<string>/path/to/library</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/uv</string>
<string>run</string>
<string>paperful</string>
<string>keepup</string>
<string>run</string>
<string>field</string>
<string>--apply</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key>
<integer>1</integer>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>15</integer>
</dict>
</dict>
</plist>
systemd user units (~/.config/systemd/user/paperful-watch-bbnj.service and
.timer):
# paperful-watch-bbnj.service
[Service]
Type=oneshot
WorkingDirectory=/path/to/library
ExecStart=/path/to/uv run paperful snowball watch run bbnj --digest
# paperful-watch-bbnj.timer
[Timer]
OnCalendar=Mon *-*-* 07:15:00
Persistent=true
[Install]
WantedBy=timers.target
Then systemctl --user enable --now paperful-watch-bbnj.timer. Read
state/snowball/watches/bbnj/digest.md when it finishes.
Each watch run also writes a normal state/snowball/<run-id>/ queue. After
baseline, that queue is empty (baseline N · proposed 0). After a later run,
it holds only the new proposals (proposed N · already seen M), with
keep = true so snowball apply can create them. OpenAlex keyword and
cited-by calls pass from_created_date from the cursor (YYYY-MM-DD of the
last run) so a work indexed after the last check can show up even when its
publication year is old. The seen set still drops overlaps. A hybrid profile
watches the keyword hit list only (depth 0); a refs hop of those hits stays a
separate snowball run.
Snowball and run¶
Job |
Command |
What you are doing |
|---|---|---|
Build |
|
Find works and, if the gate says so, create parents |
Fill |
|
PDFs and attach for items already in the library |
A dry-run ends with “candidates ready”. A writing gate with fetch_pdfs = off
ends with “items created (metadata only)”. Downloaded and attached appear in
the summary only after fast or full has run.
A snowball profile is a named job in profiles/. paperful run and
paperful all refuse it. Fetch profiles stay fetch profiles.
Seeds¶
Seed |
What it collects |
Default depth |
|---|---|---|
Keyword |
OpenAlex title/abstract search, plus (by default) works carrying matching OpenAlex keyword tags. Pass several terms for one boolean query (AND by default; |
0 — the hit list. Depth 1+ expands those hits and must be set explicitly. A global |
Hybrid |
That hit list, then one hop from the top |
The hop is always 1. |
DOI |
Each seed work (hop 0), then its neighbours ( |
1, direction |
ORCID |
Those people’s works (ORCID public API, filled by OpenAlex author filter), then the same expander. Pass several ORCID iDs for one shared crawl |
1. One hop out from the combined works |
Collection |
DOIs already in the seed collection path, then neighbours only |
1. |
expand = cited_authors (every paper by every cited author) stays off. It
is a later, capped switch.
How a hop is cut¶
An ORCID run starts at those people’s own works (hop 0). Pass several ORCID
iDs in one command (or repeat --orcid on profile save) to merge them into
one crawl under shared caps. --depth 1 takes
one hop out from those works and stops. It does not walk the neighbours’
neighbours. --direction both takes references and citing works. Each side
is capped on its own: --per-hop-limit 25 keeps 25 references and 25 citing
works per paper. --per-hop-rank chooses which ones: most-cited (default),
least-cited, or random. Use all (or 0) on either cap for every
neighbour or every row. After that hop, max_candidates (default 200) can
still cut the new + exists list.
--cites-query keeps references and citing works whose title, abstract, or
full text matches that OpenAlex search. Cited-by sends it with filter=cites:.
Reference ids send it with filter=openalex:. The direction must include refs
or cites, and depth must be at least 1. --per-hop-limit all and
--max-candidates all still apply to the matches.
paperful snowball doi 10.1016/j.jclepro.2022.132764 --depth 2 --direction both --per-hop-limit all --max-candidates all --cites-query degrowth
paperful snowball orcid 0000-0002-9162-9618 --depth 1 --direction both --per-hop-limit 25 --per-hop-rank most-cited --max-candidates all
flowchart TB
person["ORCID"]
own["Own works · hop 0"]
person --> own
paper["Each of those papers"]
own --> paper
refs["References · 25 kept"]
cites["Citing works · 25 kept"]
paper -->|"direction both"| refs
paper -->|"direction both"| cites
list["Own works plus that hop"]
own --> list
refs --> list
cites --> list
trimmed["Cut to 200 new and exists"]
kept["Every new and exists row"]
list -->|"max-candidates 200"| trimmed
list -->|"max-candidates 0"| kept
DOI and collection seeds use the same hop. For DOI, the seed work itself is included as hop 0, then neighbours. Collection expands DOIs already in the seed path. Keyword search stays on the hit list unless you set depth. The usual numbers are in Stop rules. With a stopping rule on and depth greater than 1, that first command stops after hop 1 and waits. See Stopping rules.
direction including keywords adds another side (the built-in default is
refs+keywords). OpenAlex stores at most five scored keywords on a work. The
hop takes the top keyword_limit (default 3) at or above keyword_min_score
and requests works with any of those slugs (keywords.id:a|b|c), then keeps
keyword_hop_limit of them. all is refused on both keyword caps: the matching
literature is not a closed neighbour list. Set direction = "refs" in config
to restore reference-only hops.
Keyword search (and hybrid hop 0) also uses those tags when
search_keywords is on (default): the query is resolved via OpenAlex
/keywords, then tagged works are unioned with full-text search= hits and
ranked by tag overlap. --no-search-keywords or search_keywords = false
keeps full-text retrieval only.
Works with no topic have no keywords. Paperful does not quote a share: the old “about 12%” figure predates OpenAlex’s October 2026 topic reassignment and has not been re-measured. When keywords are in the direction, the run says how many seeds have none and will not expand on that side:
2 of 5 seeds have no OpenAlex keywords and will not expand on that side.
Those seeds are skipped on the keyword side only. References and cited-by still run. If keywords is the only side and every seed lacks keywords, the command exits 2 with that sentence and does not query.
Gates¶
The gate is a config value. The default for a new user is dry-run.
Gate |
Library writes |
When |
|---|---|---|
|
None |
Always safe. |
|
Only |
|
|
Each yes on |
Short lists, at most |
|
Every |
A named profile, after you have dry-run that job once |
dry-run, approve-batch, approve-each, and auto are the gates. A writing
gate (approve-batch, approve-each, auto) requires a target collection and
stops before the crawl if it is missing. Paperful may create that collection
path. It does not invent a silent default collection. auto is not a scheduler.
Dedupe runs before create: normalized DOI against dedupe_scope (library
by default, or collection, or none logged loudly), then the existing
title+year fingerprint. Only status = new rows can become parents.
--dedupe-scope on search / doi / orcid / collection / run /
resume / watch run overrides the profile for that invocation.
snowball apply fingerprints the full library unless you pass
--dedupe-scope on apply itself (a saved collection scope does not leak
into delayed apply). After create it also writes a
state/runs/<stamp>-snowball apply.json report (optional witness block) so
an open pack can list the step — dry-run crawls stay queue-only under
state/snowball/.
--seeds-file (or - for stdin) loads DOI or ORCID lists for doi /
orcid. Trailing * on a keyword (polic*) expands client-side into an
OR group; ? and ~ are stripped with a warning. After create,
--dedupe-after classify|apply can write state/dedupe-packs/ (off by
default).
Opt-in --author-site-preflight writes coauthors.json and a proposed
pack under state/author-packs/. paperful authors -C … --apply also seeds a
proposed pack from in-library creator frequency (see commands).
snowball packs promote <slug> makes it available to the author_site grey
lane (grey:author_site). Preflight tries ORCID researcher URLs first.
A CRM and a metasearch can fill the rest; that is
Twenty and SearXNG. On fetch, author_site is a late
lane (after open access and campus, before Scholar). Promote before relying
on the pack. Paperful does not send mail.
One-shot PDFs¶
fetch_pdfs is a mode. The default is off.
Mode |
What happens after create |
|---|---|
|
Metadata only. Fetch later with |
|
First pass on the new keys: configured sources, no vault browser. |
|
That first pass, then the same stack as |
paperful snowball search "BBNJ EIA" --gate auto --fetch-pdfs fast -C "Inbox/Snowball"
paperful snowball orcid 0000-0002-9162-9618 --gate auto --fetch-pdfs full -C "Snowball/me"
Both modes stay on the keys snowball just created. Sci-Hub and Scholar run
only when they are already in sources. full prints the Sci-Hub and
browser-recovery lines when those lanes are in the second pass. If attach
fails, the parent remains and the report counts attach_deferred.
Example profile keyword-library: gate = auto, fetch_pdfs = true (fast),
target_collection set. keyword-scout is the dry-run twin.
doi-refs-gated is approve-batch. orcid-ego-auto creates metadata only.
Stop rules¶
The picture is How a hop is cut.
Knob |
Default |
Meaning |
|---|---|---|
|
0 for keyword, 1 for DOI / ORCID / collection |
Hops from the seed. Soft ceiling 5 |
|
|
|
|
|
On |
|
3 |
How many of a work’s OpenAlex keywords to expand (and how many tag slugs search resolves). Integer 1–5. |
|
50 |
Works kept per seed on the keyword side. A positive integer. |
|
0 |
Drop seed keywords below this similarity. 0 keeps whatever OpenAlex already assigned |
|
200 |
Stops after this many |
|
50 |
Neighbours kept per seed work per hop (references and cited-by). |
|
|
How a numeric |
|
unset |
OpenAlex search on every reference batch and every cited-by request (title, abstract, or full text). Needs refs or cites in the direction, and depth of at least 1 |
|
unset |
Drop candidates outside the window |
|
unset |
OpenAlex study design filter ( |
|
unset |
OpenAlex trusted journal lists ( |
|
journal-article-shaped |
OpenAlex / Zotero types |
|
false |
Metadata filter only. It does not change the PDF chain |
|
0 |
Skip cited-by expansion when the seed is below this count |
|
unset |
Applied when the source has the field. A missing language does not drop the row |
|
5 |
How many top DOI hits |
|
20 |
Largest |
|
|
|
|
|
|
|
|
How many low-yield hops in a row. The rule does not fire before iteration |
|
|
|
|
|
Labeled hops required before that fit |
|
|
|
|
false |
With |
A seed that fails to resolve is status = error. Other seeds continue. The
process exits non-zero if any seed failed, using the existing exit ladder
(2 for config / doctor).
Stopping rules¶
stop_rule is off until you set it. The hop caps above still bound every crawl. Precedence is the usual one: built-in default, then [snowball] in config.toml, then a snowball profile, then flags.
With a rule on and depth greater than 1, the first command fetches hop 1 and waits. Keyword depth 0 fetches the hit list only. hybrid is already one hop, so the rule scores that hop and does not walk further. Mark each new row Keep or Skip, then:
paperful snowball continue <run-id>
The next hop expands only from kept new rows that have an OpenAlex id or a DOI. Screened rows are status = new on the open hop. exists, filtered, and error are not that batch. approve-batch does not pre-mark those rows as Skip while a rule is on, so they stay unlabeled until you set Keep or Skip.
--gate auto on continue counts every unlabeled new row as Keep. Discover stays a dry-run search. Advanced Graph has Stop rule, Stop eps, Stop k, and Stop max screened. Next hop appears on the queue while the run is waiting.
CLI flags on search, hybrid, doi, orcid, collection, run, and profile save: --stop-rule, --stop-eps, --stop-k, --stop-max-screened. stop_tau and stop_min_points are config and profile keys only.
continue is not resume. resume finishes a crawl that OpenAlex paused (deferred.json). continue refuses while that file is present. If the pause happens on a later hop, resume first, then continue.
Rule |
Fires when |
|---|---|
|
New Keeps / screened rows stay below |
|
Cumulative Keeps reach |
|
Screened new rows reach |
|
The first of those three, or an empty kept frontier |
|
No kept work left to expand. Recorded on every hop. Stops the crawl even when another rule is selected |
Each hop appends one line per rule to state/snowball/<run-id>/stops.jsonl (paperful.snowball.stop.v1), including rules that did not fire. The stopping sentence is in stop.json (rationale) and is meant for a methods section. summary.json records stop_rule, stop_awaiting_labels, and stopped_by. A depth ceiling or a full max_candidates budget stops the walk as well and says so (stopped_by depth or max_candidates).
This is the saturation idea from SnowBallSLR, reimplemented here. It does not estimate how much of the literature you have found.
Progress¶
A long crawl keeps a progress bar at the bottom of the terminal, the same bar run and attach use. The label is coloured by step: cyan for hops and references, blue for cited-by, magenta for Crossref and Semantic Scholar, green while items are created, cyan while PDFs are fetched. When the step has a known length (reference ids, cited-by seeds, rows to fill, items to create, PDFs to fetch) the bar shows done/total. Otherwise it spins. A stage line is printed when the step changes (hop 1/2 · 40 seeds · both). When the run finishes, one summary line remains:
hop 1/2 references · 80 ids · 40 searches · 800 papers
crossref · 30 searches · 15 fields updated
fetching PDFs · 4 PDFs
creating · 12 created
Counts start over when the step changes, so a references hop does not repeat fill or PDF totals. searches counts reads in that step (OpenAlex, Crossref, or Semantic Scholar). papers counts OpenAlex works returned. fields updated counts empty title, year, venue, or author fields filled. PDFs counts files saved while fetch_pdfs runs. created counts items written while they are created.
Candidate record¶
Each run writes state/snowball/<run-id>/candidates.jsonl and summary.json.
The summary counts requests, HTTP 429s, retries, and rows by status, backend,
and hop.
Schema paperful.snowball.candidate.v1:
Field |
Contents |
|---|---|
|
Version and run id |
|
|
|
Integer. Search hits are 0. References of a seed are 1 |
|
|
|
Normalized doi, openalex, s2, pmid, orcid, when known |
|
Title, year, authors, venue, type, optional OA url, optional |
|
Short reason, for example |
|
|
|
When |
|
Backend, endpoint, |
|
The gate for this run |
|
|
The dry-run table shows title, year, DOI, why, in library, hop/direction,
and backend, with new rows first. Created items are tagged
paperful-snowball, paperful-snowball:<backend>, from-<seed-slug> from
the candidate’s seed, [snowball].default_tags, and any --tag values. A child note holds the
seed, direction, hop, why, run id, and schema version when note_provenance
is on. API keys and the mailto address never go in that note.
A separate readable line follows [remarks].surface (a child note tagged
paperful-linked by default):
“Cited by 4 papers in this collection.” when at least one item already in the target collection lists this work in its references. Paperful fetches those OpenAlex reference lists once, caches them under
state/cites/, and reuses the cache while the collection’s DOIs stay the same. If the OpenAlex budget runs out, the item is still created and this sentence is skipped.When the hop is at least 1 and at least two seeds from this run point at the work: “In the bibliography of 2 of the papers you started from.”, “Cites 2 of the papers you started from.”, or “Linked to 2 of the papers you started from.” Search hits (hop 0) get only the collection sentence.
BibTeX / RIS from the queue¶
Turn a dry-run queue or watch inbox into bibliography text without creating parents in Zotero:
paperful export-proposals state/snowball/<run-id> proposals.bib
paperful export-proposals state/snowball/watches/<name> watch-inbox.ris
Accepts a run directory (candidates.jsonl), a watch directory (inbox.jsonl),
or a path to either file. Rows need at least a resolved DOI or a title in
biblio. Agents can call MCP proposal_export with the same pack path and
format bibtex or ris (bibliography text lives in the JSON envelope
summary.bibliography). Library-scoped export of items you already own stays
paperful export (or MCP export).
snowball search … --format json on a dry-run includes summary from
summary.json when the in-memory crawl summary is empty (paths.run points
at the queue folder).
Publication trends (read-only)¶
Count OpenAlex works by publication year for a keyword query or a saved search/hybrid profile seed. Does not open the library or create parents:
paperful snowball trends "high seas biodiversity"
paperful snowball trends --profile my-seed --format json
MCP snowball_trends uses the same envelope. Distinct from watch / digest
(frontier arrivals since baseline).
Config¶
Precedence matches run profiles: built-in
default, then [snowball] in config.toml, then profiles/<name>.toml,
then flags on the command.
[snowball]
enabled = false # doctor and the CLI refuse snowball until true
depth = 1 # keyword runs still default to depth 0
direction = "refs+keywords"
search_keywords = true # search/hybrid also unions OpenAlex keyword tags
max_candidates = 200
per_hop_limit = 50 # all keeps every neighbour of a seed
per_hop_rank = "most-cited" # most-cited | least-cited | random
gate = "dry-run" # dry-run | approve-each | approve-batch | auto
target_collection = ""
dedupe_scope = "library" # library | collection | none
fetch_pdfs = "off" # off | fast | full. true means fast
tag_prefix = "paperful-snowball"
default_tags = []
note_provenance = true
backends = ["openalex", "crossref", "semanticscholar", "orcid", "europepmc", "pdf"]
languages = [] # empty = do not filter; a missing language is kept
min_seed_citations = 0
hybrid_seeds = 5
approve_each_max = 20
refine = false # suggestions only; needs [llm].enabled
dedupe_after = "off" # off | classify | apply
author_site_preflight = false
author_site_max_authors = 15
author_site_max_queries = 20
stop_rule = "off" # off | marginal_yield | asymptotic_coverage | budget | any
stop_eps = 0.01
stop_k = 2
stop_tau = 0.95 # config and profile only; no CLI flag
stop_min_points = 4
stop_max_screened = 0 # budget; 0 means no cap
[searxng].base_url (or SEARXNG_BASE_URL) is an optional metasearch for
author-site remainder discovery. It is never in sources by default.
What that engine is, and which hosts answer it:
Twenty and SearXNG.
enabled = false until you opt in, the same posture as [llm]. doctor
reports that flag, whether keys are present, and whether a backend answers.
A missing OpenAlex key warns. A missing Semantic Scholar key does not, and neither missing key aborts the other backends.
email at the top of config.toml is the contact address for Unpaywall and Crossref.
For heavy crawls, put a free OPENALEX_API_KEY in the environment (never in
config.toml). Limits, resume after budget, Semantic Scholar keys, and an
optional local or hosted OpenAlex parquet snapshot:
Advanced.
Each crawl writes state/snowball/<run-id>/candidates.jsonl as it goes, for every configured backend. A rate limit, outage, or interrupt keeps that file and, when OpenAlex stops the crawl, deferred.json. Continue with:
paperful snowball resume <run-id>
If the queue is already complete and deferred.json is gone, the same command does not search again. With --gate auto, -C, and --fetch-pdfs, it creates any rows still missing from the library and fetches PDFs for keys that do not already have one.
A keyword search that asks for more than 5,000 works follows OpenAlex’s cursor instead of stopping at 50 pages.
Short 429s and 5xx responses retry with exponential backoff. A reset of a minute or more does not keep polling until midnight.
snowball profile save writes seeds and knobs only, after a successful
dry-run, or with --force. It refuses to store a key. Repeat --doi or
--orcid for several seeds (dois = [...] / orcids = [...] in the profile).
Backends resolve in the configured order and emit each work once, keyed by DOI.
The default is OpenAlex, then Crossref, Semantic Scholar
(with or without a key), ORCID (the person’s own work list), Europe PMC,
then an open-PDF bibliography. Reorder backends when a key or proxy makes
a later source the better first try. OpenAlex wins when it and a later backend
disagree on a field that both filled. A 429 or 5xx pauses that backend and the pass
continues with the next one. After the pass, each paused backend is tried once more
on only its remaining DOIs. DOIs still blocked stay in deferred.json. Items no
paused backend still lists are created, and their PDFs are fetched when fetch_pdfs
is fast or full. An open PDF already downloaded for the bibliography is written
onto the item. Semantic Scholar responses are cached under state/snowball/cache/. Europe PMC
misses and hits are cached under state/snowball/cache/europepmc/. Crossref,
Semantic Scholar, Europe PMC, and the open-PDF bibliography ask only for hop-0
works that still have no outgoing references, and only when this crawl is
hopping (depth 1 or more, or hybrid). A keyword search at depth 0 stays
on the hit list: those backends may fill an empty field on a hit, and they
do not import its references. Neighbours and citing works are left alone. A
backend that adds references closes the gap, so the next one is not asked.
A reference title is the structured article title. The raw citation string
is not stored as the title. Before the year filter, a neighbour with a blank
or citation-shaped title is filled from the OpenAlex work. If that still
leaves no work title, the row is filtered and not created. Europe PMC searches
those gaps in batches, and fetches a reference
list only when the record has one. A MEDLINE id with no DOI is resolved when
Europe PMC has a DOI for it. When a spread of the remaining queue is absent
from the index, the rest of that pass is skipped.
backends must include openalex. An unknown name is a config error. Dropping
orcid skips the public works list and keeps the OpenAlex author filter.
When OpenAlex lists no referenced_works for a seed, a refs hop does not treat
that as “cites nothing”. It asks Semantic Scholar, then Europe PMC, then a
publisher landing page (Notes / References HTML), then an open PDF bibliography,
and resolves entries back to OpenAlex (DOI match, or a strict title match). The
Europe PMC lookup is the same cached search as the fill pass: a list is fetched
only when the record has one. A pause on one of those sources does not stop the
hop. Cited-by stays OpenAlex-only. Book chapters often deposit zero Crossref /
OpenAlex references while still listing footnotes on the public landing; the
HTML stage recovers DOI links from that page without needing a PDF.
snowball run --profile NAME prints that profile’s one-line description
before any request. mode is search, hybrid, doi, orcid, or
collection. DOI profiles store dois = [...]; ORCID profiles store
orcids = [...] (legacy singular orcid still loads). Keyword profiles store
query = "..." for one term, or queries = [...] for several; query_op = "or"
matches any term (default is AND). snowball profile save --query … --hybrid
stores mode = "hybrid"; repeat --query and pass --or when saving.
--refine (or refine = true on a profile) asks the configured model for
query strings and prints them. If [llm] is off, or the call fails, the crawl
still finishes and summary.json records suggestions_error. Suggestions are
not seeds.
Advanced¶
Rate limits, API keys, a CRM, a metasearch, and hosting a snapshot. Skip this for a small dry-run.
Twenty and SearXNG¶
Neither is on for a normal library fill. Use them when you already keep author pages in a CRM, or when you want a metasearch to find a personal site after ORCID has none.
Twenty¶
Twenty is an open-source CRM: people, companies, and notes. You can use Twenty Cloud or a workspace you host yourself. The product and the API are in the Twenty docs.
Paperful talks to People in a workspace you already run. It needs
[twenty].enabled, [twenty].base_url (or TWENTY_BASE_URL), and env
TWENTY_API_KEY. It never sends mail. Knobs:
config.
paperful twenty lookupreads People.--applywrites a proposed author pack andstate/author-contacts/only. It does not change the CRM.paperful twenty sync --applycreates a Person for a unique author who is not already a unique match, and enriches a unique match. A blank homepage or email is filled. Extra pages and emails are appended. A primary homepage or email you already set is left alone. Ambiguous names and corporate creators (FAO, and other single-field names) are skipped. New and updated People get the keywordpaperful, the collection slug (for exampleocean-bbnj), and a note titled Paperful.--limitcaps the batch. Fifty or more creates ask on a terminal unless you pass--yes. A resume ledger understate/twenty-sync/skips fingerprints already written.On fetch,
author_siteruns late (after open access and campus, before Scholar). The listing comes from a promoted pack, then the contact cache, then a capped live People lookup ([twenty].fetch_listing_max, default 20) for items that still have no PDF. That lookup does not create People.--twenty-writeback(or[twenty].writeback_listings) appends a personal page found by SearXNG or by a successfulauthor_sitefetch onto a unique Person. It does not create one, and it does not replace a primary link.[twenty].lookup_on_preflightcan add CRM websites to a snowball--author-site-preflightproposed pack. It does not callsync.
reachout --lookup uses the same People search for a contact CSV. It still
does not send mail.
SearXNG¶
SearXNG is a metasearch engine, the maintained
fork of SearX. It asks other search engines
and returns their hits. Paperful uses it in one place: author-site preflight,
after ORCID researcher URLs and any Twenty or cache listing, to find a
personal or faculty page. It is not a run source and it is not in
sources by default.
Set [searxng].base_url or SEARXNG_BASE_URL to an instance that answers
GET /search?format=json. Public instances are listed at
searx.space. Many of them leave JSON off, so a host
from that list works only when its search API
includes json. Otherwise run your own
(install) and enable the
JSON format in its settings. Paperful does not ship an instance.
A hit is cached under state/snowball/cache/searxng/. With
--twenty-writeback, a page found this way can be appended to a unique
Twenty Person, as above.
OpenAlex and Semantic Scholar keys¶
OpenAlex ignores mailto for budget. Snowball calls OpenAlex without an API
key first, so a small search needs no account. That free allowance is shared by
everyone on the same public address (a campus network or VPN exit included) and
is about a tenth of a free key. When it runs out, a configured
OPENALEX_API_KEY takes over for the rest of the crawl. With no key, the
partial queue is kept and paperful snowball resume continues after you add
one. One key only: a second free key or a user+tag@gmail.com alias does not
add budget. A free key is about $1/day. Past that, OpenAlex sells
pay-as-you-go credit and subscriptions.
A list call that hits the daily budget of the key already in use stops the same way. The reset is midnight UTC, or sooner if you add credit on that key.
Semantic Scholar’s Academic Graph is public, so snowball calls it with no key.
That unauthenticated pool is shared and can be throttled. Heavier use needs a
private key: request one
(it arrives by email; the introductory limit is 1 request per second). Put it
in SEMANTIC_SCHOLAR_API_KEY. Like OPENALEX_API_KEY, it stays in the
environment, never in config.toml.
OpenAlex parquet snapshot (optional)¶
If API limits or shared-IP budgets are the bottleneck, download the
OpenAlex snapshot yourself — on
your computer or a host you control — and configure [openalex_store]. Paperful
does not ship the dump. Backends: local_duckdb (this machine) or
ssh_duckdb (DuckDB on the data host). DOI / OpenAlex-id batches use the works
parquet. Citing (citation-map expand, --direction cites) needs
paperful openalex-store build-cites then cites_index_glob (resumable per
works updated_date). Live API is first; the snapshot is the fallback when
the daily budget is spent. Store cites-query matching is title-only (boolean
OR / AND / NOT stay on the API). Fallback lookups that miss timeout_s
(default 120s) count as failures; after fail_limit (default 3) the run skips
further snapshot calls. Keywords / ORCID / full-text search still hit the live
API. Citing with study_designs or listed_in also stays on the live API
(older dumps may lack those fields). When the API daily budget is spent,
DOI/id and citing keep using the snapshot unless those filters are set; the
rest pause for resume.
Setup and TOML: config Advanced. Roadmap for full search parity and campus HTTP hosting: ROADMAP.
Where the crawl comes from¶
The personal site already does this hop for a people graph, not for Zotero:
Site |
Snowball |
|---|---|
|
One-hop |
|
|
|
Optional Semantic Scholar fill, disk cache under |
|
DOI, ORCID, and title normalization, aligned with |
No Zotero writes |
|
Leave on the site: the people-graph JSON, the hard-coded ego slug, Jekyll
outputs, and scholar_citations.py.
What to reuse elsewhere¶
OpenAlex is the graph (search, author, referenced_works, cites:). The
ORCID public API is each person’s own works; those lists are often incomplete,
so OpenAlex expands them. Several ORCID iDs in one command merge hop-0 works
before a shared hop expand. Semantic Scholar references and citations fill gaps
on the public API; a key is only for heavier use. Crossref, already used to verify DOIs, fills metadata
gaps. It is not the forward-citation graph.
Adjacent tools, and the piece worth copying:
Tool |
Copy |
Leave there |
|---|---|---|
|
The package, and any Scopus / Web of Science / IEEE requirement |
|
— |
DOI-list PDFs when you have no Zotero. See comparison |
|
Flag ideas |
The review project, the report, the screener |
|
Overlap rank: neighbours score |
The network UI |
|
— |
In-Zotero one-hop dialogs |
|
— |
A second HTTP client. Snowball extends the client Paperful already uses for OpenAlex (mailto, sleep, backoff) |
ResearchRabbit, Litmaps, Connected Papers, Inciteful, Elicit, Consensus,
Scite, and Lens.org stay outside. --refine can suggest further queries behind
[llm]; it does not pick seed DOIs. Google Scholar stays the
existing PDF lane. It is not a snowball source. Screening a RIS file stays
with ASReview. Created items keep clean creators, DOI, and date so Better
BibTeX citekeys still make sense. Snowball does not depend on that plugin.
Citation map¶
Explore (Discover panel) seeds a tiny queue from one DOI or title, auto-expands one hop, and opens Map for ad-hoc hops and Add to library. Seed search on a map node fills the Topic form (DOI kind + seeds) for a full snowball run.
Discover Map draws the current queue before you open PDFs. Node colour is
year × cited_by_count:
Pattern |
Role |
|---|---|
Older + highly cited |
foundational |
Recent + highly cited |
influential |
Recent + fewer citations |
emerging |
Highly cited means at least 50 citations, or the median of the non-seed papers on this map when that median is higher. Recent means the last five years.
Literature categories are a second layer (badges and filter chips), orthogonal
to roles. Defaults map OpenAlex study_designs and work type onto:
Category id |
Matches |
|---|---|
|
study designs: systematic-review, meta-analysis |
|
study design: randomized-controlled-trial |
|
study design: clinical-trial (non-RCT interventional; RCTs may also carry this when OpenAlex dual-tags) |
|
study design: observational-study |
|
study design: case-report |
|
study design: study-protocol |
|
type: review |
|
type: preprint, posted-content |
|
type: book, book-chapter, edited-book |
|
type: editorial, letter, paratext |
Config [snowball].map_categories chooses which ids appear.
[snowball].map_category_defs adds custom rules (any_study_designs and/or
any_types). Graph JSON carries category_legend, and each node has
study_designs plus categories. Untagged OpenAlex works stay uncategorised.
Click a paper to Keep or Skip it, or to expand one hop (papers it cites, or papers that cite it). Expand merges neighbours into the same dry-run queue. It does not create Zotero items. The drawing code follows the personal-site force layout (zoom, drag, hover focus, side panel); nodes here are works, not people.
Library on the map. Nodes already in the library show a LIB badge and
a dark ring. The side panel lists item_key, whether a PDF is on disk, and
(when [ui].citation_map_edit is not off) library actions:
Action |
Behaviour |
|---|---|
Add to library |
Creates one parent (uses staged collection/tags/note, else the collection chip) |
Find PDF |
Runs Grab for that one |
Remove from library |
Trashes the item ( |
Save tags / note / collection |
Stages on the queue row; applies live when the paper is already in the library |
Open in Zotero / Summary |
Deep links when |
Defaults: citation_map_edit = both, citation_map_auto_search = false
(Find PDF after Add is opt-in), citation_map_remove = trash.
state/snowball/<run-id>/citation_graph.json is paperful.citation_graph.v1,
rebuilt from candidates.jsonl. Hops store biblio.graph_links so an edge
knows which paper it came from. The map caps at 150 nodes. Keyword and similar neighbours appear on the map
without citation edges — only refs/cites hops draw links. Use Repair links
on Discover when an older queue is missing graph_links. Optional overlays:
read-first triage, a year scrubber, path-between-two-papers inside the current
graph, library neighbours (ghost nodes from your mirror, not added to the queue),
and watch/compare badges when you open Discover with ?watch_name= or
?compare_run=.
Out of this lane¶
A hosted discovery service or corpus-scale OpenAlex browser (Explore is one seed + hops, not the whole public graph)
A built-in scheduler or cron helper (call
watch runyourself), or a snowball step insidepaperful allA dedicated screening TUI. Discover Keep/Skip and Next hop are the reviewer when a stopping rule is on
Sci-Hub or Scholar as ways to discover works
Pulling every publication of every cited author, until an explicit capped switch exists