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:

PhaseGoalHow
1 — Cast wideCapture everything relevantItems 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 — FilterYou decide what enters the vaultindex-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 — ProcessFull processing of approved itemsThe 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

ToolRoleLocal / Cloud
ZoteroReference manager and central inboxLocal
Zotero MCPConnects Claude Code to your Zotero library via local APILocal
ObsidianMarkdown-based note-taking and knowledge baseLocal
OllamaLocal language model for offline tasksLocal
yt-dlpDownload YouTube transcripts and podcast audioLocal
whisper.cppLocal speech-to-text transcription for podcastsLocal
NetNewsWireRSS reader for academic and non-academic feedsLocal
Claude CodeAI 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

  1. Install Homebrew (package manager)
  2. Install and configure Zotero 7 (including _inbox collection)
  3. Set up Python environment
  4. Install and configure Zotero MCP
  5. Install Claude Code
  6. Install Ollama (local language model)
  7. Install Obsidian and create vault
  8. Connect everything: configure Claude Code with MCP
  9. Run first test
  10. Optional extensions (yt-dlp, semantic search, automatic updates)
  11. Podcast integration (whisper.cpp)
  12. RSS integration + feedreader filtering (NetNewsWire + feedreader-score.py)
  13. Spaced repetition (Obsidian plugin)
  14. 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:

  1. Open Zotero
  2. Go to Zotero → Settings → Advanced (or Cmd + ,)
  3. Scroll down to the Other section
  4. Check the box next to "Allow other applications on this computer to communicate with Zotero"
  5. 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).

  1. In Zotero, right-click My LibraryNew Collection
  2. Name the collection _inbox (the underscore ensures it appears at the top of the list)
  3. Set this as the default destination in the Zotero Connector: open the browser extension → Settings → set the default collection to _inbox
  4. 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.

Better BibTeX significantly improves annotation extraction:

  1. Download the latest .xpi from retorque.re/zotero-better-bibtex/installation
  2. In Zotero: Tools → Plugins → Gear icon → Install from file
  3. Select the downloaded .xpi file
  4. 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.

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 Desktop if you plan to install it later, or skip
  • Semantic search: you can skip this now and configure it later (see step 10)

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 --fulltext option 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:

  1. Go to zotero.org/settings/keys
  2. Click Create new private key
  3. Give it a name (e.g. "Claude Code")
  4. Enable Allow library access and Allow write access under Personal Library
  5. 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:

StepWhereNotes
Zotero MCP (querying library)✅ LocalConnection via localhost
yt-dlp (fetching transcripts)✅ LocalScraping on your Mac
whisper.cpp (transcribing audio)✅ LocalM4 Metal GPU
Semantic search (Zotero MCP)✅ LocalLocal vector database
Reasoning, summarizing, writing synthesesLocalQwen3.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

  1. Open Obsidian
  2. Choose "Create new vault"
  3. Give the vault a name, e.g. ResearchVault
  4. Choose a location you will remember, e.g. ~/Documents/ResearchVault
  5. 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:

  1. Auto-detects your home path
  2. Asks for your Zotero library ID (the userID from zotero-mcp setup-info)
  3. Writes .claude/settings.local.json with the correct paths

Note: If you ever move your vault or reinstall tools, re-run ./setup.sh to 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 as unknown. 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 running zotero-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

ModelSizeSpeed (1 hour audio)Quality
base~145 MB~2 minGood for clear speech
small~465 MB~4 minRecommended starting point
medium~1.5 GB~8 minBetter for accents, fast speech
large~3 GB~15 minBest 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-api is 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 loopfeedreader-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.py runs 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 instruction inbox [URL] → Claude Code fetches the content and saves it as a Markdown file in inbox/, 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:

#BehaviourSignal strengthRecorded as
1Item clicked + added to ZoteroStrong positiveadded_to_zotero: true
2Item clicked, not added to ZoteroWeak negative (seen but not interesting enough)added_to_zotero: false after 3 days
3Item not clicked, no 👎 pressedAmbiguous — not seen, or implicitly ignoredadded_to_zotero: false after 3 days — indistinguishable from type 2
4👎 pressed without clickingStrong explicit negative (headline was enough to reject)skipped: true immediately
5Item clicked, then 👎 pressedStrongest 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.py reports 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.py processes 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:

  1. Open the article from NetNewsWire in your browser
  2. Click the Zotero icon in your browser (or use the Zotero iOS app) → item is saved to _inbox
  3. Process from _inbox via 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

  1. Open Obsidian
  2. Go to Settings → Community Plugins → Browse community plugins
  3. Search for "Spaced Repetition"
  4. Install the plugin by Stephen Mwangi
  5. 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

  1. Open Obsidian
  2. Click the card icon in the right sidebar (or use Cmd + Shift + R)
  3. Review the cards scheduled for today: Easy / Good / Hard
  4. 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)

PhaseWhat
Dump layerZotero _inbox collection — central collection bucket for all sources
Filter momentRun index-score.py to rank items by relevance; read abstract in Zotero, or have Claude Code summarize via Qwen3.5:9b (locally)
GoMove item to the relevant collection in your library
No-goDelete item from _inbox — no note is created

