CtrlK
BlogDocsLog inGet started
Tessl Logo

obsidian-linked-research

Fetch a URL, summarize it, and save as a structured research note in the current Obsidian Research/Library taxonomy. Use the master Research Library MOC as the source of truth for tags, folder routing, and freshness updates.

SKILL.md
Quality
Evals
Security

Obsidian Linked Research

Purpose

Take a URL → fetch its content → generate a structured summary → check for thesis drift against existing notes → write a standalone research note into the active Research/Library/ taxonomy in the Obsidian vault.

The library is no longer flat. Notes should be routed into the existing numbered subfolders and tagged using the vocabulary established by the master Research Library MOC and the vault tag index.

Tweet/X URLs get rich content via xAI's x_search tool (engagement stats, thread context, media descriptions). All other URLs get plain-text extraction via HTTP fetch. The VS Code model (you, the agent) does all summarization — no external LLM API call needed for the intelligence step.

Vault writes go through the obsidian skill (composable CLI wrapper).

Always-Capture Sources

The following sources bypass quality filters and are always captured as research notes when encountered — in email, X, or any other ingestion pipeline. These are high-signal sources that publish in digest/roundup format but contain original analysis worth preserving.

SourceEmail / HandleWhy
AINews / Latent Spaceswyx@substack.com, latentspace, @swyxDaily AI industry analysis with original commentary, curated signal

When a calling skill (e.g., gmail-daily-briefing) encounters content from an always-capture source, it should invoke this skill regardless of whether the content looks like a "newsletter digest" or "roundup." The quality filter exclusion for digests does NOT apply to always-capture sources.

To add more sources, append rows to the table above.

When to Use

TriggerExample
User shares a URL and wants it saved"research this: https://..."
User says "save this article""save this article to obsidian"
User says "obsidian research""/obsidian-linked-research https://x.com/..."
User shares a tweet to capture"save this tweet: https://x.com/..."
User references a link for later"I want to read this later, save it"

Prerequisites

  • Obsidian must be running with CLI enabled
  • XAI_API_KEY required for tweet URLs (via keyring automation/api, env var, or ~/.config/last30days/.env)
  • Web URLs work without any API key

Workflow

Step 0 — Inspect Taxonomy First

Before fetching or summarizing, inspect the live research taxonomy so you do not invent a second folder or tag scheme.

Read the master library MOC first:

python .github/skills/obsidian/scripts/obsidian.py read --path "Research/Library/00 MOC/🗺️ MOC - Research Library.md"

Read any relevant topic MOC only after the master MOC, and only for extra domain context. Topic MOCs are secondary maps, not the source of truth for canonical tags.

Read the current vault tag index:

python -c "import sys; sys.path.insert(0,'.github/skills/obsidian/scripts'); from obsidian import Obsidian; print(Obsidian().tags().text)"

List the live research-library note paths so you can see the active buckets:

python -c "import sys; sys.stdout.reconfigure(encoding='utf-8'); sys.path.insert(0,'.github/skills/obsidian/scripts'); from obsidian import Obsidian; print(Obsidian().files(folder='Research/Library', ext='md').text)"

Windows UTF-8 note: On Windows, subprocess pipes default to the system code page (cp1252), which cannot encode emoji or non-ASCII vault paths. Always add sys.stdout.reconfigure(encoding='utf-8') before any print() call in one-liner subprocess commands, or prefer calling the Obsidian Python API directly.

URL-based dedup (mandatory before fetching): Search for an existing note whose url: frontmatter matches the input URL. This catches duplicates even when the slug or folder differ. Use the Obsidian search API:

python -c "import sys; sys.stdout.reconfigure(encoding='utf-8'); sys.path.insert(0,'.github/skills/obsidian/scripts'); from obsidian import Obsidian; r=Obsidian().search(query='<INPUT_URL>'); print(r.text if hasattr(r,'text') else r)"

Note: Obsidian's local search does not support url: field-operator syntax — url: <value> throws "Operator not recognized". Use a plain-text query with the URL string or a distinctive fragment of it (e.g., the tweet ID or domain path).

If a match is found, stop — tell the user the note already exists, give them the path and an Obsidian link, and ask whether they want to update the existing note instead. Do NOT proceed to fetch or create a new note.

Use this routing table unless the live vault has changed again:

