pepernoten guide

How to set pepernoten up (laptop vault → private notes repo → MCP anywhere), then everything a contributor needs to understand, extend, or debug the codebase. The README has the condensed version.

Getting started

The full setup is three steps: run the CLI where your notes live, back the notes with a private GitHub repo, then mount the MCP server from any machine.

1Run it on your laptop

This is where your notes live, so you can read them in Obsidian. Launch the CLI and let it guide you through parsing your first papers:

uvx --from git+https://github.com/ameroyer/pepernoten pepernoten

LLM access comes from a logged-in claude CLI (nothing to configure), or from OpenRouter via export OPENROUTER_API_KEY=sk-or-.... Point Obsidian at the pepernoten directory — parsed notes land under Research/. Bonus: authenticate scholarinboxcli (see the README) and the inbox command pulls your Scholar Inbox digest straight into the vault.

2Put your notes in a private GitHub repo

This is what lets you reach your notes from anywhere. Research/ is gitignored in pepernoten itself, so make it its own repository — nested repos are fine:

# create the empty private repo first: gh repo create USERNAME/pepernoten_notes --private
cd /path/to/pepernoten/Research
git init -b main
git remote add origin git@github.com:USERNAME/pepernoten_notes.git
git add -A && git commit -m "backup" && git push -u origin main

# repeat the last line whenever you want to sync

Then mint a token for the servers: go to github.com/settings/personal-access-tokens, create a fine-grained token scoped to just this repo, and grant the Contents permission — Read and write for the full server, Read-only if you only want consultation. Copy it somewhere safe; GitHub shows it once.

3Mount the MCP server anywhere

On a pod, a second machine, or the same laptop — the server pulls your notes repo, so nothing else needs to be installed:

claude mcp add pepernoten \
  --env PEPERNOTEN_GITHUB_REPO=USERNAME/pepernoten_notes \
  --env PEPERNOTEN_GITHUB_TOKEN=github_pat_... \
  -- uvx --from git+https://github.com/ameroyer/pepernoten pepernoten-write-mcp

That is the read+write server: Claude can search and read your notes and parse new papers or create topic surveys, pushing each change back to the notes repo as a pepernoten: … commit. Swap the entry point for pepernoten-mcp (and a read-only token) if the machine should only consult. Mount one server, not both.

Note the two repos involved: ameroyer/pepernoten is the tool, only referenced when launching; USERNAME/pepernoten_notes is yours and holds the notes. The token belongs to the notes repo only.

Hacking on it

git clone https://github.com/ameroyer/pepernoten.git
cd pepernoten
uv sync
export OPENROUTER_API_KEY=sk-or-...   # optional with a logged-in claude CLI

# Round-trip a paper end to end
uv run scripts/parse.py parse https://arxiv.org/abs/2405.12345 --verbosity=1

# Lint, type-check, test — same checks CI runs
uv run ruff check .
uv run pyright
uv run pytest

LLM access comes from OpenRouter, or from the claude CLI when it is installed and all configured models are Claude models (llm.resolve_api_key decides per call site; keyless mode disables the vision figure-selection step, which runs on OpenRouter). Models are picked via PEPERNOTEN_MODEL, PEPERNOTEN_EXTRACTION_MODEL, and PEPERNOTEN_VISION_MODEL (OpenRouter IDs; defaults in src/llm.py).

The vault is the project root — Obsidian points at the repo, and parsed notes land under Research/. There's no separate install step to package or deploy: everything runs in place with uv run.

Design principles

Minimalism
No feature added speculatively, no abstraction before it's needed three times.
Cleanliness
One responsibility per file, no cross-layer imports going the wrong way.
Modularity
A single path constant, a single LLM client, a single index format — used everywhere.

Repository layout

