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
No feature added speculatively, no abstraction before it's needed three times.
One responsibility per file, no cross-layer imports going the wrong way.
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.
Parsing pipeline
paper.process_arxiv_paper(arxiv_url, model, vision_model, openrouter_api_key, verbosity) is the main entry point. Roughly:
- Metadata — arXiv Atom API, 4-attempt retry with exponential backoff.
- HTML — tries
arxiv.org/html/{id}, falls back toar5iv.labs.arxiv.org. - Figure extraction — parses
<figure>tags, downloads rasters. Unresolved figures go intoneeds_pdf. - PDF fallback — downloaded only if HTML is unavailable or figures are still missing.
- Banner selection — a vision model picks the best figure from up to 6 candidates (skipped in keyless mode; falls back to the first figure).
- 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.
- Note assembly — frontmatter + TL;DR + sections + related-work table + BibTeX written to
Research/. - Related-work lookup — fills in missing arXiv IDs via fuzzy title search.
- Index + backlinks — paper registered in
.paper_index.json; wikilinks added to notes that mention it. - 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
LocalBackend/GitHubBackendshare one read-only interface —list_markdown(),read_text(relpath), and the three index accessors. Relpaths are relative toResearch/for a local vault; for GitHub, to the repo root by default (or$PEPERNOTEN_GITHUB_ROOT).backend_from_env()picks the backend:PEPERNOTEN_GITHUB_REPOset → GitHub, elsePEPERNOTEN_VAULT(default: this repo).- The GitHub backend lists files via the Git Trees API (cached 60 s) and fetches content via the Blobs API (cached by immutable sha), so searches don't hammer the rate limit.
- Tools (all annotated read-only):
vault_info,list_papers,read_note,search_notes,list_tags,list_topics,read_topic.
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:
LocalWorkspace— the vault directory itself;begin()/publish()are no-ops.GitWorkspace— a clone of the notes repo under~/.cache/pepernoten/. Every write call runsbegin()(fetch + hard reset to the remote tip), then the unchanged local pipeline, thenpublish()(add/commit/push, with one pull-rebase retry if the remote moved). Since the repo root holds the notes directly (default layout) while the pipeline expects a vault root containingResearch/, the repo is cloned into<cache>/<repo>/Researchand the vault root is its parent. The first clone is blob-filtered so historical image blobs are never downloaded.workspace_from_env()mirrorsbackend_from_env():PEPERNOTEN_GITHUB_REPOset → GitWorkspace (token required), else LocalWorkspace.- Startup sets
PEPERNOTEN_VAULTto the workspace root before importing the pipeline. Each write call runs in a worker thread with stdout redirected to a_ProgressWriterthat mirrors every pipeline print line to stderr and forwards it as an MCP progress notification. A lock serializes write calls so sync cycles can't interleave. - Commits are authored as
pepernoten <pepernoten@invalid>— a label, not an account (override withPEPERNOTEN_COMMIT_NAME/PEPERNOTEN_COMMIT_EMAIL).
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:
- The read-only server exposes no mutating tools at all — no parse, no write, no delete.
- No read path may escape the notes tree:
.., absolute paths,~, and symlink escapes are all rejected; only.mdnotes plus the three known index JSONs are readable. - GitHub API requests go to
api.github.comonly, with validated repo/branch names, 30 s timeouts, and a 2 MB file cap; the token lives in request headers only and never appears in URLs or error messages. - The write server's token reaches git only through
GIT_CONFIG_*environment variables — never argv, remote URLs, on-disk config, or errors. All git commands have timeouts andGIT_TERMINAL_PROMPT=0. - The write server's only mutations are the pipeline's own outputs, pushed as plain
pepernoten: …commits — reviewable and revertable like any other commit. - Retrieved note content is wrapped in an "untrusted data" banner (prompt-injection hardening for the client LLM).
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
| Variable | Meaning |
|---|---|
PEPERNOTEN_VAULT | Notes in a local vault: the vault root containing Research/ (default: the pepernoten checkout itself, which doubles as a vault) |
PEPERNOTEN_GITHUB_REPO | Notes 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_TOKEN | Fine-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_BRANCH | Branch of the notes repo to use (default: its default branch) |
PEPERNOTEN_GITHUB_ROOT | Where 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_MODEL | OpenRouter model IDs for the writer, fact-extraction, and figure-selection stages (defaults in src/llm.py) |
OPENROUTER_API_KEY | LLM 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
| Module | Owns | Does not own |
|---|---|---|
src/vault.py | All path constants, index read/write, topic file I/O | LLM calls, network I/O |
src/mcp_backend.py | Read-only note-store backends (local / GitHub), path confinement, search | MCP protocol, pipeline code |
src/llm.py | Provider dispatch (OpenRouter / claude CLI), model defaults, call() / call_json() | Prompt text, retry logic beyond basic |
src/arxiv_utils.py | Rate limiters, arXiv HTML fetch, ID extraction, Scholar Inbox | Figure parsing, note writing |
src/notes.py | Note parsing, topic matching, backlinks, changelog | Index I/O, network |
src/figures.py | HTML + PDF figure extraction, vision selection | Note assembly, LLM prompts |
src/prompts.py | All prompt strings + grounding rules for synthesis and topic surveys | LLM calls, file I/O |
src/paper.py | End-to-end process_arxiv_paper pipeline | CLI, batch orchestration |
src/pipeline.py | Batch parsing + topic-update orchestration | UI, prompt text |
src/topics.py | Topic surveys: citation grounding, add/init/update/discover/merge | Note synthesis |
src/workspace.py | Write workspaces: local passthrough, git-synced GitHub clone | Vault paths, pipeline logic |
src/bibtex.py | BibTeX lookup cascade + cache | Note UI, clipboard |
scripts/*.py | fire.Fire CLI wrappers | Business logic (lives in src/) |
pepernoten_cli.py | Interactive REPL, rich/questionary UI | All logic (delegates to src/) |
pepernoten_mcp.py | Read tool definitions (shared via register_read_tools), untrusted-content banner | All logic (delegates to src/mcp_backend.py) |
pepernoten_write_mcp.py | Read+write server: registers the shared read tools + parse/add_topic, sync-around-call | Vault logic (delegates to workspace + pipeline + topics) |
Import rules
src/modules import from othersrc/modules only — never fromscripts/.scripts/modules are thin fire wrappers importing fromsrc/— never from each other.- Entry points import from
src/; the sole exception ispepernoten_write_mcpimportingpepernoten_mcp.register_read_toolsso the read tools stay defined once.
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
- No comments unless the why is genuinely non-obvious (a hidden constraint, a workaround, a subtle invariant).
- No docstrings for obvious functions — the name should explain it.
- Type annotations aren't required, but use them where they clarify complex signatures.
- No emoji in code or comments.
- Prefer flat code over deeply nested helpers for one-off logic.
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
- New CLI command — implement in the appropriate
src/module → register a thin wrapper in the relevantscripts/file'sfire.Fire({...})→ add acmd_*function inpepernoten_cli.pyif it needs to be interactive → expose it as a write MCP tool only if an agent should be able to run it. - New BibTeX source — add a
try/exceptblock insrc/bibtex.py'sgenerate()between the existing source attempts. The@miscfallback at the end is unconditional. - New note field — extend the f-string in
src/paper.pyand add the field spec tosrc/prompts.py's_writer_section_specs()for the relevant verbosity levels. Frontmatter fields that need to be read back also go invault.note_meta(). - New prompt semantics — add a key to
pepernoten_prompts.yamland a corresponding_cfg_get()call insrc/prompts.pywith a hardcoded default. - New topic metric — update the topic index schema and
notes.match_topics().