Tag-based filter logic

Claude Code adjusts its evaluation based on the Zotero tag of the item:

TagTreatment
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 tagGenerate 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 notes
  • status: 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

PhaseWhat
Dump layerZotero _inbox via iOS share sheet from the YouTube app
Filter momentWatch the first 5–10 minutes, or have Claude Code summarize based on metadata
Gotranscript [URL] in Claude Code
No-goDelete item from _inbox

Podcasts

PhaseWhat
Dump layerZotero _inbox via iOS share sheet from Overcast (overcast.fm URL)
Filter momentListen to the first 5–10 minutes
Gopodcast [URL] in Claude Code (download + transcription + processing)
No-goDelete 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

PhaseWhat
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 layerBrowse the filtered feed in the HTML reader or NetNewsWire; interesting items forwarded to Zotero _inbox via browser extension or iOS app
Phase 2 — Filter momentScan 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-goMark 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):

#BehaviourSignalRecorded as
1Headline clicked + added to ZoteroStrong positiveadded_to_zotero: true
2Headline clicked, not added to ZoteroWeak negative (seen, not interesting enough)added_to_zotero: false after 3 days
3Not clicked, no 👎Ambiguous — not seen, or implicitly ignoredadded_to_zotero: false after 3 days (indistinguishable from type 2)
4👎 pressed without clickingStrong explicit negative (headline was enough to reject)skipped: true immediately
5Headline clicked, then 👎 pressedStrongest 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

  1. Open System Settings → General → Sharing
  2. Enable Remote Login
  3. 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)

  1. Open Termius → tap +New Host

  2. Fill in the following fields:

    FieldValue
    LabelMac Mini (or any name you like)
    Hostnamethe local IP address of your Mac mini (see below)
    Port22
    Usernameyour macOS account name
    Passwordyour Mac login password
  3. Tap Save, then tap the host to connect

  4. 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.

Entering a password every session is inconvenient. An SSH key pair allows passwordless login.

In Termius:

  1. Go to Settings → Keychain → +Generate key
  2. Choose Ed25519, give it a name, tap Generate
  3. 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:

  1. Type /login inside Claude Code
  2. Claude Code displays a URL
  3. Open that URL in Safari on your iPad
  4. Sign in with your Claude account
  5. 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:

  1. Download Tailscale on the Mac mini and install it
  2. Download the Tailscale app on your iPad from the App Store
  3. Sign in to the same account (Google, GitHub, or email) on both devices
  4. Both devices now appear in your Tailscale network with stable 100.x.x.x addresses
  5. 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

  1. Open http://localhost:8765/filtered.html (Mac) or http://[mac-ip]:8765/filtered.html (iPad)
  2. Click ⌨️ terminal in the header bar
  3. A terminal panel opens on the right (side-by-side on desktop, stacked below on narrow screens)
  4. 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.

PhaseWhat happensYour role
1 — Cast wideItems flow into Zotero _inbox from three sourcesForward items from the feedreader or share directly from iOS
2 — FilterClaude Code presents each _inbox item with a relevance score and summary; you decideGo / No-go per item
3 — ProcessClaude Code writes a structured literature note to the Obsidian vaultReview 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:

SourceWhat it isFiltering before _inboxPhase 2 treatment
1. FeedreaderAggregated RSS/YouTube/podcast feeds scored daily by feedreader-score.py; you browse the HTML reader or NetNewsWire and forward interesting itemsPartly autonomous (scoring algorithm), partly manual (your click)Standard Go/No-go
2. iOS share sheetItems you share directly from YouTube, Overcast, Safari, or NetNewsWireDone by you — you consumed or deliberately selected the itemLighter: you already pre-filtered
3. Desktop / email / notesManual additions from any other sourceNone — comparable to source 1Standard 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 _inbox on 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.py processes these signals every morning at 06:15.
  • Once ≥30 positive signals have accumulated, feedreader-learn.py will 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:

BehaviourSignalRecorded as
Clicked + added to ZoteroStrong positiveadded_to_zotero: true
Clicked, not addedWeak negativeadded_to_zotero: false after 3 days
Not clicked, no 👎Ambiguousadded_to_zotero: false after 3 days
👎 without clickingStrong explicit negativeskipped: true immediately
Clicked, then 👎Strongest negativeskipped: 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 tagWhat 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 tagScore-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)