pepernoten/
├── pepernoten_cli.py       REPL entry point (questionary + rich UI)
├── pepernoten_mcp.py       read-only MCP server (also defines the shared read tools)
├── pepernoten_write_mcp.py read+write MCP server (read tools + parse/add_topic)
├── pepernoten_prompts.yaml user-configurable prompt semantics
├── pyproject.toml          dependencies, entry points, ruff + pyright config
│
├── src/                    LIBRARY — pure modules, no fire.Fire, no __main__
│   ├── vault.py              vault root resolution, index I/O, tag index, topic file I/O
│   ├── mcp_backend.py        read backends (local vault / GitHub API, read-only)
│   ├── workspace.py          write workspaces (local vault / git-synced GitHub clone)
│   ├── llm.py                provider dispatch, model defaults, call() / call_json()
│   ├── arxiv_utils.py        rate limiter, HTML fetch, ID extraction, Scholar Inbox
│   ├── notes.py              note parsing, topic matching, backlinks, related-work extraction
│   ├── figures.py            HTML + PDF figure extraction, vision-assisted banner selection
│   ├── prompts.py            all LLM prompt builders + shared grounding rules
│   ├── paper.py              process_arxiv_paper — the single-paper end-to-end pipeline
│   ├── pipeline.py           batch orchestration: process_batch, parse_papers, topic updates
│   ├── topics.py             topic surveys: citation grounding, add/init/update/discover/merge
│   └── bibtex.py             BibTeX lookup (PWC → CrossRef → Semantic Scholar → DBLP)
│
├── scripts/                CLI WRAPPERS — thin fire.Fire entry points, no logic
│   ├── parse.py               parse / sync / reparse_all / backlink_all
│   ├── topic_manager.py       fire wrapper over src/topics.py
│   └── bibtex.py              generate / batch / clear_cache
│
└── tests/                  pure-logic unit tests (no network, no LLM calls)

Import graph

No cycles. src/ modules never import from scripts/; the one entry-point-to-entry-point import is pepernoten_write_mcp reusing pepernoten_mcp.register_read_tools. workspace.py deliberately imports no vault module — the write server sets PEPERNOTEN_VAULT to the workspace root before importing the pipeline, because vault.py resolves its paths at import time.

pepernoten_cli ──► vault, llm, arxiv_utils, pipeline, topics, src/bibtex pepernoten_mcp ──► mcp_backend (never touches the pipeline) pepernoten_write_mcp ──► workspace ──► mcp_backend (shared repo/branch validation) ──► pipeline, topics, pepernoten_mcp (shared read tools) scripts/parse ──► pipeline, llm, vault, notes, arxiv_utils scripts/topic_manager ──► topics scripts/bibtex ──► src/bibtex src/pipeline ──► paper, topics, llm, notes, vault, arxiv_utils src/topics ──► llm, vault, notes, prompts src/prompts ──► notes (note_xml) src/figures ──► vault, arxiv_utils src/paper ──► vault, llm, arxiv_utils, notes, figures, prompts, bibtex src/notes ──► vault src/bibtex ──► vault

Parsing pipeline

paper.process_arxiv_paper(arxiv_url, model, vision_model, openrouter_api_key, verbosity) is the main entry point. Roughly:

  1. Metadata — arXiv Atom API, 4-attempt retry with exponential backoff.
  2. HTML — tries arxiv.org/html/{id}, falls back to ar5iv.labs.arxiv.org.
  3. Figure extraction — parses <figure> tags, downloads rasters. Unresolved figures go into needs_pdf.
  4. PDF fallback — downloaded only if HTML is unavailable or figures are still missing.
  5. Banner selection — a vision model picks the best figure from up to 6 candidates (skipped in keyless mode; falls back to the first figure).
  6. LLM synthesis — two stages: a fast model extracts bounded facts as JSON, then the writer model produces the prose sections from those facts plus the paper text.
  7. Note assembly — frontmatter + TL;DR + sections + related-work table + BibTeX written to Research/.
  8. Related-work lookup — fills in missing arXiv IDs via fuzzy title search.
  9. Index + backlinks — paper registered in .paper_index.json; wikilinks added to notes that mention it.
  10. Topic update — matching topics are updated, unmatched papers trigger topic discovery (once per batch).

