·18 min read
Claude Code + Obsidian: The Closest Thing to a Limitless Pill
How I turned Obsidian into Claude Code's long-term memory — a limitless-style second brain my AI agents pull from in milliseconds, instead of re-researching every task.

Most people use Claude Code like a fancy chatbot. You open a terminal, ask it something, it answers, you close the window. The context dies. Next time, you start from zero.
That's a waste.
Claude Code is at its best when it has a memory. Not a vibe, not a system prompt, not a clever rule file — a real, durable, searchable body of knowledge it can reach into on every task. For me, that memory is Obsidian. And once I plugged the two together, the whole thing started to feel less like AI tooling and more like a Limitless-style second brain.
This post is how I set it up, why it works, and what changes day-to-day when your AI agents have a real vault to pull from.
(For context on how I run the rest of my agency from one terminal window, see this post. This is the memory layer under all of it.)
Why Notion Didn't Work (And Obsidian Did)
I used Notion for years. It's beautiful. The databases are clean. The share links work. Your mum could use it.
But it has one fatal flaw for AI-first workflows: it lives behind an API. Every time Claude Code needs a note, it has to round-trip a request, pay a rate limit, and hope the block it wants isn't buried three toggles deep. Notion is a product. It's built for humans clicking around, not for agents reaching in at speed.
Obsidian is the opposite. It's just plain markdown files in a folder on your disk. No API. No auth dance. No rate limits. Claude Code can grep, read, and edit them at filesystem speed — which is to say, instantly.
That one architectural difference changes everything:
- Notion: AI assistant that has to ask permission to read your notes.
- Obsidian: AI assistant that is in your notes, right now, while you're talking to it.
If you're building a long-term knowledge system that AI agents will actually use, stop fighting this. Markdown-on-disk wins.
The "Limitless Brain" Architecture
Here's the mental model. You have two systems:
- Claude Code — the reasoning layer. Fast, context-aware, great at pattern matching, forgets everything the moment you close the terminal.
- Obsidian — the memory layer. Durable, structured, searchable, grows over time, remembers everything forever.
Alone, each one is useful but incomplete. Claude without memory is a goldfish with a PhD. Obsidian without reasoning is a pile of notes that only you can connect.
Put them together and something weird happens. Claude stops "researching" topics from scratch. It reaches into your vault, pulls the 3 most relevant notes, synthesises them, and writes an answer that's already aligned with how you think — because the source material is literally your own past thinking.
That's the closest thing I've found to the Limitless pill. Not intelligence. Compounding context.
What Actually Changes Day-to-Day
This isn't a productivity theory post. Here's what's different since I wired my vault into Claude Code:
- Cold topics get warm instantly. I ask Claude to draft a sales script for handymen, and it pulls from my local-sales-playbook.md — a file with every learning from 60+ cold calls. The first draft is already better than most people's final draft.
- Ideas surface that I'd forgotten I had. I mentioned a client problem and Claude responded with a framework I'd written six months ago in a throwaway diary entry. I had no memory of writing it. The vault did.
- Decisions get faster. Instead of thinking "have I decided this before?" I just ask Claude. If there's a past decision in the vault, it finds it. If not, we decide fresh and save the result — which feeds the next round.
- Content flows out of context, not out of thin air. Every blog post, newsletter, and social post I publish now starts with "here's the vault context — turn this into [format]." Writing stops being the hard part. The thinking is already done.
For a deeper look at how this stack replaces what used to take an entire team, see this post. The memory layer is the unlock.
Under the Hood: Three Layers and Four Hooks
The setup isn't a plugin or an MCP server. It's three layers of plain files, plus four small Python hooks that move context in and out at the right moments.
Everything below runs on my machine today: an Obsidian vault of just over 1,500 notes, 170 memory files, 186 skills, and about 30 hook commands across 10 Claude Code events. This section covers the four hooks that handle memory.
Three layers, each answering a different question:
| Layer | Holds | Loaded |
|---|---|---|
Vault (knowledge/) | What I know: strategy, SOPs, people, decisions, diary | On demand, when Claude reads a file |
Memory (memory/ + MEMORY.md) | How to work with me: corrections, gotchas, preferences | Index every session, files on demand |
| Hooks | Session state: last handoff, learned failures, skill hints | At session start and on every prompt |
Over one session, the flow looks like this:
SessionStart -> session-handoff.py read + load-instincts.py -> printed into context
UserPromptSubmit -> skill-suggest.py -> one hint line
(during the task) Claude greps and reads vault files; nested CLAUDE.md files load with them
Stop -> session-handoff.py write + extract-instincts.py -> files on disk
One rule sits under all of it: inject pointers, not content. Nothing loads the whole vault. The hooks add a few hundred tokens at most, and everything else is fetched with grep when the task needs it.
Layer 1: The Vault Is a Folder Claude Can Already Read
Obsidian is markdown files in a folder. Put that folder inside the workspace Claude Code runs in and the integration is done. Mine lives at C:\Dev\knowledge\. Claude reads, greps and edits notes with the same tools it uses on code. No API, no auth, no rate limit.
What makes it work is a CLAUDE.md at the vault root that acts as a router. Mine is mostly two tables, one for where to look and one for where to save. Trimmed:
## Knowledge Routing — Where to Find Things
| Question | Look Here |
|-----------------------------------|----------------------------------------------|
| Context on a person? | `crm/{firstname}-{role}.md` |
| What happened today/this week? | `diary/YYYY-MM-DD.md` |
| Past decisions and why? | `intelligence/decisions.md` |
| How to run a process? | `operational/` — find by cadence or topic |
## Where to SAVE Things
| What You Learned | Save To |
|-----------------------------------|----------------------------------------------|
| Interaction with a person | `crm/{name}.md` → `## Interactions` section |
| Strategic decision with reasoning | `intelligence/decision-YYYY-MM-DD-topic.md` |
| Reusable prompt or template | `resources/prompt-purpose.md` |
The folders are split by purpose, not by topic: canonical/ (identity and strategy), operational/ (SOPs and templates), intelligence/ (decisions and transcripts), crm/ (people), projects/ (active work), diary/ (daily). A purpose-based folder tells Claude how to use a file, not just what it's about.
Two Claude Code behaviours keep this cheap.
Nested CLAUDE.md files load lazily. The root file loads at startup. A CLAUDE.md inside a subfolder only loads when Claude reads a file in that subfolder. The vault's router costs nothing during a coding session and appears the moment Claude opens a note. Every project folder in the workspace works the same way.
@ imports keep rules in one place. My workspace root CLAUDE.md is nearly empty:
# Dev Workspace — Claude Code
> Shared workspace rules live in `AGENTS.md` (imported below) — it is the single source of
> truth, and shared rules change there, not here.
@AGENTS.md
The @AGENTS.md line pulls that whole file in. Codex reads AGENTS.md directly, so both agents get the same rules from one file.
The "Where to SAVE" table is the half most setups skip. Without it, Claude reads the vault and never feeds it. With it, the end of a task routinely includes writing a decision, a CRM line or a lesson back to the right folder. The vault grows from use. It held about 400 notes when I first wrote about this setup in April. It holds just over 1,500 now.
Vault notes skip YAML frontmatter and open with a plain classification block instead:
# LachlanCB Brand OS — Master Index
## Classification
**Type:** Operational (Master Index / Map-of-Content)
**Brand:** LachlanCB — this file is the hub.
The **Brand:** line earns its place. I run two brands with different audiences, and grep -rn '\*\*Brand:\*\*' knowledge/ lists which brand owns every file. That line exists because Claude kept briefing one brand's content from the other brand's notes.
Layer 2: Memory Files for How to Work With Me
The vault holds what I know. A second store holds how Claude should work with me. Claude Code's auto-memory lives in a per-project folder under ~/.claude/projects/<project>/memory/, and I keep it strict: one file per fact. A real one, trimmed:
---
name: feedback_pathspec-commit
description: "Commit with an explicit pathspec in one step — a concurrent session's plain `git commit` sweeps whatever is staged in the shared index"
metadata:
type: feedback
---
Concurrent Claude sessions share ONE git index. Staging explicit paths is not
enough: whatever sits staged between your `git add` and your `git commit`
belongs to whichever session commits first.
**Why:** I staged 38 files; in the seconds before my commit, another session
ran a plain `git commit` and swept all 38 into its own commit.
**How to apply:** `git commit -m "…" -- path1 path2`, then check
`git show --stat HEAD` holds only your files.
Related: [[feedback_pre-deploy-concurrent-session-check]]
The Why line matters more than the rule. A bare rule gets applied rigidly or argued away. With the incident attached, Claude can judge whether an edge case is the same situation.
MEMORY.md is the index, and it loads into every session. That makes it the expensive part, so it holds pointers only, one line per memory:
- [Pathspec commit](feedback_pathspec-commit.md) — 🔒 `git commit -- <paths>`
- [Don't kill long installs](feedback_dont-kill-long-installs.md) — 🔒 silence ≠ hung
- [Never ask for secrets in chat](feedback_never-ask-for-secrets-in-chat.md) — 🔒 DB reset, stdin script or the secrets file instead
I cap it at about 17 KB. It's at 19 KB as I write this, so it's due a prune. Memory that only grows turns into noise, and the pruning is the maintenance cost of the whole system.
Layer 3: Hooks
Hooks are commands Claude Code runs on lifecycle events. Each one receives the event as JSON on stdin. For SessionStart and UserPromptSubmit, anything a hook prints to stdout on exit code 0 is added to Claude's context. That is the entire mechanism for injecting memory.
The wiring in ~/.claude/settings.json, simplified (paths shortened; use absolute paths in yours):
{
"hooks": {
"SessionStart": [
{ "hooks": [
{ "type": "command", "command": "python ~/.claude/scripts/session-handoff.py read" },
{ "type": "command", "command": "python ~/.claude/hooks/load-instincts.py" }
] }
],
"UserPromptSubmit": [
{ "hooks": [
{ "type": "command", "command": "python ~/.claude/hooks/skill-suggest.py" }
] }
],
"Stop": [
{ "hooks": [
{ "type": "command", "command": "python ~/.claude/scripts/session-handoff.py write" },
{ "type": "command", "command": "python ~/.claude/hooks/extract-instincts.py" }
] }
]
}
}
Hook 1: The Session Handoff
When a session stops, one script writes a snapshot: modified files, commits from the last two hours, the tail of a scratchpad file and the current branch. At the next session start, the same script prints it back if it's under 48 hours old.
HANDOFF_PATH = Path(os.path.expanduser("~/.claude/HANDOFF.md"))
WORKSPACE = "C:/Dev"
def write_handoff():
"""Stop hook: capture session state."""
sections = [f"# Session Handoff — {datetime.now():%Y-%m-%d %H:%M}\n"]
try:
status = subprocess.run(
["git", "--no-optional-locks", "-C", WORKSPACE, "status", "--porcelain"],
capture_output=True, text=True, timeout=5,
).stdout.strip()
if status:
files = [line.strip() for line in status.splitlines()[:20]]
sections += ["## Modified Files", "```", "\n".join(files), "```\n"]
except Exception:
pass
# ...same pattern for `git log --since=2.hours.ago`, the scratchpad tail, the branch
if len(sections) > 1:
HANDOFF_PATH.write_text("\n".join(sections), encoding="utf-8")
def read_handoff():
"""SessionStart hook: print the last handoff into context."""
if not HANDOFF_PATH.exists():
return
age_hours = (datetime.now().timestamp() - HANDOFF_PATH.stat().st_mtime) / 3600
if age_hours > 48:
return # stale context is worse than none
print(f"## Last Session Handoff ({int(age_hours)}h ago)")
print(HANDOFF_PATH.read_text(encoding="utf-8").strip())
The flag --no-optional-locks came from a painful bug. A plain git status takes .git/index.lock while it refreshes the index. My hook ran it with a 5-second timeout. When the timeout killed git mid-write, the lock file stayed behind and blocked every commit in the workspace, once for 31 hours before I found it. A read-only status check should never take a lock, and with that flag it doesn't.
Hook 2: Learned Instincts
During a session, failed tool calls get logged to a JSONL file. At Stop, extract-instincts.py turns them into one-line "what failed, what works" rules, capped at 50. At the next session start, a 45-line loader prints them:
INSTINCTS_FILE = Path.home() / ".claude" / "instincts" / "learned-instincts.md"
def main():
if not INSTINCTS_FILE.exists():
return
content = INSTINCTS_FILE.read_text(encoding="utf-8").strip()
count = content.count("\n- ")
if count > 0:
print(f"\n## Learned Instincts ({count} patterns)\n")
print("These are patterns learned from past session failures. Follow them.\n")
for line in content.split("\n"):
if line.startswith("- "):
print(line)
Here's part of what landed at the top of a recent session:
## Learned Instincts (11 patterns)
These are patterns learned from past session failures. Follow them.
- Don't pipe curl output to python/sh — download first, then process (hook blocks it)
- When editing docker-compose.yml, insert services inside the services: block, not append to end of file.
- Notion REST API cannot duplicate pages with inline databases — use notion-duplicate-page MCP tool instead
Each line exists because a session hit that wall once.
Hook 3: Skill Suggestions From a TF-IDF Retriever
With 186 skills I can't remember which one exists for what. Letting Claude auto-fire them on fuzzy matches was worse, because a wrong skill costs more than no skill. So a UserPromptSubmit hook suggests up to three and leaves the decision to Claude. (If you're new to skills, I broke down how to build them here.)
It runs before every prompt, so it has to be fast and dependency-free. No embeddings, no model download, no API call. It's a TF-IDF retriever over each skill's name and description, standard library only:
MAX_SUGGESTIONS = 3
MIN_SCORE = 7.0 # absolute idf-sum floor; 3.0 let one common word clear it
MIN_HITS = 2 # a skill must match at least two distinct prompt terms
REL_GAP = 0.5 # drop results scoring under half the top score
NAME_BOOST = 2.5 # a hit in the skill's name outweighs one in its description
def tokenize(text):
words = re.findall(r"[a-z0-9]+", text.lower())
return [w for w in words if len(w) >= 3 and w not in STOPWORDS]
def build_index():
docs = [] # one entry per SKILL.md: {"name", "name_tokens", "tokens"}
# ...parse each SKILL.md's frontmatter into name + description tokens...
df = {}
for d in docs:
for t in d["tokens"]:
df[t] = df.get(t, 0) + 1
n = len(docs)
idf = {t: math.log((n + 1) / (c + 0.5)) for t, c in df.items()}
# cached to skill-index.json; rebuilt only when the skills dir mtime changes
return {"n": n, "idf": idf, "docs": docs, "skills_mtime": _skills_mtime()}
def score(prompt, index):
qtokens = set(tokenize(prompt))
results = []
for d in index["docs"]:
hits = qtokens & set(d["tokens"])
if len(hits) < MIN_HITS:
continue
s = sum(index["idf"].get(t, 0.0) * (NAME_BOOST if t in d["name_tokens"] else 1)
for t in hits)
results.append((s, d["name"]))
return sorted(results, reverse=True)
The IDF weighting does the useful work. A distinctive word like "carousel" appears in one or two skill descriptions and scores high. Generic words like "create" or "content" appear in dozens and score close to zero, and the stopword list removes the rest ("please", "thanks", "continue").
The thresholds came from living with it:
MIN_SCOREstarted at 3.0. One common word could clear it, so almost every prompt got suggestions and they turned into noise. Moving to 7.0 and requiring two distinct matching terms made it quiet unless a prompt really matches.NAME_BOOSTexists because a term in the skill's name is stronger evidence than the same term in its description.REL_GAPcuts the long tail. If the best match scores 20, a skill scoring 8 isn't worth a mention.
The output is one line, and its wording is deliberate:
Possibly relevant skills (suggestion, not auto-fired — invoke only if it fits): /blog-draft, /seo-audit
That line lands in Claude's context, so it says outright that it's a hint and not an order.
The Rule Every Hook Follows: Never Block the Prompt
A hook that hangs or crashes stalls every prompt, so each one carries a hard timeout and swallows its own errors:
# Hard timeout guard: never hang the prompt.
_guard = threading.Timer(3.0, lambda: os._exit(0))
_guard.daemon = True # load-bearing, see below
_guard.start()
# ...hook body...
if __name__ == "__main__":
try:
main()
except Exception:
pass # never block the prompt
daemon = True is there because of a real bug. Without it, the timer thread kept the interpreter alive until it fired, so every run took at least 3 seconds even after the hook's work was done. Every prompt paid that delay.
Windows: The Wrapper That Hides the Console
On Windows, every Python hook flashed a console window on screen. Each command in my settings.json actually runs through a small Node wrapper:
node ~/.claude/hooks/hidden-run.js -- python ~/.claude/hooks/skill-suggest.py
It spawns the real command with windowsHide: true and passes stdin, stdout and the exit code straight through, so the hook contract is unchanged. It also resolves bare python to a real interpreter. Claude Code can spawn hooks with a reduced PATH where the only python on it is the Microsoft Store redirector, which prints an install prompt and exits with code 9009.
What the Hooks Change
- Sessions start mid-thought. The last session's modified files, commits and branch are already in context before I type anything.
- The same failure stops repeating. Once an instinct line exists, the next session reads it before it can make that mistake again.
- The vault gets written to, not just read. It went from about 400 notes to just over 1,500 since April, and the save table is why Claude writes to it at the end of a task instead of only reading from it.
Start Smaller Than This
You don't need all of it on day one:
- Put the vault folder inside your workspace and write a
CLAUDE.mdwith two tables: where to find things and where to save them. - Add one
Stophook that writes a handoff file and oneSessionStarthook that prints it. - Write a memory file the second time you correct Claude on the same thing, with the incident in a Why line.
Skip the TF-IDF hook until you have enough skills to forget which ones you have.
FAQ
Do I need an Obsidian plugin or MCP to connect it to Claude Code?
No. That's the whole point. Obsidian is just markdown files in a folder. Claude Code already has filesystem access — point it at the folder and it works. Plugins add friction where you don't need any.
How big can the vault get before it slows Claude Code down?
Bigger than you think. Claude doesn't load the whole vault into context — it greps, reads the relevant files, and moves on. My vault has just over 1,500 notes and Claude handles it without breaking a sweat. The bottleneck is usually your CLAUDE.md clarity, not the file count.
Can I use Notion with Claude Code instead?
You can, via the Notion MCP. But it's slower, flakier, and you pay for every lookup. If speed and depth matter, Obsidian wins. If your team already lives in Notion, keep your team's docs there and build a personal Obsidian vault for AI work on the side.
What if I've never used Obsidian before?
Download it, create a vault in a folder your terminal can see, and start dumping. Don't overthink the structure. Let it grow organically for two weeks, then write a CLAUDE.md describing what's actually there. That's the backwards way — and it's faster than trying to design the perfect system upfront.
The Real Unlock
People keep asking me what's special about my Claude Code setup. It's not the skills, the prompts, or the rules. It's that my AI has a memory older than today's session — and that memory grows every time I use it.
If you're serious about building with Claude Code long-term, stop treating it as a stateless chatbot. Give it a vault. Let it live inside your second brain. Then watch how fast "new topics" stop existing — because almost nothing is new when your AI can reach back through everything you've ever thought, written, or decided.
That's the cheat code. Obsidian is the vault. Claude is the reasoning. You're the editor.
If you want to set this up for your own business and you're not sure where to start — book a free call here. I'll walk you through what the first 48 hours of your AI second brain should look like, no strings.