ScoreTreatment
🟢 ≥70Show title + score; ask Go/No-go directly — strong match, no summary needed
🟡 40–69Generate a 2–3 sentence summary via Qwen3.5:9b (local); ask Go/No-go
🔴 <40Propose 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:

  1. Retrieves metadata from Zotero MCP (title, authors, year, journal, citation key, tags) — no full text
  2. Calls the local subagent process_item.py with only the item key and metadata:
    • process_item.py fetches the full text locally, generates a structured note via Qwen3.5:9b, builds the YAML frontmatter, and writes the .md file to literature/
    • Claude Code receives only {"status": "ok", "path": "literature/..."} — no source content
  3. Adds [[internal links]] to related notes in the vault
  4. 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.py is 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:

  1. Checks whether a transcript is already cached from the feedreader (.claude/transcript_cache/{video_id}.json) — no re-fetch needed if so
  2. Falls back to yt-dlp if no cache exists
  3. 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
  4. Adds frontmatter, [[internal links]], and #video tag
  5. 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:

  1. Checks for a cached show notes file in .claude/transcript_cache/podcast_{episode_id}.json
  2. If no cache: downloads audio via yt-dlp, transcribes locally via whisper.cpp (automatic language detection)
  3. For long episodes (> 45 min): generates a layered summary first (main line → per segment), then the final note
  4. 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)
  5. Adds frontmatter, [[internal links]], and #podcast tag
  6. Removes raw .mp3 and .txt files from inbox/ 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 _inbox autonomously → 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.

  1. Browse the filtered feed at http://localhost:8765/filtered.html (or in NetNewsWire via http://localhost:8765/filtered.xml). Items are sorted by relevance score. Send interesting ones to Zotero _inbox via the browser extension or iOS app.
  2. Start Zotero (so the local API is active)
  3. Open Terminal in your vault: cd ~/Documents/ResearchVault && claude
  4. Activate the skill: type /research or "start research workflow"
  5. 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

  1. Browse the filtered feed and forward interesting items to Zotero _inbox
  2. Start Zotero
  3. Open Terminal, navigate to your vault, and start Claude Code:
    cd ~/Documents/ResearchVault
    claude
    
  4. Activate the research workflow:
    /research
    
    or just type: start research workflow
  5. Optionally, run index-score.py first to prioritize your review:
    ~/.local/share/uv/tools/zotero-mcp-server/bin/python3 .claude/index-score.py
    
    This ranks all _inbox items by semantic similarity to your existing library (using the ChromaDB embeddings from zotero-mcp), so you know which items to focus on.
  6. Claude Code retrieves all items from your Zotero _inbox and 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.
  7. For each Go: Claude Code writes a structured literature note using the safe pipeline below.
  8. For each No-go: Claude Code removes the item from _inbox (after your confirmation).
  9. 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:
    zotero-mcp update-db            # quick (metadata only)
    zotero-mcp update-db --fulltext # recommended (includes full text)
    
    Or use the alias: update-zotero (equivalent to --fulltext). Check database status with zotero-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

ProblemPossible causeSolution
Zotero MCP returns no resultsZotero is not openStart Zotero and check http://localhost:23119/
Local API not availableSetting not checkedZotero → Settings → Advanced → enable local API
zotero-mcp not founduv path not in shellAdd ~/.local/bin to $PATH in ~/.zshrc
Semantic search returns no resultsDatabase not initializedRun zotero-mcp update-db
Claude Code does not see the MCP toolConfiguration file missingCheck ~/.claude/claude_desktop_config.json
Ollama not respondingService not startedRun ollama serve or brew services start ollama
yt-dlp returns no subtitlesVideo has no (auto-)subtitlesTry --sub-lang en or check whether the video has subtitles at all
launchd update not runningZotero is not open at the scheduled timeStart Zotero manually and run update-zotero, or choose "Auto on startup" in step 10c option 1
whisper-cpp gives an errorModel not yet downloadedWait for the first download, or check disk space
Whisper transcription is inaccurateLow audio quality or incorrect language detectionUse --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 syncingNo sync configured (local always works)NetNewsWire works locally by default; iCloud sync is optional
Obsidian flashcards not appearingPlugin not enabledSettings → Community Plugins → enable Spaced Repetition
Flashcards not recognizedIncorrect formatCheck that ? is on its own line and #flashcard is present

Privacy overview

ComponentData local?Notes
Zotero + local API✅ FullyRuns on localhost, no cloud
Zotero MCP✅ FullyLocal connection; write operations use Zotero web API with your own key
Obsidian vault✅ FullyRegular files on your Mac
Ollama + Qwen3.5:9b✅ FullyModel runs locally on M4; default for all generative tasks
yt-dlp✅ FullyScraping executed locally
whisper.cpp✅ FullyTranscription locally on M4 via Metal
NetNewsWire✅ FullyRSS data stored locally, no account
Obsidian Spaced Repetition✅ FullyCards and review data in vault files
Claude Code — orchestration⚠️ PartiallyWorkflow instructions and metadata go to the Anthropic API; source content must not
Claude Code — --hd mode⚠️ PartiallyOnly 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):

  1. fetch-fulltext.py — fetches the Zotero attachment and writes it to inbox/_tmp_ITEMKEY.txt; prints only file size and status.
  2. ollama-generate.py — calls the Ollama REST API directly (no CLI, no ANSI codes), generates the note body, writes to a temp file.
  3. Frontmatter is built from the metadata arguments and prepended to the note body.
  4. The final note is written to literature/[citation-key].md.
  5. 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 --hd does source content go to the Anthropic API — Claude Code always asks for confirmation first.