FolderUse for
Research/Library/01 Agent Harnesses & Architecture/harnesses, orchestration, control planes, architecture, model/runtime design
Research/Library/02 Skills, IDEs & Agent Tooling/skills, Copilot, Claude Code, IDEs, hooks, MCP, tooling
Research/Library/03 Evals, Reliability & Control/evals, reliability, governance, testing, review, controls
Research/Library/04 SDLC, Workflow & Strategy/SDLC, workflow redesign, consulting POV, product/API strategy
Research/Library/05 Knowledge, RAG & Memory/RAG, retrieval, knowledge systems, Obsidian, memory, second brain
Research/Library/06 Cryptography/cryptography, post-quantum, blockchain security, zero-knowledge proofs
Research/Library/07 Macrotrends & Futures/space, off-world, megatrends, long-horizon futures, civilizational shifts
Research/Library/08 Org Design & AI Transformation/organizational design, corporate transformation via AI, hierarchy-vs-intelligence, management theory

Tagging rules before you write anything:

  • Reuse tags already present in the master MOC's Canonical Tag Guidance first: agents, ai-agents, agent-harnesses, skills, claude-code, copilot, mcp, evals, rag, sdlc, workflow-design, research
  • Prefer an existing vault tag over a new synonym if the meaning is the same
  • Normalize to lowercase kebab-case; do not create variants like AIagents, MachineLearning, or mixed singular/plural duplicates if a canonical form already exists
  • Only mint a new canonical tag if both the master MOC and the vault tag index are missing a genuinely useful concept
  • Choose one primary library folder per note; do not duplicate the same note across folders

Step 1 — Fetch Content

Run the fetch script to retrieve URL content:

python .github/skills/obsidian-linked-research/scripts/fetch.py "<url>"

The script outputs JSON to stdout:

For tweets:

{
  "type": "tweet",
  "url": "https://x.com/...",
  "text": "Full tweet/thread text",
  "author_handle": "username",
  "author_name": "Display Name",
  "date": "2026-03-01",
  "engagement": {"likes": 150, "reposts": 30, "replies": 12, "quotes": 5},
  "thread_context": "...",
  "is_thread": false,
  "media_descriptions": ["..."],
  "image_urls": ["https://pbs.twimg.com/media/..."],
  "article_content": "Full article text if X Article detected",
  "article_title": "Article title if X Article"
}

For web pages:

{
  "type": "web",
  "url": "https://...",
  "title": "Page Title",
  "content": "Plain text content (up to 8000 chars)",
  "image_urls": ["https://example.com/hero.jpg", "https://example.com/diagram.png"]
}

The image_urls array includes OG images and meaningful <img> tags found in the page (tracking pixels, favicons, and tiny icons are filtered out). Use Step 3b to download them into the vault.

If the result contains an "error" key, report it to the user and stop.

Transient retry: If the error is "No JSON in response" for a tweet URL, retry once — the xAI Responses API occasionally returns a top-level array [{...}] instead of a plain {...} object; a second call usually returns the plain object. If it fails twice, report to the user.

X/Twitter fallback dead-ends: Do NOT try Playwright for x.com URLs — it returns a login wall for unauthenticated sessions. Do NOT try Firecrawl — it explicitly does not support x.com. The only supported path for tweet content is fetch.py via the xAI x_search tool.

If a non-X web page is truncated or loses useful structure: fetch.py is still the required first pass, but long articles may come back clipped (for example around the first ~8000 chars) or flattened enough that headings, quotes, or diagram captions are hard to reconstruct. In that case, supplement the fetch result with a secondary enrichment pass:

  • Use WebFetch (Claude Code tool) or fetch_webpage (if available) on the same URL to recover richer article structure
  • If neither tool is sufficient for a long structured document (e.g., an academic paper with many sections), delegate to a general-purpose sub-agent with WebFetch and ask it to extract all major sections in full
  • Do not skip fetch.py; it remains the canonical fetch step and the source of image_urls and metadata
  • Keep the original fetch.py metadata (title, image_urls, url) as the source of truth unless the secondary fetch clearly corrects it

If "needs_browser": true: The tweet contains an X Article that requires JavaScript rendering. Do NOT use fetch_webpage — it cannot render X Article pages. Instead, use the Playwright browser tools:

  1. Navigate to the tweet URL (not the article URL — X Article URLs redirect to a "not supported" page):
    browser_navigate(url=tweet_url)
  2. Wait for content to load (X Articles take a few seconds to render):
    browser_wait_for(time=5000)
  3. The browser snapshot (returned by wait_for) contains the full article text in the accessibility tree. Extract the article content from the snapshot YAML.

Use the returned content as the article body for summarization. Combine it with the tweet metadata (author, engagement) already in the fetch result.