Key data structures

Paper index — Research/.paper_index.json

{ "2405.12345": {"title": "Paper Title", "file": "Paper Title.md"} }

Topic index — Research/Topics/.topic_index.json

{
  "streaming-video-llms": {
    "name": "Streaming Video LLMs",
    "file": "Topics/streaming-video-llms.md",
    "fingerprint_tags": ["streaming-video", "kv-cache", "video-llm"],
    "fingerprint_benchmarks": ["StreamingBench", "OvO-Bench"],
    "min_tag_overlap": 2,
    "papers": ["2405.12345"],
    "last_updated": "2026-06-20"
  }
}

A paper matches a topic if it shares ≥ min_tag_overlap tags from fingerprint_tags, OR any fingerprint_benchmarks — see notes.match_topics.

Two ways to create a topic entry: create + init registers fingerprint tags by hand, then generates the file from whatever already matches them. add skips the manual tags. It hands the whole vault to extraction_model alongside a written description, asks it to pick the papers that genuinely belong and infer the fingerprint, then generates the file the same way init does.

Topic file structure

---                     ← YAML frontmatter (topic metadata)
topic: "Streaming Video LLMs"
slug: streaming-video-llms
---

## Introduction         ← LLM-written body (never modified by the tooling)
## Benchmarks
## Methods & Baselines
## Techniques & Tricks
## Architecture Overview
## Open Problems & Gaps

---
## Method Index          ← appended by write_topic_file (stripped before the LLM sees it)
---
## Papers                ← appended by write_topic_file (Obsidian backlinks)

read_topic_content() strips everything from ## Method Index downward before handing content to the LLM, so those sections are always regenerated fresh on every write.

Prompt configuration

All user-facing prompt semantics (roles, tones) live in pepernoten_prompts.yaml. Code in src/prompts.py loads it via _cfg_get(dot.path, default) — missing keys fall back to hardcoded defaults silently. Only semantics are configurable; JSON output format, field specs, and retry logic are not.

Every prompt that can mention a paper also carries a hardcoded grounding block: single-paper synthesis may only cite what appears in the paper text or extracted facts, and topic surveys may only cite the citation registry built from real vault data — vault papers as [[wikilinks]], external papers as their listed arXiv link, never an invented ID or title.

MCP servers

Two stdio servers for MCP clients (Claude Code, Claude Desktop, …) — clients mount one, not both. pepernoten_mcp.py is strictly read-only; pepernoten_write_mcp.py is a superset serving the same read tools plus parse/add_topic. The read tools are defined once (pepernoten_mcp.register_read_tools) and registered by both servers. Both tool layers are thin: the logic lives in src/mcp_backend.py and src/workspace.py + src/pipeline.py + src/topics.py.

Read-only server

Read+write server

Registers the shared read tools plus exactly two mutating tools, parse and add_topic — thin calls into pipeline.parse_papers / topics.add. Reads are served through a LocalBackend over the workspace, refreshed from the remote whenever no write is in flight. The backend question is solved by workspaces rather than by teaching the pipeline to write remotely:

Safety invariants

Enforced in _check_relpath / LocalBackend._resolve / GitHubBackend._get / GitWorkspace, covered by tests/test_mcp_backend.py and tests/test_workspace.py. Do not weaken these:

stdout Keep stdout clean in server code — it carries the JSON-RPC stream. Diagnostics go to stderr.

Configuration reference

# from a local checkout
uv run --directory /path/to/pepernoten pepernoten-mcp        # or pepernoten-write-mcp

