
Zotero Obsidian Bridge
- 301 installs
- 5k repo stars
- Updated July 17, 2026
- galaxy-dawn/claude-scholar
Sync Zotero bibliographies, PDF metadata, and highlights into Obsidian vaults so literature notes, backlinks, and citations stay unified for writing projects.
About
zotero-obsidian-bridge from galaxy-dawn/claude-scholar connects Zotero reference management with Obsidian note-taking. It automates moving bibliographic data, attachments, and reading notes into a linked vault so researchers maintain one searchable, citation-aware knowledge graph.
- Zotero-to-Obsidian metadata sync
- Citation-linked note workflows
- Literature review organization
- Reduces manual copy-paste between tools
- Supports long-form academic writing
Zotero Obsidian Bridge by the numbers
- 301 all-time installs (skills.sh)
- Ranked #902 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/galaxy-dawn/claude-scholar --skill zotero-obsidian-bridgeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 301 |
|---|---|
| repo stars | ★ 5k |
| Last updated | July 17, 2026 |
| Repository | galaxy-dawn/claude-scholar ↗ |
What it does
Sync Zotero bibliographies, PDF metadata, and highlights into Obsidian vaults so literature notes, backlinks, and citations stay unified for writing projects.
Files
Zotero Obsidian Bridge
Use this skill when papers live in Zotero and the project KB should receive project-local notes.
Default flow:
Zotero -> Sources/Papers -> Knowledge -> Writing -> Maps/literature.canvasRules:
- one canonical paper note per paper under
Sources/Papers/ - literature synthesis goes to
Knowledge/ - writing-oriented outputs go to
Writing/ Maps/literature.canvasis the default derived graph artifact- update
_system/registry.md,02-Index.md, and today'sDaily/after substantial ingestion
Evidence extraction requirement
Core papers and papers with reusable claims should include an evidence record. Abstract-only or placeholder notes may stay in To-Read until there is enough evidence to extract a reusable claim.
## Evidence Record
Evidence ID:
Source:
Source type: full paper | preprint | dataset | experiment artifact | project note | abstract-only | webpage placeholder
Supports:
Contradicts:
Method / dataset / metric:
Limitation:
Project relevance:
Claim strength: speculative | observed | supported | strongUse ../research-ideation/references/research-contract.md as the shared contract for Evidence Records and claim strength definitions.
Before synthesis lands in Knowledge/ or Writing/, apply the shared Claim Promotion Gate:
- every promoted claim must point back to an Evidence Record ID,
- source type must be strong enough for the claim,
- abstract-only and webpage-placeholder items cannot support durable claims,
- allowed wording and forbidden stronger wording must be preserved when the claim may later enter a report, manuscript, or rebuttal.
When synthesis lands in Knowledge/, link the supporting paper notes explicitly. Do not promote literature synthesis that cannot point back to source notes.
Cross Subject Inventory
Coverage Summary
- Expected items: 3
- Canonical notes: 3 / 3
Item Mapping
| Zotero Key | Item Title | Canonical Note | Status |
|---|---|---|---|
| ABCDEFGH | Shared Latent Geometry for Cross-Subject Speech Decoding | Sources/Papers/Shared-Latent-Geometry.md | covered |
| HGFEDCBA | Domain Adapters for Neural Transfer | Sources/Papers/Domain-Adapters.md | covered |
| ZXCVBNML | Contrastive Transfer under Low Resource Conditions | Sources/Papers/Contrastive-Transfer.md | covered |
Triage
- fully covered: 3
- bridge-only: 0
- needs-review: 0
Collection Inventory Schema
Use this note when a Zotero collection is imported or audited at collection scope.
Canonical path
Knowledge/Zotero-Collection-{collection-slug}-Inventory.mdFrontmatter
---
type: zotero-collection-inventory
collection: Cross Subject
collection_slug: cross-subject
source: zotero
coverage_expected: 16
coverage_actual: 16
updated: 2026-03-18T00:00:00Z
---Required sections
# Cross Subject Inventory
## Coverage Summary
- Expected items: 16
- Canonical notes: 16 / 16
## Item Mapping
| Zotero Key | Item Title | Canonical Note | Status |
|---|---|---|---|
| ABCDEFGH | Example title | Sources/Papers/Example-Paper.md | covered |
## Triage
- fully covered
- skipped or bridge-only notes
- items that still need full notesRules
- Keep one durable inventory note per collection slug.
Canonical Noteshould use project-relative note paths.Statusshould use a small vocabulary such ascovered,bridge-only,skipped,needs-review.- Update coverage counts whenever the collection is batch-processed again.
Local Zotero Fallback
Use this runbook when the Zotero MCP transport is unavailable but the user still wants a collection-level pass.
Preconditions
- A local
zotero-mcpcheckout or installed Python package is available. - The local environment can read metadata/fulltext without mutating the Zotero library.
Default fallback sequence
1. Confirm that MCP transport is the failing layer, not the collection query itself. 2. Switch to the local Python-backed path for metadata and fulltext retrieval. 3. Continue creating or updating canonical paper notes. 4. Run verify_paper_notes.py before closing the batch.
Report explicitly
Always state:
- that MCP transport failed,
- that the workflow continued through local fallback,
- what remained unavailable, if anything.
Zotero-Sourced Paper Note Schema
Frontmatter
---
type: paper
project: project-slug
title: "Paper Title"
canvas_visibility: hidden
authors:
- Author A
- Author B
year: 2026
venue: "NeurIPS"
doi: "10.xxxx/xxxxx"
url: "https://doi.org/..."
citekey: "author2026paper"
zotero_key: "ABCDEFGH"
status: read
source_type: full paper
claim_strength: observed
keywords:
- subject-invariance
- contrastive-learning
concepts:
- shared geometry
methods:
- contrastive pretraining
subfield: speech-transfer
related_papers:
- "Sources/Papers/Another-Paper"
linked_knowledge:
- "Knowledge/Literature Overview"
- "Knowledge/Method Taxonomy"
argument_claims:
- "Shared geometry exists but is fragile"
argument_methods:
- "Geometry-aware transfer"
argument_gaps:
- "Still needs speech-specific validation"
paper_relationships:
- "Sources/Papers/Another-Paper::complements"
updated: 2026-03-16T00:00:00Z
---Sections
# Paper Title
## Claim
## Research question
## Method
## Evidence
Use the shared Evidence Record shape from `research-ideation/references/research-contract.md`.
## Strengths
## Limitation
## Direct relevance to repo
## Relation to other papers
## Knowledge links
## Optional downstream hooks
- Writing:Rules
related_papersandlinked_knowledgeshould prefer project-relative note paths.paper_relationshipsshould record explicit semantic edges only when they are stable enough to support graph construction.conceptsandmethodsmay remain plain strings; do not create dedicated notes by default.- Keep
Direct relevance to repoconcrete and actionable. - Prefer one durable canonical note per paper; update in place instead of making sibling notes.
- If the user asked for a full collection pass, normalize the schema across the entire covered set before closing the task.
- Treat Zotero
webpageitems as weak-source inputs by default. If they are abstract-only or placeholder pages, setsource_type: webpage placeholderorsource_type: abstract-only, keepstatus: to-read, and do not use them to supportKnowledge, manuscript, or rebuttal claims.
Zotero -> Obsidian Workflow
1. Resolve the project
1. Run the Obsidian project-memory detect flow. 2. If the repo is bound, use its Research/{project-slug}/ vault root. 3. If not bound but clearly a research repo, bootstrap first.
2. Read from Zotero
Preferred read path per paper: 1. zotero_get_item_metadata 2. zotero_get_item_fulltext 3. zotero_get_annotations 4. zotero_get_notes
Use metadata + abstract as the minimum fallback when PDF full text is unavailable. If the MCP transport path is broken but a local zotero-mcp source checkout is available, use the local Python fallback to call the same metadata/fulltext functions instead of aborting. Treat Zotero webpage items as weak-source entries unless they clearly expose full paper metadata and useful fulltext. Abstract-only pages and webpage placeholders may be kept for discovery, but they must remain To-Read or weak-source and cannot support Knowledge or manuscript claims until replaced by a full paper, preprint, or verified artifact.
3. Create/update the canonical paper note
Canonical destination:
Sources/Papers/{normalized-title-or-citekey}.md
Update instead of duplicating when the note already exists.
4. Detailed reading note requirements
Each durable paper note should contain:
- claim
- research question / problem
- method
- evidence
- strengths
- limitation
- direct relevance to repo
- links to related papers and the best matching
Knowledge/notes
5. Synthesize the stable literature knowledge
After a batch import, prefer agent-first synthesis into Knowledge/: 1. update Knowledge/Literature Overview.md when the batch yields a stable overview 2. update Knowledge/Method Taxonomy.md when method clusters are clear 3. update Knowledge/Research Gaps.md when open problems or tensions are stable enough to keep 4. if the source is a named Zotero collection, update a durable inventory note that records:
- collection size,
- triage buckets,
- collection item -> canonical note mapping,
- current coverage such as
16 / 16
Apply the Claim Promotion Gate before creating or updating Knowledge/:
- every stable claim must cite a canonical paper note and an Evidence Record ID,
speculativeandobservedclaims stay as hypotheses, motivations, or open gaps,- abstract-only or webpage-placeholder items can appear in coverage notes but not as support for durable conclusions,
- if the batch lacks enough evidence records, produce a warning/claim map instead of a polished synthesis.
6. Refresh the default literature canvas
After batch note creation or major note updates: 1. rebuild Maps/literature.canvas 2. ensure core paper notes have meaningful wikilinks into Knowledge/ 3. keep the graph lightweight and project-facing 4. prefer semantic filtering and edge thinning over dense all-to-all paper links 5. prefer paper + claim + method + gap argument-map structure for the main graph 6. add Maps/literature-main.canvas only when a second lightweight display graph is genuinely useful 7. preserve edge evidence strength where possible; do not create all-to-all paper links or stable-looking claim edges without source-note support
7. Push downstream only when justified
- during Zotero ingestion, default to
Sources/Papers/plusKnowledge/ - update
Writing/only when the user asks for a review, comparison, or draft-facing synthesis and the promoted claims pass the Claim Promotion Gate - treat
Experiments/andResults/as later project workflows, not default Zotero-import targets
8. Verify before closing
After batch ingestion or schema refactors: 1. verify every expected paper has a canonical note 2. verify zotero_key coverage matches the imported collection 3. verify all covered notes use the same canonical section schema 4. update the project Daily/ note and project memory with what changed
Recommended verification command:
python3 "${CLAUDE_PLUGIN_ROOT}/skills/zotero-obsidian-bridge/scripts/verify_paper_notes.py" \
--papers-dir "/absolute/path/to/Sources/Papers" \
--expected-zotero-keys "KEY1,KEY2,KEY3" \
--inventory-note "/absolute/path/to/Knowledge/Zotero-Collection-collection-slug-Inventory.md"#!/usr/bin/env python3
"""Verify Obsidian paper-note schema, Zotero-key coverage, and optional inventory consistency."""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
REQUIRED_HEADINGS = (
"## Claim",
"## Method",
"## Evidence",
"## Limitation",
"## Direct relevance to repo",
"## Relation to other papers",
)
REQUIRED_FRONTMATTER_FIELDS = (
"type",
"project",
"title",
"zotero_key",
"linked_knowledge",
"paper_relationships",
"updated",
)
ZOTERO_KEY_RE = re.compile(r'^zotero_key:\s*"?([A-Z0-9]+)"?', re.MULTILINE)
FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n', re.DOTALL)
FIELD_RE_TEMPLATE = r'^{}:\s*'
INVENTORY_ROW_RE = re.compile(r'^\|\s*([A-Z0-9]+)\s*\|\s*(.*?)\|\s*(.*?)\|\s*(.*?)\|\s*$')
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Verify paper-note schema and optional Zotero-key / inventory coverage."
)
parser.add_argument("--papers-dir", required=True, help="Directory containing canonical paper notes.")
parser.add_argument(
"--expected-zotero-keys",
default="",
help="Comma-separated Zotero keys expected to be covered.",
)
parser.add_argument(
"--inventory-note",
default="",
help="Optional collection inventory note to cross-check against canonical paper notes.",
)
parser.add_argument(
"--strict-missing-zotero-key",
action="store_true",
help="Treat notes without zotero_key as errors. Default behavior skips them.",
)
return parser.parse_args()
def load_expected_keys(raw_keys: str) -> list[str]:
if not raw_keys.strip():
return []
return [key.strip() for key in raw_keys.split(",") if key.strip()]
def extract_frontmatter(text: str) -> str:
match = FRONTMATTER_RE.match(text)
return match.group(1) if match else ""
def missing_frontmatter_fields(frontmatter: str) -> list[str]:
missing: list[str] = []
for field in REQUIRED_FRONTMATTER_FIELDS:
if not re.search(FIELD_RE_TEMPLATE.format(re.escape(field)), frontmatter, re.MULTILINE):
missing.append(field)
return missing
def collect_note_status(
papers_dir: Path,
strict_missing_zotero_key: bool,
) -> tuple[dict[str, str], list[str], list[str]]:
key_to_file: dict[str, str] = {}
issues: list[str] = []
skipped_without_key: list[str] = []
for path in sorted(papers_dir.glob("*.md")):
text = path.read_text(encoding="utf-8")
frontmatter = extract_frontmatter(text)
match = ZOTERO_KEY_RE.search(text)
if not match:
skipped_without_key.append(path.name)
if strict_missing_zotero_key:
issues.append(f"{path.name}: missing zotero_key")
continue
zotero_key = match.group(1)
if zotero_key in key_to_file:
issues.append(
f"{path.name}: duplicate zotero_key {zotero_key} also used by {key_to_file[zotero_key]}"
)
key_to_file[zotero_key] = path.name
missing_headings = [heading for heading in REQUIRED_HEADINGS if heading not in text]
if missing_headings:
issues.append(f"{path.name}: missing headings -> {', '.join(missing_headings)}")
missing_fields = missing_frontmatter_fields(frontmatter)
if missing_fields:
issues.append(f"{path.name}: missing frontmatter fields -> {', '.join(missing_fields)}")
return key_to_file, issues, skipped_without_key
def parse_inventory_note(inventory_path: Path) -> tuple[dict[str, str], list[str]]:
text = inventory_path.read_text(encoding="utf-8")
mapping: dict[str, str] = {}
issues: list[str] = []
in_table = False
for line in text.splitlines():
if line.strip() == "## Item Mapping":
in_table = True
continue
if in_table and line.startswith("## "):
break
if not in_table or not line.startswith("|"):
continue
if "---" in line:
continue
match = INVENTORY_ROW_RE.match(line)
if not match:
continue
key, _title, note_path, _status = [part.strip() for part in match.groups()]
if key in mapping:
issues.append(f"inventory: duplicate zotero key {key}")
mapping[key] = note_path
if not mapping:
issues.append("inventory: no item mapping rows found")
return mapping, issues
def main() -> int:
args = parse_args()
papers_dir = Path(args.papers_dir).expanduser()
expected_keys = load_expected_keys(args.expected_zotero_keys)
if not papers_dir.exists():
print(f"ERROR: papers dir not found: {papers_dir}")
return 1
note_files = list(papers_dir.glob("*.md"))
key_to_file, issues, skipped_without_key = collect_note_status(
papers_dir, args.strict_missing_zotero_key
)
print(f"Papers dir: {papers_dir}")
print(f"Paper notes scanned: {len(note_files)}")
print(f"Notes with zotero_key: {len(key_to_file)}")
if skipped_without_key:
print(f"Notes skipped without zotero_key: {len(skipped_without_key)}")
if expected_keys:
missing = [key for key in expected_keys if key not in key_to_file]
extras = sorted(set(key_to_file) - set(expected_keys))
print(f"Expected Zotero keys: {len(expected_keys)}")
print(f"Covered Zotero keys: {len(expected_keys) - len(missing)} / {len(expected_keys)}")
if missing:
issues.append(f"Missing expected keys: {', '.join(missing)}")
if extras:
print(f"Extra zotero_key notes: {', '.join(extras)}")
if args.inventory_note:
inventory_path = Path(args.inventory_note).expanduser()
if not inventory_path.exists():
issues.append(f"inventory note not found: {inventory_path}")
else:
inventory_mapping, inventory_issues = parse_inventory_note(inventory_path)
issues.extend(inventory_issues)
if inventory_mapping:
for key, note_path in inventory_mapping.items():
expected_name = key_to_file.get(key)
if expected_name is None:
issues.append(f"inventory: key {key} not found in paper notes")
continue
if Path(note_path).name != expected_name:
issues.append(
f"inventory: key {key} points to {note_path}, expected file ending {expected_name}"
)
missing_in_inventory = sorted(set(key_to_file) - set(inventory_mapping))
if missing_in_inventory:
issues.append(
f"inventory: missing keys present in paper notes -> {', '.join(missing_in_inventory)}"
)
if skipped_without_key:
print("Skipped note files without zotero_key:")
for name in skipped_without_key:
print(f"- {name}")
if issues:
print("\nISSUES:")
for issue in issues:
print(f"- {issue}")
return 1
print("\nOK: schema and coverage checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())