anthropic-research¶
Local-first RAG archive of Anthropic's published research.
Crawls the research corpus from Anthropic's sitemap, archives each entry (HTML +
any Anthropic-hosted full-paper PDF) as browsable local files, indexes it
semantically, and exposes it to coding agents through MCP tools — with citations
and local file paths. A sibling to calibre-librarian, sharing the
same store / embed / retrieve architecture.
Requirements¶
- Python 3.11+
- Ollama with
nomic-embed-textfor embeddings - Network access to
anthropic.comfor crawling
Install¶
Configure¶
Defaults work out of the box: the archive and index database live under
tool-chest's shared private data root (tool-chest.toml's [workspace].data_root,
default ~/tool-chest-data), at <data_root>/research/archive and
<data_root>/research/index.db. To override, use the workbench (Settings → Research channels) or
research config set; the values are kept in <data_root>/config/research.toml under [general]. The old
~/.config/research/config.toml is no longer read (make config-migrate moves it):
[general]
archive_dir = "~/tool-chest-data/research/archive"
db = "~/tool-chest-data/research/index.db"
embedding_model = "nomic-embed-text"
ollama_base_url = "http://localhost:11434"
include_pdfs = true
fetch_delay = 1.0 # seconds between requests (be polite)
Usage¶
# List every research entry discovered in the sitemap (no crawl)
uv run research show-scope
# Plan a sync without fetching anything
uv run research sync --dry-run
# Crawl, archive, and index the whole corpus (needs Ollama running)
uv run research sync
# Archive + chunk without embeddings (no Ollama required)
uv run research sync --no-embed
# Index a single entry (useful for testing)
uv run research sync --only alignment-faking
# Search from the CLI
uv run research search "alignment faking in language models" --limit 5
# List what's indexed
uv run research list
# Start the MCP server for Cursor / Claude Code
uv run research serve
What gets archived¶
For each /research/* entry, research sync writes to
archive_dir/<slug>/:
| File | Contents |
|---|---|
source.html |
Raw fetched HTML (redirects followed) |
article.md |
Extracted main-content markdown + PDF text |
paper.pdf |
The Anthropic-hosted full-paper PDF, when linked |
meta.json |
URL, title, date, references, fetch timestamp |
The SQLite index (db) holds chunks + embeddings and points back at these files.
Re-running sync only re-fetches entries whose sitemap lastmod changed.
External venues (arXiv, transformer-circuits.pub) are recorded as reference
links, not crawled.
MCP tools¶
| Tool | Description |
|---|---|
search_research |
Semantic search over the archived corpus |
get_passage |
Retrieve a passage with surrounding context |
list_articles |
List indexed research entries |
Scanning inbound material (research.scan)¶
Anything that enters the corpus is meant to be scanned by a deterministic step before an agent can retrieve it
(#80). research.scan.scan() takes the original bytes, the declared type, a document name and the extracted text
per unit (a PDF page, or an HTML/markdown section) and returns flags: wrong file type, size and
decompression-bomb limits, invisible/bidi/tag characters, known injection phrases, and obfuscated variants of them
(leetspeak, spaced letters, base64). It is a tripwire, not a wall: heuristic, with real false negatives. The
sandbox around extraction (research.sandbox) is the strong control.
| Level | Meaning | What a caller does |
|---|---|---|
| high | a verbatim known phrase, or a structural problem | stage it (#103) |
| medium | an obfuscated variant, or the scan itself failed | stage it |
| low | a suggestive word, or an invisible character alone | archive it, record the flags |
The rules and each rule's historical false-positive rate (the share of the trusted archive's entries it fired on)
are in src/research/scan_rules.toml. To re-measure after changing a rule:
research scan-archive # report only
research scan-archive --write-rates # also record the rates in the rules file
A rule that fires on a large share of real entries is demoted or dropped, not shipped (see the thresholds in
scan_archive.py). The scan decides nothing by itself; a flag on a security article that quotes an attack is
expected and is what the human review step is for.
The staged tier (research staged)¶
Material that is not yet trusted for the corpus (flagged by the scan at medium or high confidence, or, later, found by
an agent) is held in <data_root>/research/staged/<slug>/: the archive's file shape plus a staged.json saying why,
when and from where. It is excluded by construction: a staged entry has no row in the index and no directory
under the archive, so search, passage, list and every MCP tool cannot return it. There is no "staged" flag on an
index row that a query could forget.
research staged list # what is held, and why
research staged show <slug> # its flags (with short excerpts) and where its files are
research promote <slug> # a human override: moves it into the archive and indexes it
research reject <slug> --yes # deletes it (an untrusted fetch can be regenerated); without --yes nothing happens
research is not on your PATH; from the repository root use make research-staged to list, or
make research ARGS="staged show <slug>", make research ARGS="promote <slug>" (needs Ollama) and
make research ARGS="reject <slug> --yes". Each runs the command in the research project's own environment.
promote records promoted_at and the overridden scan flags in the entry's meta.json. Read the entry's
article.md in the staged directory before promoting; the point of the tier is that a person looked.
Reviewing a batch: the workbench. The primary way to review is Library → Staged review in the workbench
(/next/library/staged): every staged entry on one page with why it is staged and each flag's rule, level, location and
excerpt, a checkbox per entry, select all (never including agent-found entries), filters by rule and by reason, the
text around each flag on demand, and Promote or Delete for the selection (delete asks once for the whole
selection; a promote is refused up front if Ollama is unreachable, and one failing entry does not stop the others). The
page calls research review list|detail|promote|delete (JSON on stdin, by slug); it is deliberately not a bulk CLI for
people, and no MCP tool lists, promotes or deletes staged entries (a test lists the tools).
Adding a file by hand: the inbox (research ingest-inbox)¶
For something the maintainer already has (a paywalled paper, a file received directly), drop it in
<data_root>/research/inbox/ with a sidecar next to it. Copy sidecar.txt.template (written into the inbox the
first time you run the command), name the copy <file>.sidecar.txt, and fill in url, title, published, note.
Each file with a sidecar goes through one inbound path: the original bytes are hashed (original_hash, so the same
file is ingested once), the text is extracted inside the sandbox-exec helper (no network, writes confined to a
scratch directory; if it is unavailable the file is refused, never run unsandboxed), the scan runs, and the entry is
archived and indexed, or staged if the scan flagged it at medium or high confidence. Low flags only are
archived with the flags recorded in meta.json (scan_flags). Entries carry channel = manual-inbox.
- No public URL? Leave
urlempty. It is recorded as "no public URL: manually supplied" and shown without a link. Do not invent an address: a value that is not a real http(s) address makes the file be left in place. - A file with no sidecar, an unreadable or invalid sidecar, an unsupported type (PDF, HTML, markdown and plain text
are accepted) or a size over the limit is reported and left where it is. Processed files move to
inbox/processed/.
Adding a page or PDF by URL (research add-url)¶
research add-url https://example.org/paper.pdf
research add-url --staged https://a.example/x https://b.example/y # agent-suggested: always staged
The fetch is the risky step, so it refuses to be pointed at the machine itself: only http and https, no
credentials in the address, and the host must resolve only to global addresses (no loopback, private, link-local
or cloud-metadata ranges, and not through a redirect either: every hop is re-checked, and the connection goes to the
address that was checked). The body is size- and time-capped, and only PDF, HTML, markdown and plain text are
accepted. After the fetch it is the same path as the inbox: hash, sandboxed extraction, scan, archive-or-stage, with
channel = manual-url.
Agent research: pointers only¶
An agent doing research does not add to the corpus. It returns a report of candidate URLs with a one-line reason
each, as ordinary output. A person decides to submit them with research add-url --staged, and everything
submitted that way is staged for review whatever the scan says. The agent has no tool that writes to the corpus
or the staged tier (the research MCP has read tools only, and a test keeps it that way), and the text always comes
from the deterministic pipeline, never from the agent. A prompt-injected agent can at worst suggest a bad URL, which
the address checks, the size cap and the scan then handle.
Channels, and where an entry came from¶
A channel is three steps: an optional discover (what is new, used only by scheduled polling), a fetch and an
extract. The Anthropic site is the first channel (research.channels.AnthropicSiteChannel); the inbox and URL
routes skip discovery. Every entry records its channel (anthropic-site, manual-inbox, manual-url, ...) and the
original_hash of its original bytes in meta.json, and the channel is carried into the index and shown in search
results, list and the MCP tools, so the corpus can be filtered or labelled by origin.
A full research sync only ever purges entries of its own channel. Entries that arrived by hand are not in the
sitemap, so they are left alone.
Entries made before channels existed have neither field. Fill them in once, with no network, from the stored copy:
research backfill-channels --dry-run # what would change
research backfill-channels # sets channel = anthropic-site and original_hash = sha256 of source.html
original_hash here is the hash of the stored source.html, not of a re-fetch: if the site has changed since,
this records what the archive actually holds. The command is idempotent and leaves complete entries byte-for-byte
alone; an entry with no source.html is reported, not guessed.
arXiv, the first scheduled channel (research poll-arxiv)¶
Saved feeds live in the private <data_root>/research/feeds.toml (never in the repository). A missing file means
no feeds, and no error.
[[feed]]
name = "ml" # unique; letters, digits, . _ -
category = "cs.LG" # exactly one of: category, author (Hinton_G) or a raw arXiv query
max_results = 25 # optional, 1 to 100: how far back each poll looks
enabled = true # optional; false pauses the feed (the poll skips it and counts it as paused)
[[feed]]
name = "interp"
query = "cat:cs.LG AND ti:interpretability"
You can edit the list in the workbench (Settings → Research channels) instead of by hand. The form asks the
research CLI, which holds every rule: research feeds list|validate|set (JSON on stdin, --data-root). The query is
checked as you type (known field prefixes ti au abs co jr cat rn all submittedDate, closed quotes and parentheses,
AND/OR/ANDNOT, the length cap) and the exact request URL is shown. A save replaces the file atomically (mode
0600, previous version kept as feeds.toml.bak) and does not keep hand-written comments. The caps (20 feeds, 100
results per feed, 300 characters) are the research settings limits.*; poll-arxiv refuses a file that breaks them.
Poll now starts the arxiv-poll automation, nothing else.
research poll-arxiv --dry-run # what would be ingested; no PDF is downloaded
research poll-arxiv # query the feeds, download new PDFs, archive or stage each one
Each feed is queried with sortBy=submittedDate (newest first). The poller remembers what it has seen per feed
(arxiv-state.json) and does not ingest a paper twice: not by arXiv id (a new version of a seen paper is not
re-ingested), and not by the hash of the PDF bytes. New papers go through the same path as the inbox and URL routes,
with channel = arxiv; one flagged by the scan, including its title or abstract, is staged for review. At most 20
new papers are ingested per run; the rest wait for the next. A paper whose download fails is retried next run.
Politeness is binding, not a courtesy. arXiv's terms of use say to "make no more than one request every three seconds, and limit requests to a single connection at a time". Requests are strictly sequential and at least three seconds apart, across the query API and the PDF downloads alike. The Atom response is refused if it declares a DTD or an entity (a feed has no reason to), every id is validated against arXiv's id grammar, and the PDF address is built from the validated id, never taken from the response body.
It is built as an outbound = true automation (make automations-arxiv-poll; see workflow/automations), so the
auto-pause rule applies: three failures in a row pause it.
Ollama must be running when it polls: papers are embedded for search as they are indexed. The embedder is checked once before anything is fetched; if it is not reachable the run fails (it fetches nothing, and says to start Ollama) rather than downloading PDFs it cannot index. A scheduled run needs the Ollama app to be up at that hour.
Storage posture (decided 2026-10-11)¶
This channel stores the full PDF, its extracted text and its metadata under the private data root, for the maintainer's own use and for reading by the maintainer's own agent sessions. Nothing is committed, published or redistributed. arXiv's terms of use for its APIs (read at build time, 2026-10-11) say you must not "Store and serve arXiv e-prints (PDFs, source files, or other content) from your servers, unless you have the permission of the copyright holder or are permitted to do so by the license", and that you should "Direct users to arXiv.org to retrieve e-print content". They also say you may "Retrieve, store, transform, and share descriptive metadata". The maintainer's reading is that downloading papers for personal use, and never serving them to anyone else, is within those terms. That is the maintainer's reading, not a confirmation from arXiv, and it holds only while the condition does: do not share this archive, serve it to other people, or publish any index of it. If that ever changes, revisit this first; the fallback is metadata and abstract only, keeping the PDF link.
Privacy & etiquette¶
- All archiving, embedding, and search runs locally.
- Crawling respects
robots.txt(Anthropic allows it), identifies via a descriptive User-Agent, and rate-limits withfetch_delay.