# via uvx, from a local path or a git remote (needs --from; not on PyPI)
uvx --from /path/to/pepernoten pepernoten-mcp
uvx --from git+https://github.com/ameroyer/pepernoten pepernoten-mcp
VariableMeaning
PEPERNOTEN_VAULTNotes in a local vault: the vault root containing Research/ (default: the pepernoten checkout itself, which doubles as a vault)
PEPERNOTEN_GITHUB_REPONotes in a GitHub repo: owner/name of the repo storing the notes, e.g. USERNAME/pepernoten_notes. Setting this switches to the GitHub backend
PEPERNOTEN_GITHUB_TOKENFine-grained PAT scoped to just that notes repo, with Contents permission: read-only for the read-only server (private notes repos only), read and write for the read+write server (always required, it pushes commits)
PEPERNOTEN_GITHUB_BRANCHBranch of the notes repo to use (default: its default branch)
PEPERNOTEN_GITHUB_ROOTWhere the notes live inside the repo. Default: the repo root itself holds the contents of Research/. Set to Research for a repo with a Research/ directory; the write server supports only these two layouts
PEPERNOTEN_MODEL / _EXTRACTION_MODEL / _VISION_MODELOpenRouter model IDs for the writer, fact-extraction, and figure-selection stages (defaults in src/llm.py)
OPENROUTER_API_KEYLLM access for write tools; optional when the claude CLI is installed and all configured models are Claude models

Each server prints a serving … line to stderr on startup — handy for sanity-checking a config by hand. Write tools take minutes per call (arXiv rate limits + LLM calls); every pipeline step is streamed back as an MCP progress notification, which doubles as the heartbeat that keeps idle-timeout clients (Claude Code) from abandoning the call.


Module responsibilities

ModuleOwnsDoes not own
src/vault.pyAll path constants, index read/write, topic file I/OLLM calls, network I/O
src/mcp_backend.pyRead-only note-store backends (local / GitHub), path confinement, searchMCP protocol, pipeline code
src/llm.pyProvider dispatch (OpenRouter / claude CLI), model defaults, call() / call_json()Prompt text, retry logic beyond basic
src/arxiv_utils.pyRate limiters, arXiv HTML fetch, ID extraction, Scholar InboxFigure parsing, note writing
src/notes.pyNote parsing, topic matching, backlinks, changelogIndex I/O, network
src/figures.pyHTML + PDF figure extraction, vision selectionNote assembly, LLM prompts
src/prompts.pyAll prompt strings + grounding rules for synthesis and topic surveysLLM calls, file I/O
src/paper.pyEnd-to-end process_arxiv_paper pipelineCLI, batch orchestration
src/pipeline.pyBatch parsing + topic-update orchestrationUI, prompt text
src/topics.pyTopic surveys: citation grounding, add/init/update/discover/mergeNote synthesis
src/workspace.pyWrite workspaces: local passthrough, git-synced GitHub cloneVault paths, pipeline logic
src/bibtex.pyBibTeX lookup cascade + cacheNote UI, clipboard
scripts/*.pyfire.Fire CLI wrappersBusiness logic (lives in src/)
pepernoten_cli.pyInteractive REPL, rich/questionary UIAll logic (delegates to src/)
pepernoten_mcp.pyRead tool definitions (shared via register_read_tools), untrusted-content bannerAll logic (delegates to src/mcp_backend.py)
pepernoten_write_mcp.pyRead+write server: registers the shared read tools + parse/add_topic, sync-around-callVault logic (delegates to workspace + pipeline + topics)

Import rules

If you find yourself needing to import from scripts/ inside src/, the logic belongs in src/ instead.

LLM calls Always use llm.call() / llm.call_json() — they dispatch to OpenRouter (https://openrouter.ai/api/v1) or the claude CLI. Never instantiate OpenAI directly, and never call the Anthropic API directly.

Rate limiting New arXiv API calls use arxiv_api.wait(); static asset downloads use arxiv_asset.wait() — both from src/arxiv_utils.py.

Code style

Testing & CI

tests/ covers pure logic only — note/frontmatter parsing, topic matching, arXiv ID extraction, BibTeX key generation, LLM JSON extraction, workspace and backend safety invariants. Nothing that hits the network or calls an LLM: those paths are exercised by hand via the round-trip in Hacking on it.

uv run pytest

GitHub Actions (.github/workflows/ci.yml) runs ruff check and pytest on every push and pull request.

Common tasks