Extracting images from browser content: Use browser_evaluate to extract pbs.twimg.com image URLs from the DOM (the snapshot YAML won't include src attributes):

browser_evaluate(function="() => { return JSON.stringify(Array.from(document.querySelectorAll('img')).filter(i => i.src && i.src.includes('pbs.twimg.com') && !i.src.includes('_bigger')).map(i => i.src)); }")

Then download the images using:

python -c "import sys; sys.path.insert(0,'.github/skills/obsidian-linked-research/scripts'); from fetch import download_images; import json; r=download_images(['url1','url2'], '<vault_path>/Research/Library/attachments', '{slug}'); print(json.dumps(r))"

Close the browser when done: browser_close()

Step 2 — Summarize (You, the Agent)

Using the fetched content, generate a deep, structured analysis — not a shallow summary. Do NOT call an external API — use your own reasoning.

Quality bar: Study the existing notes in the target Research/Library/ subfolder for reference. Good notes are section-by-section breakdowns with tables, code examples, specific details, and "Relevance to This Repo" sections. They read like comprehensive technical references, not tweet-length summaries.

Think through the content and produce this structure internally:

{
  "title": "Clear, descriptive title for the note",
  "slug": "kebab-case-filename-slug (3-6 words, no special chars)",
  "library_bucket": "01 Agent Harnesses & Architecture | 02 Skills, IDEs & Agent Tooling | 03 Evals, Reliability & Control | 04 SDLC, Workflow & Strategy | 05 Knowledge, RAG & Memory",
  "library_path": "Research/Library/<bucket>/<slug>.md",
  "author": "@handle or Author Name",
  "source": "x|reddit|blog|article|github",
  "core_thesis": "1-2 sentences capturing the central argument or claim",
  "sections": [
    {
      "heading": "Section heading from the article",
      "content": "Detailed breakdown — preserve key arguments, lists, code, tables, quotes"
    }
  ],
  "key_takeaways_table": [
    {"lesson": "Short label", "detail": "Specific explanation"}
  ],
  "relevance_to_repo": "2-4 sentences on how this connects to Eric Cartman / the user's workflow",
  "related_notes": ["[[Note Title 1]]", "[[Note Title 2]]"],
  "master_moc_section": "The existing section in the master Research Library MOC this note belongs under",
  "topic_moc": "Optional topic MOC to update secondarily, if one clearly applies",
  "tag_rationale": "Short note on which tags were reused from the master MOC/tag index and whether any new canonical tag is truly needed",
  "tags": ["tag1", "tag2", "tag3"]
}

Summarization rules:

  • slug: lowercase, hyphens only, 3-6 words, no special chars
  • library_bucket: choose exactly one existing library folder based on the dominant theme
  • library_path: always point at Research/Library/<bucket>/<slug>.md
  • tags: lowercase, 4-7 tags, topically relevant, hyphens in multi-word, with existing vault tags preferred over new inventions
  • sections: Preserve the article's own structure. Include code blocks, tables, numbered lists, and blockquotes from the original. DO NOT flatten rich content into bullet points.
  • key_takeaways_table: 4-8 rows. Each "lesson" is a short label; "detail" is the specific, non-obvious insight. Avoid generic platitudes.
  • relevance_to_repo: Connect the content to this repo's architecture, skills, or workflow patterns. Be specific.
  • related_notes: Use [[wiki-link]] format to connect to other Library notes.
  • master_moc_section: prefer an existing folder section in 🗺️ MOC - Research Library; also use Recently Added
  • topic_moc: optional; only set this if the note clearly belongs to a subordinate topic MOC such as 🤖 MOC - AI Agent Development
  • tag_rationale: explicitly check the master MOC and Obsidian().tags().text output before deciding to introduce any new canonical tag
  • source: detect from URL (x.com→x, reddit.com→reddit, github.com→github, else→article)
  • author: extract from content or URL. "Unknown" if not identifiable
  • For tweets: incorporate engagement stats and thread context

Step 2.5 — Thesis Drift Check

After generating the summary (Step 2), check whether the new note's core thesis conflicts with, supersedes, or complements existing vault notes. This step uses your own reasoning (the VS Code agent model) — no external API call.

2.5a — Find related existing notes

Search the vault for existing notes that share tags or are in the same library bucket as the new note:

# Search by primary tags (run for each of the top 2-3 tags)
python -c "import sys; sys.stdout.reconfigure(encoding='utf-8'); sys.path.insert(0,'.github/skills/obsidian/scripts'); from obsidian import Obsidian; ob=Obsidian(); r=ob.search(query='<primary_tag>', path='Research/Library/<bucket>'); print(r.text if hasattr(r,'text') else r)"

Also check the related_notes identified in Step 2 — read any that exist.

From the search results plus related notes, select the top 3-5 most related existing notes by this priority:

  1. Notes in the same library bucket with 2+ shared tags
  2. Notes in any bucket with 3+ shared tags
  3. Notes explicitly listed in related_notes from Step 2

2.5b — Read and compare theses

For each of the top 3-5 related notes:

python -c "import sys; sys.stdout.reconfigure(encoding='utf-8'); sys.path.insert(0,'.github/skills/obsidian/scripts'); from obsidian import Obsidian; ob=Obsidian(); content=ob.read(path='<existing_note_path>'); print(content[:2000])"

Extract the ## Core Thesis section (or the first substantive paragraph if no Core Thesis heading exists). Compare it against the new note's core_thesis from Step 2.

2.5c — Classify the relationship

For each comparison, make a judgment call. This is semantic analysis, not keyword matching. Apply these definitions:

RelationshipDefinitionAction
SupersedesThe new source covers the same ground but with updated data, revised conclusions, or a more complete framework. The old note is no longer the best reference on its specific claim.Add supersedes: [[old-note-slug]] to new note frontmatter. After writing (Step 4), update old note's frontmatter with status: superseded and superseded_by: [[new-note-slug]].
ConflictsThe two sources genuinely disagree on a meaningful claim — not just emphasis or scope differences, but contradictory conclusions or incompatible frameworks.Flag in output. Create a synthesis note (see 2.5d). Add both notes to each other's ## Related with a conflict annotation.
ComplementsThe new source adds a different angle on the same topic without contradicting. Different scope, different data, or different perspective that enriches understanding.No special action — just ensure it appears in related_notes for Step 3.

Judgment guidance:

  • A 2025 study updating a 2024 study's numbers = Supersedes (if same methodology and claims)
  • "LLMs can't reason" vs "LLMs exhibit emergent reasoning" = Conflicts
  • "Here's how to implement RAG" vs "Here's how to evaluate RAG" = Complements
  • A broader survey covering the same narrow topic = Supersedes only if it renders the narrow one redundant; otherwise Complements
  • Two articles saying similar things with different examples = Complements (not supersedes — both add value)

2.5d — Handle conflicts and supersessions

If Supersedes:

  1. Add to the new note's frontmatter (will be written in Step 4):
    supersedes: "[[old-note-slug]]"
  2. After the new note is written (end of Step 4), update the old note:
    python -c "
    import sys
    sys.path.insert(0, '.github/skills/obsidian/scripts')
    from obsidian import Obsidian
    ob = Obsidian()
    content = ob.read(path='<old_note_path>')
    # Add superseded status to frontmatter
    content = content.replace('status: unread', 'status: superseded', 1)
    # If no status field, add one after the last frontmatter field before ---
    # Also add superseded_by field
    import re
    if 'superseded_by:' not in content:
        content = re.sub(r'^(---\s*$)', r'superseded_by: \"[[<new-note-slug>]]\"\n\1', content, count=1, flags=re.MULTILINE)
    ob.create(path='<old_note_path>', content=content, overwrite=True)
    print('Updated old note with superseded status')
    "
  3. Report to user: "Note X supersedes [[old-note]]: [one-line explanation]"

If Conflicts (genuinely important disagreement):

  1. Flag in Step 7 output: "THESIS CONFLICT: [[new-note]] vs [[old-note]]: [explanation]"

  2. Create a synthesis note that captures both sides of the disagreement:

    ---
    type: research-note
    source: synthesis
    date_saved: {today YYYY-MM-DD}
    tags: [synthesis, {shared-tags}]
    status: unread
    resolves:
      - "[[note-slug-1]]"
      - "[[note-slug-2]]"
    ---
    
    # Conflict: {Topic} — {Slug1} vs {Slug2}
    
    ## The Disagreement
    
    **[[{slug1}]]** argues: {thesis 1}
    
    **[[{slug2}]]** argues: {thesis 2}
    
    ## Why It Matters
    
    {2-3 sentences on why this tension is meaningful for the research library}
    
    ## Possible Resolution
    
    {Your analysis of which position has stronger evidence, or whether they're
    actually addressing different aspects of the same problem}
    
    ## Related
    
    - [[{slug1}]] · [[{slug2}]]

    Write this synthesis note to: Research/Library/<bucket>/conflict-<slug1>-vs-<slug2>.md

  3. Add the synthesis note to both original notes' ## Related sections.

If Complements:

No special action. Ensure the existing note appears in related_notes (Step 2 output) so the ## Related section in the new note will include it.

2.5e — Report drift check results

Before proceeding to Step 3, briefly report what the drift check found:

  • Number of existing notes compared
  • Any supersessions or conflicts detected
  • Any synthesis notes that will be created
  • If no conflicts found: "Thesis drift check: clean — no conflicts with N existing notes"

Step 3 — Compose Note

Assemble the markdown. The note should be comprehensive enough to replace reading the original — a full technical reference, not a summary card.

If Step 2.5 found a supersession, include supersedes: "[[old-note-slug]]" in the frontmatter. If a conflict was found, the synthesis note will be created after this note is written (in Step 4).

Use this template as a starting point, but adapt sections to match the content:

---
type: research-note
source: {source}
author: "{author}"
url: {url}
date_found: {today YYYY-MM-DD}
date_saved: {today YYYY-MM-DD}
tags: [{comma-separated tags}]
status: unread
supersedes: "[[old-note-slug]]"  # only if Step 2.5 found a supersession
---

**Author**: {author}
**Published**: {date}
**Views**: {views if known}
**Source**: {url}
**Engagement**: ❤️ {likes} 🔁 {reposts} 💬 {replies} 📝 {quotes}

---

## Core Thesis

{1-2 sentences: the central argument or finding}

---

## {Section 1 Heading from Article}

{Detailed content — preserve tables, code blocks, lists, quotes from original.
Include specific numbers, tool names, people. Don't flatten into generic bullets.}

## {Section 2 Heading}

{Continue for each major section of the source material...}

---

## Key Takeaways

| Lesson | Detail |
|--------|--------|
| {short label} | {specific, non-obvious insight} |
| ... | ... |

---

## Relevance to This Repo

{2-4 sentences connecting this to Eric Cartman's architecture, skills, or
the user's workflow. Be specific about what to keep, change, or investigate.}

---

## Related

- [[Note Title 1]] · [[Note Title 2]]
- External source: {url}

## My Notes

Formatting rules:

  • Use --- horizontal rules between major sections for visual scanability
  • Preserve code blocks with language hints (bash, json, etc.)
  • Use tables for structured comparisons (don't convert tables to bullet lists)
  • Use blockquotes (>) for direct quotes from the source
  • Include **bold** for key terms and emphasis as in the original
  • ## Related should use [[wiki-links]] to connect to other Library notes
  • Keep the frontmatter compatible with existing research notes in the chosen folder

And if it's a thread, add a Thread Context section before Summary:

## Thread Context

{thread_context}

If images are present, embed them inline within the section they belong to — right after the paragraph or heading they illustrate. Do NOT collect images into a separate ## Images section at the end. Place each image where a reader would naturally expect to see it, using this format:

![[{slug}-1.jpg]]
*{media_description_1}*

Use ![[filename]] (Obsidian wiki-link embed) for each image. If media_descriptions are available, add them as italic captions below each image. If an image doesn't clearly belong to a specific section (e.g., a generic hero image or author avatar), place it just below the metadata header block.

Step 3b — Download Images

If the fetch result contains image_urls, download them to the vault's attachment folder. First, discover the vault path:

python -c "import sys; sys.path.insert(0,'.github/skills/obsidian/scripts'); from obsidian import Obsidian; print(Obsidian().vault_info().text)"

Parse the vault path from the output, then download images:

python .github/skills/obsidian-linked-research/scripts/fetch.py "<url>" --download-images "<vault_path>/Research/Library/attachments" --slug "{slug}"

Or if you already have the fetch result and just need to download, call Python directly:

python -c "import sys; sys.path.insert(0,'.github/skills/obsidian-linked-research/scripts'); from fetch import download_images; download_images({image_urls_list}, '<vault_path>/Research/Library/attachments', '{slug}')"

Images will be saved as {slug}-1.jpg, {slug}-2.png, etc. Use ![[{slug}-1.jpg]] in the note to embed them.

PowerShell $ gotcha (Substack/Cloudinary CDN URLs): substackcdn.com image URLs contain a signature segment like $s_!5LAd!,w_1456,.... PowerShell interpolates $s_ as a variable and mangles the URL, producing a 404 even though the URL is valid. Do not pass these URLs inside a python -c "..." one-liner from PowerShell. Two fixes, in order of preference:

  1. Use the underlying origin URL instead — strip the https://substackcdn.com/image/fetch/<sig>/ prefix and URL-decode the remainder to get the plain https://substack-post-media.s3.amazonaws.com/public/images/<uuid>_<WxH>.png URL. No $, no resizing params, full resolution.
  2. If you must keep the CDN URL, write the URL list into a temp .py file with the Write tool and run python _tmp_dl.py — never inline it in a shell argument.

Same rule applies to any one-liner: prefer a temp script over python -c whenever the payload contains $, backticks, or nested quotes.

Step 4 — Write to Vault

Primary pattern (works in all environments, including Claude Code on Windows):

Use the Write tool to write the note content to a temp file, then call the Obsidian Python API directly:

# 1. Write content to temp file using the Write tool:
#    file_path: Z:\Projects\Eric-Cartman\_tmp_note.md
#    content: {full markdown content}

# 2. Call the Obsidian Python API (in a Bash tool):
python -c "
import sys
sys.path.insert(0, '.github/skills/obsidian/scripts')
from obsidian import Obsidian

with open('_tmp_note.md', 'r', encoding='utf-8') as f:
    content = f.read()

ob = Obsidian()
result = ob.create(path='Research/Library/{library_bucket}/{slug}.md', content=content)
print(result)
"

# 3. Delete the temp file:
rm _tmp_note.md

IMPORTANT — keyword arguments required: ob.create() does not accept positional arguments. Always use path= and content= as keyword arguments. ob.create(path, content) will fail.

WARNING — do not use heredoc pipes from bash: The @'...'@ heredoc pipe pattern only works in a native PowerShell shell. When Claude Code runs bash on Windows, attempting bash -c "powershell -Command \"@'...'@\"" fails due to irreparably lossy quote escaping. Any note containing apostrophes, contractions, or code will break the heredoc approach. Use the Write-tool + Python API pattern above instead.

Verify the write:

python .github/skills/obsidian/scripts/obsidian.py read --path "Research/Library/{library_bucket}/{slug}.md" 2>&1 | head -6

If the first few lines match your frontmatter, the write succeeded.

Check for existing notes before creating (deduplicate):

The primary dedup gate is the URL-based search in Step 0. This slug check is a secondary guard for the rare case where two different URLs produce the same slug:

python -c "
import sys
sys.path.insert(0, '.github/skills/obsidian/scripts')
from obsidian import Obsidian
ob = Obsidian()
result = ob.read(path='Research/Library/{library_bucket}/{slug}.md')
print('EXISTS' if result and not result.startswith('Error') else 'NOT_FOUND')
"

If the note already exists, append -2, -3, etc. to the slug before calling ob.create().

Post-write drift actions (from Step 2.5):

After the new note is successfully written, apply any pending drift actions:

  • If a supersession was detected: update the old note's frontmatter (see Step 2.5d)
  • If a conflict was detected: create the synthesis note (see Step 2.5d)
  • If synthesis notes were created: add them to both original notes' ## Related sections

Step 5 — Update the Master MOC

After the note exists, refresh the master Research Library MOC as part of the same run unless doing so would require a major restructure.

At minimum:

  • add the note to the relevant folder section if it is useful for navigation
  • append or refresh an entry under Recently Added
  • keep the one-line description current and specific
  • prune Recently Added entries older than 7 days (see pruning logic below)

If the master MOC would need a larger restructure, stop after creating the note and suggest a separate obsidian-vault-linker refresh.

If you need to insert the note into an existing section (for example the correct library bucket list plus Recently Added), prefer a clean overwrite instead of stacking append-only blocks at the end of the file:

  1. Read the current MOC
  2. Prune stale Recently Added entries (older than 7 days)
  3. Add the new note to the relevant folder section and Recently Added
  4. Rewrite the full note through the obsidian wrapper using create --overwrite

Example pattern (use the Python API directly — do not use heredoc pipes from bash):

python -c "
import sys
sys.path.insert(0, '.github/skills/obsidian/scripts')
from obsidian import Obsidian

ob = Obsidian()

# Read current MOC
content = ob.read(path='Research/Library/00 MOC/🗺️ MOC - Research Library.md')

# Make in-memory edits (string replacement)
updated = content.replace(old_text, new_text)

# Overwrite — always use keyword args
ob.create(path='Research/Library/00 MOC/🗺️ MOC - Research Library.md', content=updated, overwrite=True)
print('MOC updated')
"

Windows UTF-8: ob.read() and ob.create() handle encoding correctly. Do not go through subprocess pipes for MOC reads/writes — encoding errors will occur with emoji paths.

Recently Added entry format

When writing a new Recently Added entry, always include an inline date tag so the pruning step can identify stale entries without reading every linked note:

- [[{slug}|{title}]] — {one-line why this note matters} `{YYYY-MM-DD}`

Example:

- [[five-ai-paradigm-shifts|Five AI Paradigm Shifts]] — Miessler's framework for agent-era mental models `2026-03-30`

Pruning stale Recently Added entries

Whenever Step 5 runs (i.e., on every successful note save), prune Recently Added entries whose inline date is more than 7 days before today. Entries without an inline date are left untouched (they predate this feature).

Use this Python snippet as part of the in-memory MOC edit before rewriting:

import re
from datetime import date, timedelta

cutoff = date.today() - timedelta(days=7)

def prune_recently_added(moc_content: str) -> tuple[str, int]:
    """Remove Recently Added list items whose `YYYY-MM-DD` date is older than cutoff.
    Returns (updated_content, number_pruned).
    Items without a date tag are kept unchanged.
    """
    # Match list items that end with a backtick-wrapped date: `2026-03-22`
    date_pattern = re.compile(r'`(\d{4}-\d{2}-\d{2})`')
    pruned = 0
    output_lines = []
    in_recently_added = False

    for line in moc_content.splitlines(keepends=True):
        # Detect entry into / exit from the Recently Added section
        if re.match(r'^##\s+Recently Added', line):
            in_recently_added = True
            output_lines.append(line)
            continue
        if in_recently_added and re.match(r'^##\s+', line):
            in_recently_added = False

        if in_recently_added and line.startswith('- '):
            m = date_pattern.search(line)
            if m:
                entry_date = date.fromisoformat(m.group(1))
                if entry_date < cutoff:
                    pruned += 1
                    continue  # drop this line
        output_lines.append(line)

    return ''.join(output_lines), pruned

Apply it before inserting the new entry:

updated_content, n_pruned = prune_recently_added(moc_content)
if n_pruned:
    print(f"Pruned {n_pruned} stale Recently Added entries")
# then insert new entry and rewrite

Use the lighter append approach only when a small footer-style refresh is good enough and the file does not need an in-place insertion. Even in that case, still run prune_recently_added on the current content first, rewrite with pruning applied, then append the new entry.

If a topic MOC clearly applies, update it secondarily after the master MOC is current. The master MOC remains authoritative for canonical tags and library-wide freshness.

Step 6 — Open in Obsidian

python -c "import sys; sys.path.insert(0,'.github/skills/obsidian/scripts'); from obsidian import Obsidian; ob=Obsidian(); ob.open('Research/Library/{library_bucket}/{slug}')"

Step 6.5 — Detect Connections

After writing the note and updating MOCs, run the connection detector to classify relationships between the new note and existing vault content:

python .github/skills/obsidian-connection-detector/scripts/detect.py --note "Research/Library/{library_bucket}/{slug}.md" --no-section

This is best-effort — if it fails or is unavailable, continue to Step 7. The detector will classify relationships (supports, contradicts, extends, bridges) and write to Research/connections.json. The thesis tracker will pick up new connections on its next run and flag emerging theses.

Step 7 — Confirm

Report to the user:

  • Note title and path
  • Brief summary (1-2 sentences)
  • Tag list
  • Whether the master MOC was updated, and whether any topic MOC was also updated
  • Thesis drift results: how many notes were compared, any supersessions or conflicts
  • If a supersession occurred: which old note was marked superseded
  • If a conflict was found: path to the synthesis note
  • Connections found: how many and what types (supports/contradicts/extends/bridges)
  • Obsidian link: obsidian://open?vault=Obsidian%20Vault&file=Research%2FLibrary%2F{library_bucket_urlencoded}%2F{slug}

Step 8 — Reflection (composable)

Invoke the skill-reflection skill with the following context:

  • Calling skill: obsidian-linked-research
  • SKILL.md path: .github/skills/obsidian-linked-research/SKILL.md
  • Steps completed: list each step with pass/fail/skipped (including Step 2.5)
  • Friction notes: any workarounds, retries, unexpected errors, or manual interventions

The reflection skill will analyze the run and produce improvement recommendations.

Output Format

The output is a Research/Library/<bucket>/{slug}.md file in the Obsidian vault, following the enriched note template above. The format matches the existing library pages and respects the live MOC/tag taxonomy already in the vault.

If thesis drift was detected, additional outputs may include:

  • Updated frontmatter on superseded notes (status: superseded, superseded_by:)
  • Synthesis notes at Research/Library/<bucket>/conflict-<slug1>-vs-<slug2>.md

Rules

  1. Never skip the fetch step — always run fetch.py even if you think you know the content
  2. Never call an external LLM API for summarization — use your own model (VS Code)
  3. Always use the Obsidian Python API for vault writes — use ob.create(path=, content=) with keyword args; never use positional arguments and never write vault files directly via filesystem tools
  4. Write-tool + Python API is the primary write pattern — write content to a temp file with the Write tool, read it in Python, call ob.create(path=..., content=...), then delete the temp file; do not use @'...'@ heredocs from bash on Windows (they break on apostrophes and single quotes in note content)
  5. Never use bash -c "powershell -Command \"...\"" for heredocs — quote escaping through that shell boundary is irreparably lossy; the Write-tool + Python API pattern avoids this entirely
  6. Windows UTF-8 — on Windows, subprocess pipes default to cp1252 and cannot encode emoji or non-ASCII vault paths; add sys.stdout.reconfigure(encoding='utf-8') to one-liner subprocess commands, or call the Obsidian Python API directly (preferred)
  7. Handle fetch errors gracefully — if fetch returns an error, tell the user and suggest alternatives
  8. Deduplicate by URL first, slug second — search all Research/Library/ frontmatter for the input URL in Step 0 before fetching; check slug in Step 4 as a secondary guard
  9. Zero pip deps — fetch.py uses only Python stdlib
  10. Read the master MOC first — tag and folder decisions must be based on the live Research/Library/00 MOC/🗺️ MOC - Research Library.md
  11. Prefer existing tags — reuse the master MOC's canonical lowercase kebab-case tags before introducing a new one
  12. Keep the master MOC fresh — update it with every successful research-note addition unless the change truly requires larger curation work
  13. Use topic MOCs secondarily — they are optional supporting maps, not the authoritative taxonomy source
  14. Thesis drift check is semantic, not syntactic — use your own judgment to compare core theses; do not reduce this to keyword overlap or string matching
  15. Supersession updates are post-write — only modify old notes after the new note is successfully written to the vault
  16. Synthesis notes are for genuine conflicts only — do not create synthesis notes for mere differences in emphasis, scope, or audience; reserve them for real disagreements on factual claims or framework incompatibilities

Related Skills

SkillRelationship
obsidianComposable vault wrapper — used for all vault writes
obsidian-daily-researchAutomated daily pipeline — produces Research/Dailies/ notes with #keep tags that get promoted to Research/Library/
obsidian-vault-lintWeekly maintenance — Phase 3 taxonomy repair detects tag and folder drift; thesis drift check complements it at ingest time
last30daysResearch skill — shares xAI API patterns and keyring/env/config key cascade
content-research-writerLong-form writing partner — can use Library notes as sources

Architecture

User: "research this: <url>"
          │
          ▼
┌─────────────────────────┐
│  Read master MOC        │  ← Determine folder + canonical tags
│  + tag index + library  │
└──────────┬──────────────┘
           │
           ▼
┌─────────────────────────┐
│  fetch.py <url>         │  ← Python stdlib only
│  ├─ Tweet? → xAI API   │     (x_search tool, grok-4-1-fast)
│  └─ Web?  → urllib      │     (HTML strip, plain text)
└──────────┬──────────────┘
           │ JSON stdout
           ▼
┌─────────────────────────┐
│  Agent (VS Code model)  │  ← Summarize, structure, compose
│  ├─ Generate summary    │
│  ├─ Thesis drift check  │     ← NEW: compare against existing notes
│  │  ├─ Supersedes?      │        Update old note frontmatter
│  │  ├─ Conflicts?       │        Create synthesis note
│  │  └─ Complements?     │        Add to Related
│  ├─ Compose markdown    │
│  └─ Write → Python API  │
└──────────┬──────────────┘
           │ Write tool → _tmp_note.md → ob.create(path=, content=)
           ▼
┌─────────────────────────┐
│  obsidian.py create     │  ← CLI wrapper (composable)
│  └─ Research/Library/   │
│     <bucket>/{slug}.md  │
└──────────┬──────────────┘
           │
           ▼
┌─────────────────────────┐
│  Post-write drift ops   │  ← Update superseded notes, create synthesis
└──────────┬──────────────┘
           │
           ▼
┌─────────────────────────┐
│  Update master MOC      │  ← Keep library map and tags current
│  (+ topic MOC if needed)│
└─────────────────────────┘
Repository
0xRabbidfly/Eric-Cartman
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.