Local Research Workflow
A privacy-first workflow for processing documents, videos, podcasts, and RSS feeds with local AI. Designed for a Mac with Apple Silicon; no cloud storage for your research data.
Estimated installation time: 60–120 minutes Requirements: macOS Sequoia or later, internet connection for downloads, an Anthropic account (for Claude Code)
The 3-phase model
Every source — paper, podcast, video, RSS article — passes through three explicit phases:
| Phase | Goal | How |
|---|---|---|
| 1 — Cast wide | Capture everything relevant | Items flow into Zotero _inbox from three sources: (1) the feedreader (feedreader-score.py) scores RSS/YouTube/podcast feeds daily and produces a sorted HTML reader and Atom feed; (2) items shared directly via the iOS share sheet; (3) manual additions from desktop/email/notes |
| 2 — Filter | You decide what enters the vault | index-score.py ranks inbox items by semantic similarity to your library; Qwen3.5:9b (local) generates a summary for mid-range items; you give a Go or No-go |
| 3 — Process | Full processing of approved items | The local subagent process_item.py fetches the full text, generates a structured literature note via Qwen3.5:9b, and writes it to the Obsidian vault — including key findings, methodology notes, relevant quotes, and [[internal links]] |
The separation between phases 1 and 3 keeps both your feed reader and your vault clean: only sources you have consciously approved end up in the vault.
Tools required
| Tool | Role | Local / Cloud |
|---|---|---|
| Zotero | Reference manager and central inbox | Local |
| Zotero MCP | Connects Claude Code to your Zotero library via local API | Local |
| Obsidian | Markdown-based note-taking and knowledge base | Local |
| Ollama | Local language model for offline tasks | Local |
| yt-dlp | Download YouTube transcripts and podcast audio | Local |
| whisper.cpp | Local speech-to-text transcription for podcasts | Local |
| NetNewsWire | RSS reader for academic and non-academic feeds | Local |
| Claude Code | AI assistant that orchestrates the workflow; generative work runs locally via Qwen3.5:9b (Ollama) | Local (default) / Cloud API with --hd |
In standard mode, only orchestration instructions are sent to the Anthropic API; all generative work is handled locally by Qwen3.5:9b. Only when --hd is explicitly requested do the prompt and source content go to the Anthropic API (Claude Sonnet 4.6). Reference data, notes, and transcriptions always stay local.
Overview of steps
- Install Homebrew (package manager)
- Install and configure Zotero 7 (including
_inboxcollection) - Set up Python environment
- Install and configure Zotero MCP
- Install Claude Code
- Install Ollama (local language model)
- Install Obsidian and create vault
- Connect everything: configure Claude Code with MCP
- Run first test
- Optional extensions (yt-dlp, semantic search, automatic updates)
- Podcast integration (whisper.cpp)
- RSS integration + feedreader filtering (NetNewsWire + feedreader-score.py)
- Spaced repetition (Obsidian plugin)
- Set up filter layer per source
Step 1: Install Homebrew
Homebrew is the standard package manager for macOS and simplifies the installation of all further tools.
Open Terminal (found via Spotlight: Cmd + Space → type "Terminal") and run the following command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Follow the on-screen instructions. After installation, add Homebrew to your shell path (the installation script shows this command at the end — copy and run it).
Verify the installation was successful:
brew --version
Step 2: Install and configure Zotero 7
2a. Download Zotero
Download Zotero 7 via zotero.org. Install the application by opening the .dmg and dragging Zotero to your Applications folder.
2b. Enable the local API
This is the crucial step that makes Zotero MCP possible:
- Open Zotero
- Go to Zotero → Settings → Advanced (or
Cmd + ,) - Scroll down to the Other section
- Check the box next to "Allow other applications on this computer to communicate with Zotero"
- Note the port number that appears (default:
23119)
Privacy note: This API is only accessible via
localhost— no external access is possible.
2c. Verify the local API is working
Open a new tab in your browser and go to:
http://localhost:23119/
You should see a JSON response with version information from Zotero. If that works, the local API is functioning correctly.
2d. Create _inbox collection (central collection bucket)
In the 3-phase model, Zotero's _inbox collection is not a source in itself, but the central collection bucket where all sources flow: documents via the Zotero Connector or iOS app, RSS items via NetNewsWire, podcasts and videos via the Zotero iOS share sheet, and emails or notes via the iOS share button. You evaluate the content only in phase 2 (see step 14).
- In Zotero, right-click My Library → New Collection
- Name the collection
_inbox(the underscore ensures it appears at the top of the list) - Set this as the default destination in the Zotero Connector: open the browser extension → Settings → set the default collection to
_inbox - Set the same default on iOS: open the Zotero app → Settings → set the default collection location to
_inbox
From now on, everything you save via the Connector or the iOS share sheet automatically goes to _inbox. You can also create a note directly within the Zotero app itself — it also ends up in _inbox if you have set that as the default.
2e. Install Better BibTeX plugin (recommended)
Better BibTeX significantly improves annotation extraction:
- Download the latest
.xpifrom retorque.re/zotero-better-bibtex/installation - In Zotero: Tools → Plugins → Gear icon → Install from file
- Select the downloaded
.xpifile - Restart Zotero
Step 3: Set up Python environment
Zotero MCP requires Python 3.10 or higher. On Apple Silicon, uv works best as a fast, modern package manager.
3a. Install uv (recommended)
brew install uv
Verify the installation:
uv --version
3b. Check Python version
python3 --version
If the version is lower than 3.10, install a newer version:
brew install python@3.12
Step 4: Install and configure Zotero MCP
4a. Install the package
uv tool install zotero-mcp-server
Verify the installation was successful:
zotero-mcp version
4b. Run the setup wizard
zotero-mcp setup
The wizard asks you a number of questions:
- Access method: choose
local(no API key needed, fully offline) - MCP client: choose
Claude Desktopif you plan to install it later, or skip - Semantic search: you can skip this now and configure it later (see step 10)
4c. Initialize semantic search
Build the local search database (uses the free, locally running model all-MiniLM-L6-v2):
# Quick version (metadata only):
zotero-mcp update-db
# Extended version (including full text — recommended):
zotero-mcp update-db --fulltext
Note: The
--fulltextoption takes longer but gives much better search results. On an M4 Mac mini with an average-sized library this takes 5–20 minutes.
Check the status of the database:
zotero-mcp db-status
4d. Set up a Zotero API key (required for write access)
The local Zotero API (port 23119) is read-only. To allow Claude Code to remove processed items from your _inbox collection automatically, you need a Zotero web API key with write access.
Create the key:
- Go to zotero.org/settings/keys
- Click Create new private key
- Give it a name (e.g. "Claude Code")
- Enable Allow library access and Allow write access under Personal Library
- Click Create and copy the key
Find your library ID:
Your library ID is visible in the Zotero web interface URL after logging in, or you can retrieve it from the local API:
curl -s http://localhost:23119/api/users/0/items?limit=1 | python3 -c "import json,sys; items=json.load(sys.stdin); print(items[0]['library']['id'])"
Store the credentials:
Add these lines to your ~/.zprofile (persistent across sessions):
export ZOTERO_API_KEY=your_api_key_here
export ZOTERO_LIBRARY_ID=your_library_id_here
export ZOTERO_LIBRARY_TYPE=user
Also create a .env file in your vault root (used by the helper scripts):
cat > ~/Documents/ResearchVault/.env << EOF
ZOTERO_API_KEY=your_api_key_here
ZOTERO_LIBRARY_ID=your_library_id_here
ZOTERO_LIBRARY_TYPE=user
EOF
The .env file is already listed in .gitignore so it will not be committed to version control.
Step 5: Install Claude Code
5a. Install Node.js
brew install node
5b. Install Claude Code
npm install -g @anthropic-ai/claude-code
5c. Authenticate
claude
On first launch you will be asked to log in with your Anthropic account. Follow the instructions in the terminal.
Step 6: Install Ollama
Local vs. cloud — what happens where?
It is important to understand what runs locally in this workflow and what goes through the cloud:
| Step | Where | Notes |
|---|---|---|
| Zotero MCP (querying library) | ✅ Local | Connection via localhost |
| yt-dlp (fetching transcripts) | ✅ Local | Scraping on your Mac |
| whisper.cpp (transcribing audio) | ✅ Local | M4 Metal GPU |
| Semantic search (Zotero MCP) | ✅ Local | Local vector database |
| Reasoning, summarizing, writing syntheses | ✅ Local | Qwen3.5:9b via Ollama (default); Anthropic API only when --hd is used |
In the default mode, all generative work — summaries, literature notes, flashcards — is handled locally by Qwen3.5:9b via Ollama. Claude Code orchestrates the workflow but does not send source content to the Anthropic API. No tokens, no data transfer.
The honest trade-off: local models are less capable than Claude Sonnet for complex or nuanced tasks. For simple summaries and flashcards the difference is small; for writing rich literature notes or drawing subtle connections between sources, Claude Sonnet is noticeably better. Add --hd to any request to switch to Claude Sonnet 4.6 via the Anthropic API for that task — Claude Code always announces this and asks for confirmation before making the API call.
6a. Install
brew install ollama
6b. Download models
For an M4 Mac mini with 24 GB memory, Qwen3.5:9b is the recommended default model for all local processing tasks in the workflow:
ollama pull qwen3.5:9b # ~6.6 GB — default model for all tasks
Qwen3.5:9b has a context window of 256K tokens, recent training with explicit attention to multilingualism (201 languages, including Dutch), and a hybrid architecture that stays fast even with long input. It replaces both llama3.1:8b and mistral for all workflow tasks.
# Optional alternatives (not needed if you use qwen3.5:9b):
ollama pull llama3.1:8b # ~4.7 GB — proven, but 128K context and less multilingual
ollama pull phi3 # ~2.3 GB — very compact, for systems with less memory
Check which models are available after downloading:
ollama list
6c. Start Ollama
ollama serve
Leave this running in a separate Terminal window, or configure it as a background service so it is always available:
# Start automatically at system startup (recommended):
brew services start ollama
Check whether Ollama is active and which models are available:
ollama list
6d. How Ollama is used in the workflow
Qwen3.5:9b is the default engine for all generative tasks. The workflow uses a dedicated helper script — .claude/ollama-generate.py — to call Ollama via its REST API rather than the CLI.
Why a helper script instead of ollama run?
The Ollama CLI (ollama run qwen3.5:9b < file.txt) produces terminal output with ANSI escape codes and a loading spinner. When this output is captured by Claude Code's bash tool, the result contains hundreds of lines of control characters that need to be stripped before the content is usable. The helper script avoids this entirely by talking directly to the Ollama REST API at http://localhost:11434/api/generate.
The script also prepends /no_think to the prompt, which tells Qwen3.5:9b to skip its internal reasoning step and produce output directly. This saves time and keeps the output clean.
Usage:
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/ollama-generate.py \
--input inbox/source.txt \
--output literature/note.md \
--prompt "Write a literature note in the same language as the source text..."
The script prints only status lines (Input: ..., Model: ..., Written: ...) — never the source content or the generated text. This ensures source content does not appear in Claude Code's context.
Test whether Ollama is reachable:
echo "Test" > /tmp/test.txt
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/ollama-generate.py \
--input /tmp/test.txt \
--output /tmp/test-out.txt \
--prompt "Say only: works."
cat /tmp/test-out.txt
If this returns a short response, Ollama and the helper script are ready for use.
Default rule in CLAUDE.md
The CLAUDE.md in your vault already contains the privacy rule and points to ollama-generate.py as the standard tool — no further configuration is needed per task.
Step 7: Install Obsidian and create vault
7a. Download Obsidian
Download Obsidian via obsidian.md. Install the application in the usual way.
7b. Create a vault
- Open Obsidian
- Choose "Create new vault"
- Give the vault a name, e.g.
ResearchVault - Choose a location you will remember, e.g.
~/Documents/ResearchVault - Click "Create"
7c. Create folder structure
Create the following folders in your vault (right-click in the left panel → "New folder"):
ResearchVault/
├── literature/ ← summaries of Zotero papers, YouTube and podcasts
│ └── annotations/ ← extracted PDF annotations
├── syntheses/ ← thematic cross-connections
├── projects/ ← per project or collaboration
├── daily/ ← daily notes
├── flashcards/ ← spaced repetition cards (optional, separate from literature/)
└── inbox/ ← raw input, yet to be processed
7d. Create CLAUDE.md
Create a file CLAUDE.md in the root of your vault (not in a subfolder). This is Claude Code's memory. Paste the following starter text and adjust to your preference:
# CLAUDE.md — ResearchVault Workflow
## Obsidian conventions
- All files are Markdown (.md)
- Use [[double brackets]] for internal links between notes
- Use #tags for thematic categorization
- File names: use hyphens, no spaces (e.g. `author-2024-keyword.md`)
## Vault structure
- `literature/` — one note per paper or source from Zotero
- `syntheses/` — thematic syntheses of multiple sources
- `projects/` — project-specific documentation
- `daily/` — daily notes and log
- `inbox/` — raw input yet to be processed
## Literature notes (from Zotero)
Each literature note contains:
- Bibliographic details (author, year, journal)
- Core question and main argument
- Key findings (3–5 points)
- Methodological notes
- Quotes relevant to my research
- Links to related notes in the vault
## Language
- Answer in English unless asked otherwise
- Write literature notes in English, quotes in the original language
## Zotero workflow
- Use Zotero MCP to retrieve papers by title or keywords
- Save literature notes as `literature/[author-year-keyword].md`
- Always add a #tag for the topic of the paper
## YouTube transcripts (yt-dlp)
- Transcripts are stored in `inbox/` as `.vtt` files
- Process a transcript into a note in `literature/` with the following structure:
- Title, speaker, channel, date, URL
- Summary (3–5 sentences)
- Key points with timestamps
- Relevant quotes (with timestamp)
- Links to related notes in the vault
- File name for transcript notes: `[speaker-year-keyword].md` with #tag `#video`
- Delete raw `.vtt` files from `inbox/` after the note has been created
## Zotero database maintenance
- The semantic search database must be updated periodically after adding new papers
- Remind the user to update the database if more than a week has passed since the last update, or if searches are missing recent additions
- Use the command `update-zotero` (alias) or `zotero-mcp update-db --fulltext` for a full update
- Check the status with `zotero-status` or `zotero-mcp db-status`
## Podcast transcripts (whisper.cpp + yt-dlp)
- Audio is downloaded via yt-dlp and stored in `inbox/` as `.mp3`
- Transcription runs locally via whisper.cpp (fully offline)
- Whisper detects the language automatically; only pass `--language` explicitly if automatic detection is incorrect
- Process a transcript into a note in `literature/` with the following structure:
- Title, speaker(s), program/channel, date, URL or source reference
- Summary (3–5 sentences)
- Key points with timestamps
- Relevant quotes (with timestamp, in the original language)
- Links to related notes in the vault
- File name for podcast notes: `[speaker-year-keyword].md` with #tag `#podcast`
- For long podcasts (> 45 min): first create a layered summary (overview → per segment)
- Delete raw `.mp3` and `.txt` files from `inbox/` after the note has been created
## RSS feeds
- All RSS feeds (academic and non-academic) are followed via NetNewsWire
- Academic articles of interest: add them to Zotero via the browser extension or iOS app → they end up in `_inbox`
- Non-academic articles: add via Zotero Connector, or pass the URL with `inbox [URL]` for direct storage as Markdown in `inbox/`
- File name for RSS items without a Zotero record: `[source-year-keyword].md` with #tag `#web` or `#policy`
## Spaced repetition (Obsidian plugin)
- Flashcards are created after each literature note or synthesis
- Format: question and answer separated by `?` on a new line, enclosed by `#flashcard` tag
- Create a maximum of 5 cards per source — choose the most relevant concepts
- Daily review via Obsidian Spaced Repetition plugin (sidebar → Review cards)
Step 8: Connect Claude Code to Zotero MCP
8a. Edit the configuration file
Claude Code reads the MCP configuration from ~/Library/Application Support/Claude/claude_desktop_config.json. This is the same location used by Claude Desktop. Create or edit this file:
mkdir -p ~/Library/Application\ Support/Claude
nano ~/Library/Application\ Support/Claude/claude_desktop_config.json
Insert the following (or add the zotero section to an existing file):
{
"mcpServers": {
"zotero": {
"command": "zotero-mcp",
"env": {
"ZOTERO_LOCAL": "true"
}
}
}
}
Save with Ctrl + O, Enter, then Ctrl + X.
8b. Verify the MCP configuration
zotero-mcp setup-info
This shows the installation path and the configuration as Zotero MCP sees it. Note down the userID shown — you will need it in the next step.
8c. Configure Claude Code permissions
Claude Code's permission settings for this vault are stored in .claude/settings.local.json. This file contains your home path and Zotero library ID, so it is not checked into version control. Generate it from the template using the setup script:
cd ~/Documents/ResearchVault
./setup.sh
The script:
- Auto-detects your home path
- Asks for your Zotero library ID (the userID from
zotero-mcp setup-info) - Writes
.claude/settings.local.jsonwith the correct paths
Note: If you ever move your vault or reinstall tools, re-run
./setup.shto regenerate the file.
Step 9: Run first test
9a. Start Zotero
Make sure Zotero 7 is open and active (the local API is only available when Zotero is running).
9b. Open Claude Code in your vault
cd ~/Documents/ResearchVault
claude
9c. Run test prompts
Try the following prompts in Claude Code to verify everything works:
Test 1 — Zotero connection:
Search my Zotero library for recent additions and give an overview.
Test 2 — Retrieve a paper:
Find a paper about [a topic you have in Zotero] and write a literature note
to literature/ in Obsidian format.
Test 3 — Semantic search:
Use semantic search to find papers that are conceptually related to [topic].
Test 4 — Vault awareness:
Look at the structure of this vault and give a summary of what is already in it.
If all four tests work, the basic installation is complete.
Step 10: yt-dlp & semantic search
10a. Install yt-dlp (YouTube transcripts)
With yt-dlp you can retrieve transcripts from YouTube videos and store them as sources in your vault. This is useful for lectures, conference recordings, interviews, and other academic video content.
Install
brew install yt-dlp
Verify the installation:
yt-dlp --version
Retrieve a transcript
The basic command to download an automatically generated subtitle file:
yt-dlp --write-auto-sub --skip-download --sub-format vtt \
"https://www.youtube.com/watch?v=VIDEOID" -o "transcript"
This creates a .vtt file in your current folder. The VTT format contains timestamps and is directly readable as text.
For videos where you want to retrieve manual subtitles if available (better quality than auto-generated):
# Retrieve manual subtitles (if available), otherwise automatic:
yt-dlp --write-sub --write-auto-sub --skip-download \
--sub-lang nl,en --sub-format vtt \
"https://www.youtube.com/watch?v=VIDEOID" -o "~/Documents/ResearchVault/inbox/%(title)s"
The flags --write-sub --write-auto-sub work the same way for all languages: yt-dlp takes manual subtitles if available, and falls back to auto-generated ones otherwise. --sub-lang nl,en requests both languages — useful for bilingual content. The -o "...%(title)s" option automatically uses the video title as the filename, so you know what you have saved.
Integrating into the vault workflow
Always save transcripts to inbox/ and ask Claude Code to process them:
# Step 1: retrieve transcript to inbox
yt-dlp --write-auto-sub --skip-download --sub-format vtt \
"https://www.youtube.com/watch?v=VIDEOID" \
-o "~/Documents/ResearchVault/inbox/%(title)s"
# Step 2: open Claude Code in your vault
cd ~/Documents/ResearchVault
claude
Then give Claude Code the instruction:
Process the transcript in inbox/ into a structured note in literature/
with summary, key points, and timestamped quotes.
Multiple videos at once (search results)
You can also retrieve YouTube search results in batch. You do not need to type the command yourself: you can instruct Claude Code in plain language, for example:
Retrieve the first ten YouTube videos about "implementation care agreement Netherlands"
and save the transcripts to inbox/
Claude Code writes and runs the yt-dlp command itself. The underlying command looks like this:
# Retrieve the first 10 videos about a search term:
yt-dlp --write-auto-sub --skip-download --sub-format vtt \
"ytsearch10:implementation care agreement Netherlands" \
-o "~/Documents/ResearchVault/inbox/%(title)s"
Privacy note: yt-dlp only connects to YouTube to download publicly available subtitle files. No personal data is sent and no account is required.
10b. Better semantic embeddings (optional)
The default local model (all-MiniLM-L6-v2) is free and fast. If you want better search results and are willing to use an OpenAI API key exclusively for embeddings (not for text generation):
zotero-mcp setup --semantic-config-only
Then choose openai as the embedding model and enter your API key. Afterwards, reinitialize the database:
zotero-mcp update-db --fulltext --force-rebuild
10c. Automatic database updates
Every time you add new papers to Zotero, the semantic search database must be updated in order to find those papers. You can do this manually, but automation is more convenient.
Option 1: Via the Zotero MCP setup wizard (simplest)
zotero-mcp setup --semantic-config-only
For the update frequency, choose "Daily" or "Auto on startup". With "Auto on startup" the database is updated every time Claude Code calls Zotero MCP — this is the most hands-off approach.
About "unknown" as model name: After running
zotero-mcp setup-info, the embedding model name may be displayed asunknown. This is normal behavior: the default local model (all-MiniLM-L6-v2) is used, but the name is not reported back by setup-info. Your installation is working fine. Verification is done not via the terminal but via Claude Code: after runningzotero-mcp update-db, ask Claude Code to semantically search for a term that exists in your library. If that returns results, the database is working correctly.
Option 2: Handy alias in your shell profile
Add an alias so you can update the database with a single command:
# Open your shell configuration file:
nano ~/.zshrc
Add at the bottom:
# Zotero MCP helper commands
alias update-zotero="zotero-mcp update-db --fulltext"
alias zotero-status="zotero-mcp db-status"
Activate the changes:
source ~/.zshrc
After that, you can always simply type:
update-zotero
Option 3: Automated via macOS launchd (background task)
For fully automatic daily updates you can set up a macOS launchd task. Create a new file:
nano ~/Library/LaunchAgents/com.zotero-mcp.update.plist
Paste the following:
<?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>com.zotero-mcp.update</string>
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>zotero-mcp update-db --fulltext >> ~/Documents/ResearchVault/zotero-mcp-update.log 2>&1</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>8</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>RunAtLoad</key>
<false/>
</dict>
</plist>
This runs a database update daily at 08:00 and saves the log to your vault. Activate the task:
launchctl load ~/Library/LaunchAgents/com.zotero-mcp.update.plist
Note: For the launchd option, Zotero must be open at the time the update runs. If your Mac is in sleep mode at that time, the task will be skipped until the next day.
Step 11: Podcast integration (whisper.cpp)
With whisper.cpp you can transcribe podcasts and other audio recordings entirely locally on your M4 Mac mini. The M4 chip executes this via Metal (the GPU) extremely fast: one hour of audio takes approximately 3–5 minutes.
11a. Install whisper.cpp
brew install whisper-cpp
Verify the installation:
whisper-cpp --version
11b. Download and transcribe a podcast
yt-dlp supports many podcast platforms beyond YouTube (SoundCloud, Podbean, direct MP3 links via RSS). The full pipeline in two steps:
# Step 1: download audio to inbox/
yt-dlp -x --audio-format mp3 \
"https://[podcast-url]" \
-o "~/Documents/ResearchVault/inbox/%(title)s.%(ext)s"
# Step 2: transcribe — whisper detects the language automatically
whisper-cpp --model small \
~/Documents/ResearchVault/inbox/[filename].mp3
This creates a .txt and a .vtt file alongside the .mp3. The .txt file contains the transcription without timestamps; the .vtt file contains timestamps per segment.
Language
Whisper detects the language automatically based on the first few seconds of audio. For most monolingual podcasts (Dutch or English) this works fine and you do not need to configure anything. Only pass --language explicitly if you encounter problems, for example with multilingual content or if automatic detection picks the wrong language:
# Only needed if detection fails:
whisper-cpp --model small --language nl ~/Documents/ResearchVault/inbox/[file].mp3
whisper-cpp --model small --language en ~/Documents/ResearchVault/inbox/[file].mp3
In the daily workflow, Claude Code lets the language be detected automatically based on metadata (podcast title, channel, description) and only passes --language explicitly if that metadata is unclear.
Models and quality
| Model | Size | Speed (1 hour audio) | Quality |
|---|---|---|---|
base | ~145 MB | ~2 min | Good for clear speech |
small | ~465 MB | ~4 min | Recommended starting point |
medium | ~1.5 GB | ~8 min | Better for accents, fast speech |
large | ~3 GB | ~15 min | Best quality |
Models are automatically downloaded on first use.
11c. Process in the vault
After the transcript is available in inbox/, open Claude Code in your vault:
cd ~/Documents/ResearchVault
claude
Give the instruction:
Process the podcast transcript in inbox/[filename].txt into a structured
note in literature/ with summary, key points, and timestamped quotes.
Claude Code follows the conventions from CLAUDE.md (see step 7d): title, speaker, summary, key points with timestamps, and links to related vault notes.
Privacy note: whisper.cpp runs entirely locally on your M4. No audio leaves your machine.
11d. Shortcut: full pipeline in one step
You can also have Claude Code run the full pipeline with a single instruction:
podcast https://[url-to-episode]
Claude Code downloads the audio, automatically determines the language based on metadata, runs whisper.cpp, and processes the transcript into a literature note — see also the skill (step "activate skill"). The Zotero _inbox step is skipped: the podcast goes directly to Obsidian. This is intended for episodes you have already evaluated and approved.
Step 12: RSS integration + feedreader filtering
In the 3-phase model, RSS feeds are pre-filtered automatically before you see them — the feedreader (feedreader-score.py) is part of phase 1, not a separate phase. It scores feeds daily so that your feed reader only shows items that are likely relevant to your research. You then browse this curated selection and send interesting items to Zotero _inbox. Only in phase 2 do you decide what goes into the vault.
12a. Feedreader — Automatic relevance filtering
feedreader-score.py runs daily via launchd and produces a filtered, scored Atom feed and HTML reader from your RSS subscriptions. It uses the same ChromaDB preference profile as index-score.py — items are scored by semantic similarity to your existing library.
Install dependencies (if not already present from step 10):
~/.local/share/uv/tools/zotero-mcp-server/bin/pip install feedparser sentence-transformers youtube-transcript-api
youtube-transcript-apiis used to fetch transcripts for YouTube items in your feeds. These transcripts enrich the relevance score (instead of scoring on the title alone) and are cached in.claude/transcript_cache/so they are only fetched once per video.
Configure your feeds — add one URL per line to .claude/feedreader-list.txt:
https://arxiv.org/rss/econ.GN
https://www.skipr.nl/feed/
http://onlinelibrary.wiley.com/rss/journal/10.1002/(ISSN)1099-1050
Load the launchd agents (run once after installation):
launchctl load ~/Library/LaunchAgents/nl.researchvault.feedreader-server.plist
launchctl load ~/Library/LaunchAgents/nl.researchvault.feedreader-score.plist
launchctl load ~/Library/LaunchAgents/nl.researchvault.feedreader-learn.plist
This starts a local HTTP server on port 8765 and schedules the daily score run at 06:00.
Run manually (first time, or on demand):
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/feedreader-score.py
Access the filtered feed:
- HTML reader (Mac/iPhone/iPad):
http://localhost:8765/filtered.html - Atom feed (NetNewsWire):
http://localhost:8765/filtered.xml
The HTML reader includes an ⌨️ terminal button in the header. Clicking it opens an embedded terminal panel (powered by ttyd — see Step 17) alongside the article list, so you can run Phase 2 (Claude Code) without leaving the browser tab. The terminal works both on Mac (via localhost) and on iPad (via the Mac's local IP address).
YouTube articles: clicking a YouTube headline in the HTML reader opens a generated reading article at http://localhost:8765/article/{video_id} instead of going to YouTube. The article (Introduction + Key Points + Conclusion, written in the original video language) is generated locally by qwen2.5:7b via Ollama. The first visit takes 30–60 seconds; a loading page refreshes automatically every 5 seconds until it is ready. Subsequent visits are instant (cached in .claude/article_cache/).
Podcast articles: clicking a podcast headline opens a similar generated article at http://localhost:8765/article/podcast/{episode_id}, based on the episode's show notes rather than an audio transcript. Only episodes with show notes of at least 200 characters get an article page; episodes with thinner show notes link directly to the source. Generation follows the same async pattern as YouTube articles.
The article page (for both YouTube and podcast) includes three tag buttons — ✅ verwerken, 📖 later lezen, geen tag (default) — that control which Zotero tag is attached when you save the page via the Zotero Connector. The selected tag is injected as a COinS span (<span class="Z3988">); the full article text is also injected as rft.description, so it appears automatically in the Abstract field of the saved Zotero item.
Serve directory: the HTTP server serves files from
~/.local/share/feedreader-serve/, not from~/Documents/, because macOS TCC prevents system Python from accessing the Documents folder when launched via launchd.
Learning loop — feedreader-learn.py runs daily at 06:15 and matches recently added Zotero items (by URL) against the score log. After ≥30 positives it prints a threshold recommendation. Once the threshold is stable, activate score filtering in feedreader-score.py by adjusting THRESHOLD_GREEN and THRESHOLD_YELLOW.
Privacy note:
feedreader-score.pyruns entirely locally. Feed URLs are fetched directly from the source; no feed content is sent to any cloud service.
12b. RSS feeds via NetNewsWire
NetNewsWire is a free, open-source RSS reader for macOS and iOS, with iCloud sync between both devices. Rather than subscribing to individual feeds, you subscribe to the single filtered feed produced by the feedreader. This way your reading list only contains items that are likely relevant, sorted by relevance score.
Install:
brew install --cask netnewswire
Or download via netnewswire.com.
Subscribe to the filtered feed — add this single URL in NetNewsWire:
http://localhost:8765/filtered.xml
Titles are prefixed with score and label (🟢 54 | Title…). To sort by relevance, click the Date column header → Newest First. Phase 0 encodes the score as a synthetic publication date so that higher-scoring items appear at the top.
Add your source feeds to .claude/feedreader-list.txt instead of directly to NetNewsWire. Useful sources:
- Journal RSS (e.g. BMJ, NEJM, Wiley Health Economics)
- PubMed searches:
https://pubmed.ncbi.nlm.nih.gov/rss/search/?term=[searchterm]&format=abstract - Policy sites and government newsletters
- Trade blogs (e.g. Zorgvisie, Skipr)
- Substack:
[name].substack.com/feed
Phase 0 → phase 1: Items in the filtered feed have not yet been saved — they only exist in your feed reader. You browse through them and scan the scored headlines. Only what is truly relevant gets forwarded to Zotero. That is the phase 1 moment.
From NetNewsWire to the vault (phase 1 → phase 2 → phase 3):
Interesting articles are saved via two routes:
- Via Zotero browser extension or iOS app: open the article, click the Zotero icon → item is saved with metadata to Zotero
_inbox. Use this route for academic articles where you want to retain BibTeX metadata and annotation capabilities. - Direct to
inbox/: pass the URL to Claude Code with the instructioninbox [URL]→ Claude Code fetches the content and saves it as a Markdown file ininbox/, without Zotero. Use this route for non-academic articles, news items, and policy documents.
Privacy note: NetNewsWire stores feed data locally. No reading habits are sent to external servers.
12c. Feedback signals: training the scoring
The HTML reader (http://localhost:8765/filtered.html) captures three types of user behaviour that feed into the learning loop:
| # | Behaviour | Signal strength | Recorded as |
|---|---|---|---|
| 1 | Item clicked + added to Zotero | Strong positive | added_to_zotero: true |
| 2 | Item clicked, not added to Zotero | Weak negative (seen but not interesting enough) | added_to_zotero: false after 3 days |
| 3 | Item not clicked, no 👎 pressed | Ambiguous — not seen, or implicitly ignored | added_to_zotero: false after 3 days — indistinguishable from type 2 |
| 4 | 👎 pressed without clicking | Strong explicit negative (headline was enough to reject) | skipped: true immediately |
| 5 | Item clicked, then 👎 pressed | Strongest negative signal (read and rejected) | skipped: true + added_to_zotero: false |
Type 3 remains ambiguous even with the 👎 button. Items you never looked at receive the same label as items you chose not to add. Only types 4 and 5 are unambiguous rejections.
feedreader-learn.pyreports all three categories separately so you can track signal quality over time.
How to use the 👎 button:
- When a headline is clearly off-topic, press 👎 directly — no need to open the article.
- The item is immediately faded and struck through in the reader.
- The rejection is sent to the server and queued in
skip_queue.jsonl;feedreader-learn.pyprocesses it the next morning.
Future use of explicit negatives: once enough skipped: true items have accumulated, they can be used to build a negative profile that penalises similarity to rejected content: score = sim(item, positive_profile) − λ × sim(item, negative_profile). The learning loop will advise when enough data is available.
12d. Academic feeds: from NetNewsWire to Zotero
For academic articles from NetNewsWire, the recommended route is to always add them to Zotero first before having them processed. This way you have BibTeX metadata and annotation capabilities available:
- Open the article from NetNewsWire in your browser
- Click the Zotero icon in your browser (or use the Zotero iOS app) → item is saved to
_inbox - Process from
_inboxvia type 0 → type 1 in the skill
Step 13: Spaced repetition (Obsidian plugin)
The Obsidian Spaced Repetition plugin uses the SM-2 algorithm to schedule flashcards based on how well you know them. Cards are created in your Markdown files and reviewed within Obsidian.
13a. Install plugin
- Open Obsidian
- Go to Settings → Community Plugins → Browse community plugins
- Search for "Spaced Repetition"
- Install the plugin by Stephen Mwangi
- Enable the plugin via the toggle
13b. Flashcard format
Cards are created in regular Markdown files using the ? separator:
#flashcard
What is the definition of substitution care?
?
Care that is moved from secondary to primary care, maintaining quality but at lower cost.
What are the three pillars of the Integrated Care Agreement?
?
1. Appropriate care
2. Regional collaboration
3. Digitalization and data exchange
Cards can be in the same file as the literature note or written to flashcards/. The recommended approach is to place cards in the note itself: this keeps the card connected to its context and source reference, and during review you can immediately see where a concept came from. The flashcards/ folder is intended for standalone concept cards that are not tied to one specific source — for example definitions or principles you want to remember independently of a paper.
13c. Claude Code generates flashcards automatically
After creating a literature note, you can ask Claude Code to generate flashcards:
Create 3–5 flashcards for the literature note just created.
Use the Obsidian Spaced Repetition format with ? as separator.
Claude Code adds the cards to the end of the existing note (or writes them to flashcards/[same name].md).
13d. Daily review
- Open Obsidian
- Click the card icon in the right sidebar (or use
Cmd + Shift + R) - Review the cards scheduled for today: Easy / Good / Hard
- The plugin automatically schedules the next review based on your rating
Privacy note: All cards and review data are stored as local files in your vault. No cloud sync is required.
Step 14: Set up filter layer per source
This is the core of the 3-phase model: each source has its own capture route (phase 1) and filter moment (phase 2). Below is a per-source overview of all phases and how you indicate what may enter the vault.
Papers (Zotero)
| Phase | What |
|---|---|
| Dump layer | Zotero _inbox collection — central collection bucket for all sources |
| Filter moment | Run index-score.py to rank items by relevance; read abstract in Zotero, or have Claude Code summarize via Qwen3.5:9b (locally) |
| Go | Move item to the relevant collection in your library |
| No-go | Delete item from _inbox — no note is created |
Tag-based filter logic
Claude Code adjusts its evaluation based on the Zotero tag of the item:
| Tag | Treatment |
|---|---|
✅ | Previously approved — skip Go/No-go, go directly to processing |
📖 | Marked as interesting — only ask the Go/No-go question, no summary |
/unread, no tag, or another tag | Generate summary + relevance indication, ask Go or No-go |
Items with unknown tags (e.g. your own project tags or type indicators) are therefore treated as /unread: Claude Code generates a summary and asks for a Go/No-go decision.
No-go is always final: a rejected item is deleted from _inbox and receives no note in the vault. Claude Code always asks for confirmation before deletion.
Reading status in Obsidian
Every literature note gets a status field in its YAML frontmatter:
status: unread— default for all new notesstatus: read— set automatically when the Zotero item had a✅tag (meaning you had already read it before approving)
After reading a note in Obsidian, change status: unread to status: read manually.
To see all unread notes at a glance, create a note with this Dataview query:
TABLE authors, year, journal, tags
FROM "literature"
WHERE status = "unread"
SORT year DESC, file.name ASC
Note: frontmatter tags must be written without
#(e.g.tags: [beleid, zorg]). Obsidian adds the#in the UI automatically. Using#inside a YAML array breaks frontmatter parsing.
Relevance scoring with index-score.py
Before starting the Go/No-go review, you can run index-score.py to get a ranked list of inbox items sorted by semantic similarity to your existing library:
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/index-score.py
The script uses ChromaDB embeddings (all-MiniLM-L6-v2, same model as zotero-mcp) to compute a relevance score (0–100) per item. Items with PDF annotations in Zotero weigh more heavily in the preference profile. Output labels: 🟢 strong match (≥70) · 🟡 possibly relevant (40–69) · 🔴 weak match (<40).
Summary requests
Claude Code can help you with the evaluation — ask for a summary of items in _inbox:
Give me an overview of the items in my Zotero _inbox collection with a 2–3 sentence
summary per item and a relevance assessment for my research on [topic].
Claude Code retrieves the metadata and abstract via Zotero MCP and gives a recommendation per item. You then decide which items deserve the next step.
YouTube videos
| Phase | What |
|---|---|
| Dump layer | Zotero _inbox via iOS share sheet from the YouTube app |
| Filter moment | Watch the first 5–10 minutes, or have Claude Code summarize based on metadata |
| Go | transcript [URL] in Claude Code |
| No-go | Delete item from _inbox |
Podcasts
| Phase | What |
|---|---|
| Dump layer | Zotero _inbox via iOS share sheet from Overcast (overcast.fm URL) |
| Filter moment | Listen to the first 5–10 minutes |
| Go | podcast [URL] in Claude Code (download + transcription + processing) |
| No-go | Delete item from _inbox |
For podcasts with rich show notes (≥ 200 characters), clicking the headline in the HTML reader opens a generated article at /article/podcast/{episode_id} — the same layout as YouTube articles, with tag buttons and abstract injection via COinS. For episodes with thin show notes the headline links directly to the source. You can also ask Claude Code to fetch show notes manually:
Fetch the show notes from [URL] and give a 3-sentence summary.
RSS feeds
| Phase | What |
|---|---|
| Feedreader (fase 1, bron 1) | feedreader-score.py scores all feed items daily; YouTube items are scored using transcript text fetched via youtube_transcript_api; podcast items with show notes ≥ 200 chars have their show notes cached; produces filtered Atom feed + HTML reader sorted by relevance at http://localhost:8765/filtered.html; clicking a YouTube headline opens a generated article (/article/{video_id}) with Zotero tag buttons; clicking a podcast headline (with sufficient show notes) opens a similar article (/article/podcast/{episode_id}); both article types inject the full text into the Zotero Abstract field via rft.description in COinS |
| Phase 1 — Dump layer | Browse the filtered feed in the HTML reader or NetNewsWire; interesting items forwarded to Zotero _inbox via browser extension or iOS app |
| Phase 2 — Filter moment | Scan headline and intro of items in _inbox |
| Go (academic) | Item already in Zotero _inbox → process via type 0 → type 1 in the skill |
| Go (non-academic) | Save via Zotero Connector, or pass inbox [URL] to Claude Code |
| No-go | Mark item as read or delete from NetNewsWire; remove from _inbox if already saved |
Feedback signals in the HTML reader
The HTML reader captures five distinct behaviour types that feed into the learning loop (feedreader-learn.py):
| # | Behaviour | Signal | Recorded as |
|---|---|---|---|
| 1 | Headline clicked + added to Zotero | Strong positive | added_to_zotero: true |
| 2 | Headline clicked, not added to Zotero | Weak negative (seen, not interesting enough) | added_to_zotero: false after 3 days |
| 3 | Not clicked, no 👎 | Ambiguous — not seen, or implicitly ignored | added_to_zotero: false after 3 days (indistinguishable from type 2) |
| 4 | 👎 pressed without clicking | Strong explicit negative (headline was enough to reject) | skipped: true immediately |
| 5 | Headline clicked, then 👎 pressed | Strongest negative signal (read and rejected) | skipped: true + added_to_zotero: false |
Only types 4 and 5 are unambiguous rejections. Type 3 remains ambiguous even with the 👎 button. See Step 12c for details on how these signals are used to calibrate scoring thresholds.
Step 15: Future perspective — local orchestrator
The only component in this stack that does not run fully locally is Claude Code as orchestrator. All generation tasks (summaries, literature notes, syntheses, flashcards) already run via Qwen3.5:9b on Ollama — fully local, fully private. What goes through the Anthropic API are the prompts with which Claude Code steers the workflow: the intake, the phase monitoring, the vault conventions, the iterative Go/No-go dialogue.
For those who also want to solve this layer locally, two serious candidates are emerging.
Open WebUI + MCPO
Open WebUI is a self-hosted chat interface (similar to the Claude.ai interface, but local) that accesses local models via Ollama. From version 0.6.31 onwards it supports MCP natively. The MCPO proxy (Model Context Protocol to OpenAPI) translates stdio-based MCP servers — such as zotero-mcp — to HTTP endpoints that Open WebUI can call. The architecture fits directly onto the existing Mac mini M4 stack: Ollama keeps running, zotero-mcp is made available via MCPO, and Open WebUI acts as the conversational interface in the browser.
Advantages: mature interface, actively maintained, works on macOS without Docker, Qwen3.5:9b is compatible with tool use in this configuration. Disadvantages: browser-based (no terminal workflow like Claude Code), the skill logic and vault conventions must be fully rewritten as a system prompt, no native filesystem integration without an additional MCP server.
ollmcp (mcp-client-for-ollama)
ollmcp is a terminal interface (TUI) that connects Ollama models to multiple MCP servers simultaneously. It has an agent mode with iterative tool execution, human-in-the-loop controls, and model switching. The interface is closer to how Claude Code works — everything in the terminal, no browser. You can connect zotero-mcp, choose Qwen3.5:9b, and pass the skill as a system prompt.
Advantages: terminal-native, close to the current workflow, supports multiple MCP servers simultaneously, human-in-the-loop is built in. Disadvantages: less mature than Open WebUI, writing vault files requires an additional filesystem MCP server, the skill logic must be rebuilt as a system prompt.
Why this is not yet worth the effort
The orchestration layer that Claude Code provides is more than tool calls. It involves phase monitoring across a longer session, vault awareness (knowing what already exists and how it should be linked), the iterative Go/No-go dialogue per item, and reliable adherence to vault conventions across multiple steps. All of this logic currently lives in the skill and CLAUDE.md, and Claude Code follows it accurately.
With a local orchestrator, the same logic must be passed as a system prompt to Qwen3.5:9b. Instruction-following in complex multi-step workflows is noticeably less reliable with local models than with Claude Sonnet — not due to a lack of language capability, but because of consistency across multiple rounds and tools. The result is achievable, but requires considerable extra work for a less robust outcome.
This is also precisely why Claude Code structurally distinguishes itself as an orchestrator from local alternatives: not in raw generation quality (for which Qwen3.5:9b is already strong enough for most tasks), but in the reliability of workflow logic across phases and tools. Whether and when local models will reach this level is an open question. The landscape is changing fast — Open WebUI, ollmcp, and similar tools are actively in development and worth continuing to follow.
Step 16: iPad access via SSH terminal
Phase 1 of the workflow run naturally on an iPad — the feedreader HTML reader at http://[mac-ip]:8765/filtered.html is accessible from any browser on your local network, and adding items to Zotero works via the iOS app. Phase 2 and Phase 3 require Claude Code, which runs on the Mac mini.
Two routes to Phase 2 from an iPad:
- In-browser terminal (Step 17, recommended): the HTML reader has an embedded terminal panel (⌨️ button in the header) that runs directly in Safari, without a separate app. This keeps feedreader browsing and Phase 2 work in the same tab. See Step 17 for setup.
- SSH terminal app (this step): connect to the Mac mini via SSH using Termius or Blink Shell. This is slightly more powerful (full terminal, no browser overhead) and works outside your home network via Tailscale. It is also the fallback when the browser terminal is not available.
The rest of this page documents the SSH approach.
16a. Enable SSH on the Mac mini
- Open System Settings → General → Sharing
- Enable Remote Login
- Enable "Allow full disk access for remote users" on — macOS protects folders like Documents and Desktop via TCC (privacy framework), which blocks SSH access even for your own account unless this option is enabled
16b. Install a terminal app on your iPad
Recommended: Termius (free tier is sufficient for a single SSH connection)
Alternative: Blink Shell (one-time purchase, slightly more powerful but not necessary)
No account is required in Termius for basic use. The built-in sync and AI features are not needed for this workflow — skip account creation when prompted.
16c. Set up the connection in Termius (home network)
-
Open Termius → tap + → New Host
-
Fill in the following fields:
Field Value Label Mac Mini(or any name you like)Hostname the local IP address of your Mac mini (see below) Port 22Username your macOS account name Password your Mac login password -
Tap Save, then tap the host to connect
-
Termius stores the host key silently on first connection — no manual confirmation needed
Finding your Mac mini's local IP address:
Run the following in Terminal on the Mac mini:
ifconfig | grep "inet " | grep -v 127.0.0.1
The address shown (e.g. 192.168.178.x) is your local IP. For a stable address, assign a fixed IP to the Mac mini's MAC address in your router's DHCP settings — this prevents the address from changing after a restart.
Verifying the host key fingerprint:
To verify that Termius stored the correct key, run the following on the Mac mini:
for f in /etc/ssh/ssh_host_*_key.pub; do ssh-keygen -lf "$f"; done
This lists the fingerprints for all three host key types (ECDSA, ED25519, RSA). In Termius, check Edit host → Known Host and confirm that the stored fingerprint matches one of these — the ED25519 fingerprint is the most commonly used in modern connections.
16d. Set up SSH key authentication (recommended)
Entering a password every session is inconvenient. An SSH key pair allows passwordless login.
In Termius:
- Go to Settings → Keychain → + → Generate key
- Choose Ed25519, give it a name, tap Generate
- Tap the key → Export public key → copy the text to your clipboard
On the Mac mini, paste the public key into your authorized_keys file:
echo "PASTE_YOUR_PUBLIC_KEY_HERE" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
Back in Termius, edit the host and set the Key field to the key you just created. From now on, the connection opens without a password prompt.
16e. Ensure required services are running on the Mac mini
Before starting Claude Code, two background services must be running on the Mac mini:
Zotero — Claude Code communicates with Zotero via its local API on port 23119. If Zotero is not open, all MCP calls will fail.
To start Zotero automatically at login, open System Settings on the Mac mini, go to General → Login Items, click the + button, and select Zotero from your Applications folder. After this one-time setup, Zotero will always be running when the Mac mini is on.
If Zotero is not running for any reason, you can start it from the SSH session itself (this works as long as the Mac mini has an active logged-in user session):
open -a Zotero
Ollama — required for local model generation. Ollama starts automatically as a background service after installation, so no action is normally needed. If it is not running:
open -a Ollama
Obsidian does not need to be running. Claude Code writes Markdown files directly to the vault on the filesystem — Obsidian picks up the changes the next time you open it.
16f. Run Claude Code via SSH
Once connected, navigate to the vault and start Claude Code:
cd ~/Documents/ResearchVault
claude
You are now in exactly the same environment as when using Claude Code on the Mac mini directly — including Zotero MCP, Ollama, whisper.cpp, and all workflow skills. Type /research to start the research workflow.
First-time login: The Claude Code CLI stores its credentials separately from the desktop or VS Code login. The first time you run claude via SSH it will tell you that you are not logged in and ask you to run /login. Do the following:
- Type
/logininside Claude Code - Claude Code displays a URL
- Open that URL in Safari on your iPad
- Sign in with your Claude account
- After confirmation the token is saved to
~/.claude/on the Mac mini — you will not be asked again
One-step shortcut: Add the following alias to ~/.zshrc on the Mac mini so you can launch Claude Code in the vault with a single command:
alias rv='cd ~/Documents/ResearchVault && claude "start research workflow"'
Activate it once with source ~/.zshrc, then from any SSH session just type rv.
A keyboard (Smart Keyboard, Magic Keyboard, or similar) makes this experience comfortable on an iPad.
16g. Outside your home network — Tailscale
The setup above works on your local network. To also run Phase 2 and Phase 3 from outside your home (e.g. on the road with an iPad and cellular), you need a way to reach the Mac mini securely over the internet. Tailscale is the simplest option: it creates an encrypted peer-to-peer network between your devices without requiring port forwarding or a VPN server.
Installation takes about 10 minutes:
- Download Tailscale on the Mac mini and install it
- Download the Tailscale app on your iPad from the App Store
- Sign in to the same account (Google, GitHub, or email) on both devices
- Both devices now appear in your Tailscale network with stable
100.x.x.xaddresses - In Termius, create a second host entry using the Tailscale IP of your Mac mini (visible in the Tailscale app on either device) as the hostname
From that point on, the SSH connection works identically whether you are at home or anywhere else — no port forwarding needed, no firewall rules required. Tailscale's free tier supports up to 100 devices, which is more than sufficient for personal use.
Step 17: In-browser terminal — Phase 1 and Phase 2 in one tab
By default, feedreader (reading the filtered feed) happens in the browser, and Phase 2 (running Claude Code) happens in a separate terminal window. This step embeds a fully interactive terminal directly into the feedreader HTML reader, so both phases run in the same browser tab — on Mac, iPad, or any other device on your local network.
How it works
VS Code embeds a terminal in the browser using xterm.js (a terminal renderer) connected to node-pty (a pseudo-terminal backend) over a WebSocket. The same architecture is available as a standalone tool: ttyd.
ttyd runs a local HTTP server that serves an xterm.js terminal connected to a shell on your Mac mini. The feedreader HTML reader embeds this terminal as an iframe. Clicking the ⌨️ terminal button in the header reveals the terminal panel alongside your article list.
The iframe URL is derived dynamically from window.location.hostname, so the terminal works whether you access the page from the Mac mini itself (localhost) or from an iPad on the local network (via the Mac's IP address).
17a. Install ttyd
brew install ttyd
17b. Load the launchd agent
A launchd agent starts ttyd automatically at login and keeps it running:
launchctl load ~/Library/LaunchAgents/nl.researchvault.ttyd.plist
This agent starts ttyd on port 7681 with the --writable flag enabled, which is required for interactive use. Without --writable, the terminal renders but ignores all keyboard input.
To verify it is running:
launchctl list | grep ttyd
A PID (number) in the first column confirms it is active. Log output is written to /tmp/ttyd.log.
17c. Use the terminal in the HTML reader
- Open
http://localhost:8765/filtered.html(Mac) orhttp://[mac-ip]:8765/filtered.html(iPad) - Click ⌨️ terminal in the header bar
- A terminal panel opens on the right (side-by-side on desktop, stacked below on narrow screens)
- The terminal is a full interactive shell — navigate to the vault and start Claude Code:
cd ~/Documents/ResearchVault
claude
Type /research to start the research workflow skill. You can now read feedreader items on the left and run Phase 2 in the terminal on the right, without switching tabs or apps.
The terminal panel is lazy-loaded: ttyd is only contacted when you first open the panel. If you never click the button, nothing changes in performance or behaviour.
17d. Optional: persistent sessions with tmux
By default, each time you open the terminal panel a new shell session starts. If you want to reconnect to an existing Claude Code session after navigating away or closing the panel, install tmux:
brew install tmux
Then unload the current agent, update the plist to use tmux, and reload:
launchctl unload ~/Library/LaunchAgents/nl.researchvault.ttyd.plist
Edit ~/Library/LaunchAgents/nl.researchvault.ttyd.plist and replace the ProgramArguments block with:
<key>ProgramArguments</key>
<array>
<string>/bin/sh</string>
<string>-c</string>
<string>/opt/homebrew/bin/tmux new-session -d -s phase2 2>/dev/null; exec /opt/homebrew/bin/ttyd --port 7681 --writable /opt/homebrew/bin/tmux attach -t phase2</string>
</array>
Then reload:
launchctl load ~/Library/LaunchAgents/nl.researchvault.ttyd.plist
Now every connection to the terminal panel attaches to the same persistent phase2 session. A Claude Code conversation that was running when you closed the panel will still be there when you reopen it.
17e. Security note
ttyd listens on all network interfaces (0.0.0.0) by default, which is what makes it reachable from an iPad. This also means any device on your local network can open a terminal on your Mac mini. This is acceptable for a trusted home network — if you use public or shared networks, stop the agent before connecting:
launchctl unload ~/Library/LaunchAgents/nl.researchvault.ttyd.plist
Using the workflow
This section describes how to use the research workflow day to day, assuming everything has been installed (see the Installation and Extensions sections).
The workflow has three phases. Phase 3 is handled by tooling; phases 1 and 2 involve you.
| Phase | What happens | Your role |
|---|---|---|
| 1 — Cast wide | Items flow into Zotero _inbox from three sources | Forward items from the feedreader or share directly from iOS |
| 2 — Filter | Claude Code presents each _inbox item with a relevance score and summary; you decide | Go / No-go per item |
| 3 — Process | Claude Code writes a structured literature note to the Obsidian vault | Review the generated note |
Three sources into Zotero _inbox (Phase 1)
All items that enter the workflow pass through Zotero _inbox first. They arrive from three distinct sources:
| Source | What it is | Filtering before _inbox | Phase 2 treatment |
|---|---|---|---|
| 1. Feedreader | Aggregated RSS/YouTube/podcast feeds scored daily by feedreader-score.py; you browse the HTML reader or NetNewsWire and forward interesting items | Partly autonomous (scoring algorithm), partly manual (your click) | Standard Go/No-go |
| 2. iOS share sheet | Items you share directly from YouTube, Overcast, Safari, or NetNewsWire | Done by you — you consumed or deliberately selected the item | Lighter: you already pre-filtered |
| 3. Desktop / email / notes | Manual additions from any other source | None — comparable to source 1 | Standard Go/No-go |
Key distinction: items from source 2 arrive after you have already read, watched, or listened to them (or deliberately chosen them in NetNewsWire). The Phase 2 Go/No-go step is therefore lighter for these items. Items from sources 1 and 3 still need a full Phase 2 review.
One nuance for source 2: if you click an item in the feedreader HTML reader and then share it to Zotero via iOS — without having read it fully — it still needs proper reading before a Go in Phase 2. The share action is not always a signal that the content has been consumed.
Current state: calibration mode
The feedreader is in calibration mode until its scoring threshold is stable. During this phase:
- The feedreader runs automatically but does not yet route items to Zotero
_inboxon its own. - You browse the HTML reader daily and give feedback signals: click on interesting items (and add them to Zotero) for positive signals, press 👎 on irrelevant items for explicit negative signals.
feedreader-learn.pyprocesses these signals every morning at 06:15.- Once ≥30 positive signals have accumulated,
feedreader-learn.pywill recommend an initial threshold.
Future state: autonomous mode
Once the threshold is set, the feedreader will route items above the threshold automatically to Zotero _inbox without any action from you. The HTML reader stays available as a transparency window and as an ongoing calibration channel — occasional browsing in NetNewsWire and sharing items to Zotero continues to refine the threshold over time.
See Roadmap for the remaining steps.
Phase 1: sources into Zotero _inbox
Phase 1 is the collection step. Items from three sources flow into Zotero _inbox. The feedreader handles source 1 automatically; sources 2 and 3 are always manual.
Source 1: the feedreader
feedreader-score.py runs daily at 06:00 via launchd. It fetches all feeds from feedreader-list.txt, scores each item by semantic similarity to your Zotero library, and produces:
- HTML reader (Mac, iPhone, iPad):
http://localhost:8765/filtered.html - Atom feed (NetNewsWire):
http://localhost:8765/filtered.xml
Reading the item list
Each item shows:
- Score badge — relevance score 0–100, colour-coded: 🟢 ≥50 · 🟡 40–49 · 🔴 <40
- Title — links to the original source
- Source and date — feed name and publication date
- Snippet — short text excerpt (2 lines max): first meaningful prose from the description or show notes; for YouTube, from the video description or — if that contains only links — the opening lines of the cached transcript
Type filters in the header: Alles / 📄 web / ▶️ YouTube / 🎙️ podcast. Three sort views: Op score (default), Op bron, Op datum.
Forwarding to Zotero _inbox
When a headline is interesting, click it (marks as read) and save to Zotero _inbox via the browser extension or iOS app. This is the phase 1 action for source 1.
Giving feedback: calibrating the feedreader
The feedreader learns from your behaviour. Two types of signal matter:
| Behaviour | Signal | Recorded as |
|---|---|---|
| Clicked + added to Zotero | Strong positive | added_to_zotero: true |
| Clicked, not added | Weak negative | added_to_zotero: false after 3 days |
| Not clicked, no 👎 | Ambiguous | added_to_zotero: false after 3 days |
| 👎 without clicking | Strong explicit negative | skipped: true immediately |
| Clicked, then 👎 | Strongest negative | skipped: true + added_to_zotero: false |
Use 👎 liberally on off-topic headlines. Unclicked items are ambiguous — they could mean "not seen" just as easily as "not interesting." Only 👎 signals are unambiguous rejections.
feedreader-learn.py runs at 06:15 every morning and tracks signal quality. Run it manually for a progress report:
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/feedreader-learn.py
After ≥30 positive signals, it prints an initial threshold recommendation. Apply it in .claude/feedreader-score.py:
THRESHOLD_GREEN = ... # from the recommendation
THRESHOLD_YELLOW = ... # from the recommendation
Learning is continuous. After the initial threshold is set, every 👎 signal and every Zotero addition continues to refine the scoring. Occasional browsing in NetNewsWire and sharing items to Zotero remains useful even in autonomous mode.
Hiding read and skipped items
Click verberg gelezen / overgeslagen in the header to hide processed items.
In-browser terminal
The ⌨️ terminal button opens an embedded ttyd terminal panel (port 7681) alongside the article list. Use it to start a Phase 2 session without switching apps:
cd ~/Documents/ResearchVault && claude
Then type beoordeel inbox to begin the Go/No-go review.
NetNewsWire as an alternative reader
filtered.xml can be added as a single subscription in NetNewsWire on macOS or iOS. Titles are prefixed with score and label (🟢 54 | Title…). Sorting by Newest First equals sorting by relevance (the feedreader encodes scores as synthetic dates).
When you share an item from NetNewsWire to Zotero via the iOS share sheet, it behaves like source 2 — a deliberate choice — and contributes a clean positive calibration signal.
Source 2: iOS share sheet
Items you share directly from YouTube, Overcast, Safari, or NetNewsWire arrive in Zotero _inbox as deliberate choices. You have typically already consumed the content (watched the video, listened to the podcast, read the article) or you made a specific decision to save it.
Phase 2 treatment is lighter for these items: no summary needed for content you have already evaluated. Claude Code will recognise the context and ask only for a Go/No-go confirmation.
One nuance: if you clicked a feedreader headline and then shared it via the iOS share button without having read the full content, it is still source 1 in terms of depth — you will need to read/watch/listen before confirming Go in Phase 2.
Items from the iOS share sheet may carry a Zotero tag from the source app:
✅— you marked it for processing; Phase 2 skips the Go/No-go and goes directly to Phase 3📖— you marked it as "read later"; Phase 2 asks only for Go/No-go confirmation
Source 3: desktop / email / notes
Items added manually from email, a desktop browser, or notes follow the same path as source 1: they need a full Phase 2 review before entering the vault. Add them to Zotero _inbox via the Zotero browser extension or the desktop app, or pass a URL directly:
inbox [URL]
This fetches the article and saves it as Markdown in inbox/ without going through Zotero.
Phase 2: Go/No-go in Claude Code
Phase 2 is the filter step. Items in Zotero _inbox are reviewed one by one; you decide which ones enter the vault (Go) and which ones do not (No-go).
Starting a Phase 2 session
Make sure Zotero is running (the local API must be active), then:
cd ~/Documents/ResearchVault && claude
Start the review with either of these:
beoordeel inbox
or via the workflow menu:
/research → [0]
Optionally, run index-score.py first to pre-rank items:
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/index-score.py
This scores each _inbox item by semantic similarity to your existing Zotero library (0–100) and prints a sorted list. Claude Code uses these scores during the review session.
How Claude Code reviews each item
The treatment depends on the item's Zotero tag and its relevance score.
Tag-based treatment
| Zotero tag | What Claude Code does |
|---|---|
✅ | Marked as certain Go in Phase 1 — skip the Go/No-go question and process directly in Phase 3 |
📖 | Needs more evaluation — generate a compact summary via summarize_item.py (local, privacy-safe); show path; wait for Go/No-go |
No tag, /unread, or any other tag | Score-based treatment (see below) |
The 📖 tag is set in Phase 1 when you need more information before deciding. In Phase 2, summarize_item.py generates a compact summary (Introduction · Key findings · Relevance) locally via Qwen3.5:9b and writes it to inbox/_summary_ITEMKEY.md. Claude Code shows you the path; you read the file and give your decision. No summary text reaches the Anthropic API.
Score-based treatment (for untagged items)
| Score | Treatment |
|---|---|
| 🟢 ≥70 | Show title + score; ask Go/No-go directly — strong match, no summary needed |
| 🟡 40–69 | Generate a 2–3 sentence summary via Qwen3.5:9b (local); ask Go/No-go |
| 🔴 <40 | Propose No-go ("Score: X — low match with your library. No-go?"); you can still choose Go |
Claude Code asks for one Go/No-go decision at a time, giving you space to decide per item.
Go
Go means: this item is approved for Phase 3.
Claude Code moves the item to the appropriate Zotero collection and calls the local subagent process_item.py with the item key and metadata (title, authors, year, tags). The subagent fetches the full text locally, generates the literature note via Qwen3.5:9b, builds the YAML frontmatter, and writes the .md file to literature/. Claude Code receives only a JSON status object — no source content.
The status field in the frontmatter is set based on the Zotero tag:
status: read— if the item had a✅tag (you had already read it)status: unread— in all other cases
No-go
No-go means: this item will not enter the vault.
Claude Code always asks for confirmation before deleting. After confirmation, the item is permanently removed from Zotero _inbox. No note is created. There is no intermediate option — a No-go is final.
High-definition mode
For a higher-quality summary, add --hd to activate Claude Sonnet 4.6 instead of the local Qwen model:
beoordeel inbox --hd
Claude Code will ask for explicit confirmation before sending any content to the Anthropic API.
End of session
At the end of the session, Claude Code shows a summary: X items approved, Y items removed.
If new papers were added to your Zotero library, update the semantic search database:
update-zotero # full update including full text (recommended)
zotero-mcp db-status # check current database status
This ensures that new additions are included in future relevance scores.
Phase 3: processing to the vault
Phase 3 converts approved items into structured Obsidian notes. All generation runs locally via Qwen3.5:9b. Add --hd to any command to use Claude Sonnet 4.6 instead (after explicit confirmation).
Papers
Papers reach _inbox via the Zotero browser extension, the iOS app, or automatically via the feedreader (after calibration). After a Go decision in Phase 2:
verwerk recente papers
Claude Code:
- Retrieves metadata from Zotero MCP (title, authors, year, journal, citation key, tags) — no full text
- Calls the local subagent
process_item.pywith only the item key and metadata:process_item.pyfetches the full text locally, generates a structured note via Qwen3.5:9b, builds the YAML frontmatter, and writes the.mdfile toliterature/- Claude Code receives only
{"status": "ok", "path": "literature/..."}— no source content
- Adds
[[internal links]]to related notes in the vault - Removes the item from Zotero
_inbox
The generated note contains:
- YAML frontmatter (title, authors, year, journal, citation key, tags, status)
- Core question and main argument
- Key findings (3–5 points)
- Methodological notes
- Relevant quotes (original language)
- Links to related notes
Notes are saved to literature/[citation-key].md.
Privacy: no paper content ever appears in Claude Code's context.
process_item.pyis a self-contained local subagent — it fetches, generates, and writes without returning any source text to the orchestration layer.
YouTube videos
Videos added via the iOS share sheet from the YouTube app arrive in Zotero _inbox as watch URLs. After a Go decision:
transcript [URL]
Claude Code:
- Checks whether a transcript is already cached from the feedreader (
.claude/transcript_cache/{video_id}.json) — no re-fetch needed if so - Falls back to yt-dlp if no cache exists
- Generates a structured note locally via Qwen3.5:9b:
- Title, speaker, channel, date, URL
- Summary (3–5 sentences)
- Key points with timestamps
- Relevant quotes with timestamps
- Adds frontmatter,
[[internal links]], and#videotag - Removes the raw transcript from
inbox/and the item from Zotero_inbox
Notes are saved to literature/[speaker-year-keyword].md.
Podcasts
Podcast episodes added from Overcast (via iOS share sheet) arrive in Zotero _inbox as overcast.fm URLs. After a Go decision:
podcast [URL]
Claude Code:
- Checks for a cached show notes file in
.claude/transcript_cache/podcast_{episode_id}.json - If no cache: downloads audio via yt-dlp, transcribes locally via whisper.cpp (automatic language detection)
- For long episodes (> 45 min): generates a layered summary first (main line → per segment), then the final note
- Generates a structured note locally via Qwen3.5:9b:
- Title, speaker(s), programme/channel, date, URL
- Summary (3–5 sentences)
- Key points with timestamps
- Relevant quotes with timestamps (original language)
- Adds frontmatter,
[[internal links]], and#podcasttag - Removes raw
.mp3and.txtfiles frominbox/and the item from Zotero_inbox
Notes are saved to literature/[speaker-year-keyword].md.
RSS web articles
Non-academic articles from RSS feeds that you forward to _inbox can be processed in two ways:
Via Zotero (recommended for articles worth citing):
verwerk recente papers
The item is already in _inbox with metadata from the Zotero Connector. Processed as a standard literature note.
Direct to inbox/ (for news items, policy documents, quick reads):
inbox [URL]
Claude Code fetches the article and saves it as a Markdown file in inbox/. You can then ask Claude Code to convert it to a note in literature/.
Notes get #web or #beleid as appropriate.
After processing
After each session, check whether:
- New notes are linked to related existing notes (
[[double brackets]]) - Relevant syntheses in
syntheses/need updating - Flashcards should be created for the new notes:
maak flashcards voor literature/[note].md
If new papers were added to Zotero, update the semantic search database:
update-zotero
Roadmap
The workflow is production-ready and in daily use, but several planned improvements remain. This page describes what has been done and what is still coming.
Completed
Rename: phase0-* → feedreader-* ✅
All scripts, configuration files, launchd agents, and internal references renamed. "Phase 0" was not a separate phase in the workflow but the automatic filtering function within Phase 1. The name feedreader better reflects the function: scoring, filtering, and serving RSS/YouTube/podcast feeds.
Inline snippets in the HTML reader ✅
The HTML reader now shows a short text excerpt below each item title (max. 2 lines). For web articles and podcasts this comes from the RSS description or show notes. For YouTube videos it comes from the video description, with a fallback to the opening lines of the cached transcript for channels that provide no meaningful description.
This replaced an earlier design where clicking a YouTube or podcast headline generated a full reading article via Ollama (qwen2.5:7b). That approach placed article generation in the wrong part of the workflow: the HTML reader is for filtering, not for deep reading. All headlines now link directly to the original source URL.
In progress
Threshold calibration
The feedreader is learning your preferences by observing which items you add to Zotero (positive signal) and which items you explicitly reject with 👎 (negative signal).
Current requirement: ≥30 positive signals before feedreader-learn.py can produce a reliable initial threshold recommendation.
How to contribute signals:
- Browse the HTML reader daily
- Click on interesting items and add them to Zotero
_inbox - Press 👎 on clearly irrelevant items
feedreader-learn.py runs automatically at 06:15 every morning. Run it manually for a progress report:
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/feedreader-learn.py
Once ≥30 positives are reached, it will print a threshold recommendation. Apply it in .claude/feedreader-score.py:
THRESHOLD_GREEN = ... # from the recommendation
THRESHOLD_YELLOW = ... # from the recommendation
Learning is continuous. After the initial threshold is set, every 👎 signal and every Zotero addition continues to refine the scoring. There is no endpoint — the feedreader keeps improving as long as you interact with it.
Planned
Autonomous mode — after initial threshold is set
Once the threshold is configured, the feedreader will route items above the threshold automatically to Zotero _inbox via the Zotero web API — without any action from you.
This changes the daily rhythm:
- Now: browse HTML reader → manually forward interesting items → Phase 2
- After: feedreader fills
_inboxautonomously → you interact mainly in Phase 2 and 3
The HTML reader stays available as a transparency window and as an ongoing calibration channel. The 👎 button continues to work as a correction mechanism. Occasional browsing in NetNewsWire and sharing items to Zotero remains useful for keeping the scoring calibrated over time.
Skill update — after autonomous mode
The research workflow skill (research-workflow-skill-v1.17.md) will be updated to reflect the fully autonomous feedreader as the default state for source 1, and to document the three-source model more precisely in the daily workflow description.
NetNewsWire integration (ongoing, optional)
filtered.xml is available in NetNewsWire on iOS. Sharing an item from NetNewsWire to Zotero via the iOS share sheet generates a clean positive calibration signal and contributes to the ongoing threshold refinement — making NetNewsWire a permanent optional calibration channel, not just a calibration-phase tool.
Longer-term perspective
Local orchestrator
The only component in this stack that does not run fully locally is Claude Code as orchestrator. All generation tasks (summaries, notes, syntheses, flashcards) already run via Qwen3.5:9b on Ollama — fully local, fully private. What goes through the Anthropic API are the workflow instructions: intake, phase monitoring, vault conventions, and the iterative Go/No-go dialogue.
Two serious candidates exist for replacing this layer with a local model:
Open WebUI + MCPO — a self-hosted browser interface that connects to Ollama and can call MCP servers (including zotero-mcp) via the MCPO proxy. Mature interface, actively maintained, works on macOS without Docker.
ollmcp — a terminal interface (TUI) that connects Ollama models to multiple MCP servers simultaneously, with an agent mode and human-in-the-loop controls. Closer to how Claude Code works today.
Neither is yet a full replacement. The orchestration layer that Claude Code provides involves multi-phase session awareness, vault conventions, and reliable instruction-following across many rounds and tools. The landscape is changing fast and worth continuing to follow.
Activating the skill & daily workflow
Activating the research workflow skill
The skill is a markdown file that tells Claude Code how to behave during research sessions. One-time installation:
# Create the skills folder in your vault
mkdir -p ~/Documents/ResearchVault/.claude/skills
# Copy the skill file to the vault
cp research-workflow-skill-v1.16.md ~/Documents/ResearchVault/.claude/skills/
Then add the following line to your CLAUDE.md (at the bottom):
## Active skills
- Read and follow `.claude/skills/research-workflow-skill-v1.16.md` during every research session.
From that point on, the skill is active as soon as you open Claude Code in your vault. You can start the workflow by typing: /research or simply "start research workflow".
Daily workflow after installation
Once everything is set up, the daily workflow is straightforward. The feedreader runs automatically — no action required.
- Browse the filtered feed at
http://localhost:8765/filtered.html(or in NetNewsWire viahttp://localhost:8765/filtered.xml). Items are sorted by relevance score. Send interesting ones to Zotero_inboxvia the browser extension or iOS app. - Start Zotero (so the local API is active)
- Open Terminal in your vault:
cd ~/Documents/ResearchVault && claude - Activate the skill: type
/researchor "start research workflow" - Claude Code asks an intake question and guides you interactively from there
You do not need to know exactly what you are looking for — the skill is designed to help you with that.
Full session flow
- Browse the filtered feed and forward interesting items to Zotero
_inbox - Start Zotero
- Open Terminal, navigate to your vault, and start Claude Code:
cd ~/Documents/ResearchVault claude - Activate the research workflow:
or just type:/researchstart research workflow - Optionally, run
index-score.pyfirst to prioritize your review:
This ranks all~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/index-score.py_inboxitems by semantic similarity to your existing library (using the ChromaDB embeddings from zotero-mcp), so you know which items to focus on. - Claude Code retrieves all items from your Zotero
_inboxand presents each one with a short summary and relevance assessment — the summary is generated locally by Qwen3.5:9b. You respond Go or No-go per item. - For each Go: Claude Code writes a structured literature note using the safe pipeline below.
- For each No-go: Claude Code removes the item from
_inbox(after your confirmation). - At the end of the session, Claude Code shows a summary: X approved, Y removed. If new papers were added, update the semantic search database:
Or use the alias:zotero-mcp update-db # quick (metadata only) zotero-mcp update-db --fulltext # recommended (includes full text)update-zotero(equivalent to--fulltext). Check database status withzotero-mcp db-status.
Helper scripts
The workflow uses three helper scripts in .claude/. They keep source content out of Claude Code's context and handle Zotero write operations.
fetch-fulltext.py — retrieve and save attachment text
Fetches the full text of a Zotero attachment and saves it to a local file. Only prints status; never prints content.
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/fetch-fulltext.py ITEMKEY inbox/bron.txt
# Output: Saved: inbox/bron.txt (12,345 chars, type: application/pdf)
ollama-generate.py — generate text via Ollama REST API
Calls Ollama's REST API directly (no CLI, no ANSI codes). Prepends /no_think to suppress Qwen3.5:9b's reasoning step. Prints only status lines.
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/ollama-generate.py \
--input inbox/bron.txt \
--output literature/notitie.md \
--prompt "Write a literature note in Dutch..."
# Output: Input: inbox/bron.txt (12,345 chars) | Written: literature/notitie.md (3,200 chars)
zotero-remove-from-inbox.py — remove processed item from _inbox
Removes the item from the _inbox collection in Zotero via the web API. Requires ZOTERO_API_KEY in the environment or .env file (see step 4d).
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/zotero-remove-from-inbox.py ITEMKEY
# Output: Item ITEMKEY removed from _inbox.
Troubleshooting
| Problem | Possible cause | Solution |
|---|---|---|
| Zotero MCP returns no results | Zotero is not open | Start Zotero and check http://localhost:23119/ |
| Local API not available | Setting not checked | Zotero → Settings → Advanced → enable local API |
zotero-mcp not found | uv path not in shell | Add ~/.local/bin to $PATH in ~/.zshrc |
| Semantic search returns no results | Database not initialized | Run zotero-mcp update-db |
| Claude Code does not see the MCP tool | Configuration file missing | Check ~/.claude/claude_desktop_config.json |
| Ollama not responding | Service not started | Run ollama serve or brew services start ollama |
| yt-dlp returns no subtitles | Video has no (auto-)subtitles | Try --sub-lang en or check whether the video has subtitles at all |
| launchd update not running | Zotero is not open at the scheduled time | Start Zotero manually and run update-zotero, or choose "Auto on startup" in step 10c option 1 |
| whisper-cpp gives an error | Model not yet downloaded | Wait for the first download, or check disk space |
| Whisper transcription is inaccurate | Low audio quality or incorrect language detection | Use --model medium for better quality, or specify the language explicitly with --language nl or --language en if automatic detection picks the wrong language |
| NetNewsWire not syncing | No sync configured (local always works) | NetNewsWire works locally by default; iCloud sync is optional |
| Obsidian flashcards not appearing | Plugin not enabled | Settings → Community Plugins → enable Spaced Repetition |
| Flashcards not recognized | Incorrect format | Check that ? is on its own line and #flashcard is present |
Privacy overview
| Component | Data local? | Notes |
|---|---|---|
| Zotero + local API | ✅ Fully | Runs on localhost, no cloud |
| Zotero MCP | ✅ Fully | Local connection; write operations use Zotero web API with your own key |
| Obsidian vault | ✅ Fully | Regular files on your Mac |
| Ollama + Qwen3.5:9b | ✅ Fully | Model runs locally on M4; default for all generative tasks |
| yt-dlp | ✅ Fully | Scraping executed locally |
| whisper.cpp | ✅ Fully | Transcription locally on M4 via Metal |
| NetNewsWire | ✅ Fully | RSS data stored locally, no account |
| Obsidian Spaced Repetition | ✅ Fully | Cards and review data in vault files |
| Claude Code — orchestration | ⚠️ Partially | Workflow instructions and metadata go to the Anthropic API; source content must not |
Claude Code — --hd mode | ⚠️ Partially | Only on explicit --hd request: prompt and source content go to Anthropic API (Claude Sonnet 4.6) |
The content privacy rule
The most important privacy boundary in this workflow is not which tools you use — it is whether source content appears in Claude Code's context.
Claude Code communicates with the Anthropic API in every session. This is unavoidable: it is how the orchestration layer works. What you can control is whether the content of your papers, transcripts, or articles gets included in that communication.
The rule: source content (full text of papers, article HTML, transcripts) must never be returned as output of a Bash command. The moment text appears as tool output, it has reached the Anthropic API.
The safe pipeline — single subagent call:
# One command: fetch, generate, prepend frontmatter, clean up — only JSON status returned
~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/process_item.py \
--item-key ITEMKEY \
--title "Title of paper" \
--authors "Smith, John" \
--year 2024 \
--citation-key smith2024keyword \
--zotero-url "zotero://select/library/1/items/ITEMKEY" \
--tags "health-economics" \
--status unread
# → {"status": "ok", "path": "literature/smith2024keyword.md"}
What goes to the Anthropic API in this pipeline: only the item key and metadata (title, authors, year, tags). What stays local: the full text, the Qwen3.5:9b generation, the note body, and the final .md file.
What process_item.py does internally (all local):
fetch-fulltext.py— fetches the Zotero attachment and writes it toinbox/_tmp_ITEMKEY.txt; prints only file size and status.ollama-generate.py— calls the Ollama REST API directly (no CLI, no ANSI codes), generates the note body, writes to a temp file.- Frontmatter is built from the metadata arguments and prepended to the note body.
- The final note is written to
literature/[citation-key].md. - Temp files in
inbox/are removed.
Neither tool ever prints source content to the terminal or to Claude Code's context. Claude Code only sees the JSON status object.
Conclusion: in the default mode, no paper content, transcript, or note text leaves the Mac mini. Claude Code orchestrates the workflow (instructions go to Anthropic), but all generative work runs locally via Qwen3.5:9b. Only when you explicitly request
--hddoes source content go to the Anthropic API — Claude Code always asks for confirmation first.