Every Plan I Approve Now Files Itself in Obsidian. Here's How.

Every plan I approve in Claude Code disappears the moment I say yes. Not literally; Claude Code does save it somewhere in ~/.claude/, under a generated filename I'd never think to search for. But for all practical purposes, once I approve, that plan is gone. Weeks later, trying to remember what we actually decided on for a feature or a whole project, there's no real way to find it again.

I plan almost everything with Claude Code before writing a line of code, so this wasn't a rare annoyance, it was happening constantly. And even setting the disappearing part aside, a Claude Code plan was never going to read like an actual project spec anyway. Every other project I've documented in Obsidian follows the same shape: an overview, goals and objectives, what's in and out of scope, functional and non-functional requirements, technical specifications. Claude Code has no reason to know that structure exists, so its plans come out organized however the moment calls for, not how my own documentation actually works.

The fix has two halves, then. Claude Code needs to know the shape a plan should take, the same sections every other spec in my vault already follows, so what it produces is usable as documentation and not just a transcript of a conversation. And once a plan looks like that, approving it should be enough, on its own, no judgment call about whether saving seemed worth it this time, to turn it into a real note in Obsidian, filled in section by section, sitting in the right folder, without me writing a word of it myself.

That second half is doing more work than it sounds like. Claude Code can react the instant a plan is approved, but only through a hook, something that fires because ExitPlanMode was called and not because Claude decided, in the moment, that saving seemed relevant enough, which is the more common way Claude Code reaches for extra capabilities. Even with that settled, reacting isn't the same as knowing what to do with what it's reacting to; a hook just runs a script, and that script knows nothing about Obsidian, a vault, or what a finished spec note should look like on its own. Getting from "a plan was approved" to "a properly formatted spec exists in the right folder" means building a bridge between two apps that were never designed to talk to each other, and teaching one of them a format the other already uses, one phase at a time:

  1. Getting Obsidian ready to receive plans
  2. Teaching Claude Code to plan in the shape of a spec
  3. Writing the hook that builds and saves the note
  4. Wiring the hook in globally and testing it for real

While the fix looks good on paper, there are still some critical decisions to be made about how to implement it. The language was the easy call: TypeScript. It's a language I've come to really enjoy working with. The runtime took more thought. I already have Bun installed and reach for it often, when working with TypeScript. Bun runs .ts files directly and has its own built-in .env loading, no packages needed but, ultimately, Claude Code itself depends on Node. Given that dependency, it just makes more sense to go with Node.

I may make another post about doing this exact project using Bun. Who knows?

Getting Obsidian Ready to Receive Plans

Before Claude Code can hand anything off to Obsidian, Obsidian has to be willing to listen. By default, it isn't; it's a local app that only talks to itself. So the first step in building this integration isn't writing any code at all; it's giving Obsidian a way to accept messages from the outside world, and making sure it knows what to do with them once they arrive. That takes two pieces working together:

  1. Templater | Obsidian
    Builds my notes from templates; it's the one that knows how to take my project-specs format and fill it in correctly.
  2. MCP Connector | Obsidian
    Runs its own small, token-protected server right inside Obsidian, no separate binary to download, giving Claude Code a way to write directly into the vault instead of me doing it by hand.

With both plugins installed and enabled, the next step is generating a token so Claude Code can actually talk to MCP Connector.

Open Settings → MCP Connector inside Obsidian. Under the "Access Control" section, a fresh install already has one token waiting, labeled Default. On that token's row, click "Claude Code". MCP Connector will build the exact JSON needed, port and all, so there's nothing to hand-type or guess at:

1{
2 "mcpServers": {
3 "mcp-tools-istefox": {
4 "type": "http",
5 "url": "http://127.0.0.1:27200/mcp",
6 "headers": {
7 "Authorization": "Bearer YOUR_TOKEN"
8 }
9 }
10 }
11}

27200 is just the first port in the range MCP Connector tries; if it's already taken on your machine, the plugin picks the next free one up through 27205, and the config it copies will reflect whatever it actually landed on.

That config goes into ~/.claude.json, in its global mcpServers block, for a connection available across every project rather than one. Rather than hand-editing that file directly, since it also holds session state and other settings worth not breaking, registering should be done via the command line:

claude mcp add-json "mcp-tools-istefox" '{"type": "http", "url": "http://127.0.0.1:27200/mcp", "headers": {"Authorization": "Bearer ACTUAL_TOKEN"}}' --scope user

Claude Code now has a live connection to the Obsidian vault.

Teaching Claude Code to Plan in the Shape of a Spec

None of this works unless a plan can be split into the number of pieces the template expects. Left alone, Claude Code writes plans however fits the moment, a paragraph here, a checklist there, never the same shape twice.

So it needs a standing rule, not something typed out by hand every session. Claude Code reads ~/.claude/CLAUDE.md before every session starts, in every project, which makes it the right place for this:

In Scope and Out of Scope will get their own headings here, even though the template itself may render them as bold bullets under one "Scope" heading. Real headings are easy for code to cut apart cleanly later; bold text buried in a bullet isn't. The hook script, coming up next, is what will turn this shape into a more readable shape.

1<!-- ...whatever else is already in your CLAUDE.md, this just gets appended to it... -->
2 
3## Plan structure
4 
5When presenting a plan in Plan Mode, start with a short, specific title as a single H1 heading, a few words, short enough to work as a filename. Then structure the rest using exactly these headings, in this order, and nothing else:
6 
7# <Plan Title>
8 
9## Overview
10## Goals & Objectives
11## Scope
12### In Scope
13### Out of Scope
14## Requirements
15### Functional Requirements
16### Non-Functional Requirements
17## Technical Specifications
18 
19Use this heading text verbatim, except for the plan title itself. Every section needs real content, even for a small change, don't skip one because it feels thin.

The last line exists because a small change might tempt Claude Code to write "N/A" and move on, which won't help once every field needs real text.

Writing the Hook That Builds and Saves the Note

Everything up to this point has been setup: Obsidian listening for connections, Claude Code registered against it, a standing rule shaping how plans get written. None of it does anything on its own. This is the piece that actually watches for an approved plan and turns it into a note.

It breaks into six jobs: catch the moment a plan gets approved, read the plan's text, figure out which project it belongs to, split the plan into the sections the spec format expects, assemble those into a full note, and hand that note to Obsidian. Six small files, each one doing exactly one of those jobs:

├── .editorconfig
├── .gitignore
├── biome.json
├── package-lock.json
├── package.json
├── src
│   ├── build-note.ts
│   ├── hook.ts
│   ├── mcp-client.ts
│   ├── parse-plan.ts
│   ├── project-name.ts
│   └── types.ts
└── tsconfig.json

types.ts just holds the shape every other file shares, the seven fields a parsed plan breaks down into.

1export type PlanSections = {
2 overview: string;
3 goals: string;
4 scopeIn: string;
5 scopeOut: string;
6 functionalRequirements: string;
7 nonFunctionalRequirements: string;
8 technicalSpecs: string;
9};

parse-plan.ts does the actual splitting. It knows nothing about Obsidian or MCP, only how to turn one block of plan text into a title and those seven sections, by finding a heading and reading everything up to whichever heading comes next.

1import type { PlanSections } from '@/types';
2 
3const extractPlanTitle = (text: string): string => {
4 const match = text.match(/^#\s+(.+)$/m);
5 return match ? match[1].trim() : 'Untitled Plan';
6};
7 
8const extractSection = (text: string, heading: string, nextHeading?: string): string => {
9 const start = text.indexOf(heading);
10 if (start === -1) return '';
11 
12 const remaining = text.slice(start + heading.length);
13 const nextIndex = nextHeading ? remaining.indexOf(nextHeading) : -1;
14 
15 return remaining.slice(0, nextIndex === -1 ? undefined : nextIndex).trim();
16};
17 
18export const parsePlan = (content: string): { title: string; sections: PlanSections } => ({
19 title: extractPlanTitle(content),
20 sections: {
21 overview: extractSection(content, '## Overview', '## Goals & Objectives'),
22 goals: extractSection(content, '## Goals & Objectives', '## Scope'),
23 scopeIn: extractSection(content, '### In Scope', '### Out of Scope'),
24 scopeOut: extractSection(content, '### Out of Scope', '## Requirements'),
25 functionalRequirements: extractSection(
26 content,
27 '### Functional Requirements',
28 '### Non-Functional Requirements',
29 ),
30 nonFunctionalRequirements: extractSection(
31 content,
32 '### Non-Functional Requirements',
33 '## Technical Specifications',
34 ),
35 technicalSpecs: extractSection(content, '## Technical Specifications'),
36 },
37});

project-name.ts answers a different question, not what the plan says, but which project it happened in. Claude Code sets CLAUDE_PROJECT_DIR on the hook process itself, so this reads the working directory's own name and turns it into something presentable.

1import { basename } from 'node:path';
2 
3export const projectNameFromDirectory = (): string => {
4 const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
5 const directoryName = basename(projectDir);
6 
7 return directoryName
8 .replace(/[-_]+/g, ' ')
9 .trim()
10 .split(' ')
11 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
12 .join(' ');
13};

build-note.ts takes what the first two files produced and turns it into an actual file, a vault path and the full markdown text, frontmatter included.

1import { dedent } from 'ts-dedent';
2import type { PlanSections } from '@/types';
3 
4const slugify = (title: string): string =>
5 title
6 .replace(/[^a-zA-Z0-9\s-]/g, '')
7 .trim()
8 .replace(/\s+/g, '-');
9 
10export const buildFilePath = (projectName: string, planTitle: string): string => {
11 const now = new Date();
12 const datePart = [
13 now.getFullYear(),
14 String(now.getMonth() + 1).padStart(2, '0'),
15 String(now.getDate()).padStart(2, '0'),
16 ].join('-');
17 
18 return `Projects/Spec - ${projectName} - ${slugify(planTitle)} - ${datePart}.md`;
19};
20 
21export const buildNoteContent = (projectName: string, planTitle: string, sections: PlanSections): string => {
22 const lastModified = new Date().toLocaleString('en-US', {
23 weekday: 'long',
24 year: 'numeric',
25 month: 'long',
26 day: 'numeric',
27 hour: 'numeric',
28 minute: '2-digit',
29 second: '2-digit',
30 });
31 
32 return dedent`
33 ---
34 Project Name: ${projectName}
35 Status: To Do
36 Priority: Low
37 Last Modified: ${lastModified}
38 tags: project/specs
39 ---
40 
41 # ${planTitle}
42 
43 ## Overview
44 
45 ${sections.overview}
46 
47 ## Goals & Objectives
48 
49 ${sections.goals}
50 
51 ## Scope
52 
53 * **In Scope:** ${sections.scopeIn}
54 * **Out of Scope:** ${sections.scopeOut}
55 
56 ## Requirements
57 
58 * **Functional Requirements:** ${sections.functionalRequirements}
59 * **Non-Functional Requirements:** ${sections.nonFunctionalRequirements}
60 
61 ## Technical Specifications
62 
63 ${sections.technicalSpecs}
64 `;
65};

mcp-client.ts is the only file that talks to anything outside itself, one HTTP call carrying a single JSON-RPC request to whatever create_vault_file needs.

Sending the request is only half the job. MCP Connector can reply "OK" even when saving the note failed, with the real error tucked inside the reply. So the reply gets read too: parseJsonRpcResponse unpacks it, since it can arrive in one of two formats, and toolErrorMessage checks it for a hidden error.

1type JsonRpcResponse = {
2 result?: { isError?: boolean; content?: { type: string; text?: string }[] };
3 error?: { message: string };
4};
5 
6const parseJsonRpcResponse = (body: string): JsonRpcResponse | null => { ...
7 const dataLines = body
8 .split('\n')
9 .filter((line) => line.startsWith('data:'))
10 .map((line) => line.slice('data:'.length).trim());
11 const json = dataLines.at(-1) ?? body;
12 
13 try {
14 return JSON.parse(json) as JsonRpcResponse;
15 } catch {
16 return null;
17 }
18};
19 
20const toolErrorMessage = (response: JsonRpcResponse | null): string | null => {
21 if (response === null || (response.result === undefined && response.error === undefined)) {
22 return 'unreadable response';
23 }
24 
25 if (response.error) {
26 return response.error.message;
27 }
28 
29 if (response.result?.isError) {
30 const text = response.result.content
31 ?.map((part) => part.text ?? '')
32 .join(' ')
33 .trim();
34 
35 return text || 'tool reported an error';
36 }
37 
38 return null;
39};
40 
41export const createVaultFile = async (path: string, content: string): Promise<void> => {
42 const response = await fetch(process.env.MCP_CONNECTOR_URL ?? 'http://127.0.0.1:27200/mcp', {
43 method: 'POST',
44 headers: {
45 'Content-Type': 'application/json',
46 Accept: 'application/json, text/event-stream',
47 Authorization: `Bearer ${process.env.MCP_CONNECTOR_TOKEN?.trim()}`,
48 },
49 body: JSON.stringify({
50 jsonrpc: '2.0',
51 id: 1,
52 method: 'tools/call',
53 params: { name: 'create_vault_file', arguments: { path, content } },
54 }),
55 });
56 
57 if (!response.ok) {
58 throw new Error(`create_vault_file failed: ${response.status}`);
59 }
60 
61 const errorMessage = toolErrorMessage(parseJsonRpcResponse(await response.text()));
62 
63 if (errorMessage !== null) {
64 throw new Error(`create_vault_file failed: ${errorMessage}`);
65 }
66};

hook.ts ties the five of them together. This is the file Claude Code actually runs.

Two things in here are easy to miss. First, the plan's text shows up in tool_response.plan, not tool_input, which arrives empty. Second, you'd never see this script's error messages while using Claude Code, so any failure also gets written to a log file at ~/.claude/hooks/save-plan-to-obsidian.log.

1import { appendFileSync } from 'node:fs';
2import { homedir } from 'node:os';
3import { join } from 'node:path';
4import { buildFilePath, buildNoteContent } from '@/build-note';
5import { createVaultFile } from '@/mcp-client';
6import { parsePlan } from '@/parse-plan';
7import { projectNameFromDirectory } from '@/project-name';
8 
9const logPath = join(homedir(), '.claude', 'hooks', 'save-plan-to-obsidian.log');
10 
11type PostToolUseEvent = {
12 hook_event_name: 'PostToolUse';
13 tool_name: string;
14 tool_response: { plan: string };
15};
16 
17const readStdin = (): Promise<string> =>
18 new Promise((resolve, reject) => {
19 let data = '';
20 process.stdin.setEncoding('utf-8');
21 process.stdin.on('data', (chunk) => {
22 data += chunk;
23 });
24 process.stdin.on('end', () => resolve(data));
25 process.stdin.on('error', reject);
26 });
27 
28const main = async (): Promise<void> => {
29 const raw = await readStdin();
30 const event = JSON.parse(raw) as PostToolUseEvent;
31 
32 if (event.tool_name !== 'ExitPlanMode') {
33 return;
34 }
35 
36 const projectName = projectNameFromDirectory();
37 const { title, sections } = parsePlan(event.tool_response.plan);
38 const filePath = buildFilePath(projectName, title);
39 const content = buildNoteContent(projectName, title, sections);
40 
41 await createVaultFile(filePath, content);
42};
43 
44main()
45 .catch((error) => {
46 const message = `save-plan-to-obsidian hook failed: ${error}`;
47 console.error(message);
48 
49 try {
50 appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`);
51 } catch {}
52 })
53 .finally(() => {
54 process.exit(0);
55 });

That .finally() forcing a clean exit is deliberate. Whatever goes wrong in here, a bad MCP response, a parsing edge case, nothing about it should ever surface as an error in the Claude Code session. Failures get logged to a file and swallowed, not surfaced. Writing to the log gets its own try/catch too, so a problem with the log can't become a problem of its own.

Wiring the Hook in Globally and Testing It for Real

Every piece exists now: Obsidian listening, Claude Code connected, a standing rule shaping every plan, a script that knows how to turn one into a note. None of it is actually connected yet. Claude Code has no idea this script exists, and the script has no way to get the one secret it needs, the MCP Connector token, without it ending up somewhere it shouldn't.

That second part first. The token belongs in a .env file, sitting right next to the script (~/.claude/hooks/.env) rather than inside the git repo:

1MCP_CONNECTOR_URL=http://127.0.0.1:27200/mcp
2MCP_CONNECTOR_TOKEN=<your real token>

hook.ts needs one small addition to actually read it, using a path built from the home directory rather than a relative one, since where this script's working directory ends up isn't something worth trusting. homedir and join are already there for the log file, so only loadEnvFile is new:

1import { appendFileSync } from 'node:fs';
2import { homedir } from 'node:os';
3import { join } from 'node:path';
+import { loadEnvFile } from 'node:process';
5import { buildFilePath, buildNoteContent } from '@/build-note';
6import { createVaultFile } from '@/mcp-client';
7import { parsePlan } from '@/parse-plan';
8import { projectNameFromDirectory } from '@/project-name';
9 
+loadEnvFile(join(homedir(), '.claude', 'hooks', '.env'));
11 
12const logPath = join(homedir(), '.claude', 'hooks', 'save-plan-to-obsidian.log'); ...
13 
14type PostToolUseEvent = {
15 hook_event_name: 'PostToolUse';
16 tool_name: string;
17 tool_response: { plan: string };
18};
19 
20const readStdin = (): Promise<string> =>
21 new Promise((resolve, reject) => {
22 let data = '';
23 process.stdin.setEncoding('utf-8');
24 process.stdin.on('data', (chunk) => {
25 data += chunk;
26 });
27 process.stdin.on('end', () => resolve(data));
28 process.stdin.on('error', reject);
29 });
30 
31const main = async (): Promise<void> => {
32 const raw = await readStdin();
33 const event = JSON.parse(raw) as PostToolUseEvent;
34 
35 if (event.tool_name !== 'ExitPlanMode') {
36 return;
37 }
38 
39 const projectName = projectNameFromDirectory();
40 const { title, sections } = parsePlan(event.tool_response.plan);
41 const filePath = buildFilePath(projectName, title);
42 const content = buildNoteContent(projectName, title, sections);
43 
44 await createVaultFile(filePath, content);
45};
46 
47main()
48 .catch((error) => {
49 const message = `save-plan-to-obsidian hook failed: ${error}`;
50 console.error(message);
51 
52 try {
53 appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`);
54 } catch {}
55 })
56 .finally(() => {
57 process.exit(0);
58 });

Since Node doesn't run .ts files on its own, something has to bridge that gap. The obvious choice would be npx tsx, but npx checks the working directory for a local copy of a package before falling back to a global one, and this hook's working directory can't be trusted, the same problem CLAUDE_PROJECT_DIR was solving back in project-name.ts. Installing tsx globally and calling it directly sidesteps that entirely:

npm install -g tsx

That's also why the dry-run test later in this section still uses npx tsx rather than plain tsx: it runs from inside the repo itself, where the working directory is exactly right, and resolving the repo's own pinned dependency there is the correct behavior, not a mistake.

With that settled, the actual wiring is a direct edit to ~/.claude/settings.json, the global one, rather than a project-scoped .claude/settings.json.

This hook is the same three decisions: which event, PostToolUse, since it fires after a plan is approved, whether or not you saw an approval prompt; a matcher, ExitPlanMode specifically, so nothing else on the system gets caught by this; and the command itself:

cd $HOME/path/to/claude-obsidian-integration && tsx $HOME/.claude/hooks/save-plan-to-obsidian.ts

That cd isn't decoration. tsx resolves the @/* path aliases in tsconfig.json relative to the process's working directory, not the script's own location, so run from anywhere else, Claude Code included, the bare tsx $HOME/.claude/hooks/save-plan-to-obsidian.ts throws a module-not-found error before any of this file's own try/catch ever gets a chance to run.

Wired into JSON, that's:

{
"hooks": {
"PostToolUse": [
{
"matcher": "ExitPlanMode",
"hooks": [
{ "type": "command", "command": "cd $HOME/path/to/claude-obsidian-integration && tsx $HOME/.claude/hooks/save-plan-to-obsidian.ts" }
]
}
]
}
}

Merge, do not replace. settings.json holds plenty besides hooks.

Reopening /hooks afterward confirms it landed.

This hook never tells Claude Code to approve or deny anything, and it always finishes cleanly, so a failed save never interrupts your session. Failures go to the log file instead.

Testing this by actually planning something in Claude Code will work, but it's slow; getting to the point of having a real plan to approve means going through the whole planning conversation first. That means Claude reasoning through a real problem and writing a real plan, which takes real time before there's even anything to test. And once there is, it hides failures rather than revealing them: errors here get deliberately swallowed so they never surface in the session, which means a bug doesn't look like an error, it looks like nothing happened in Obsidian at all, no different from success at a glance. The log file is the first place to check when a plan goes missing, but you still have to go looking for it.

Testing the script on its own sidesteps both problems, by feeding it a fake event directly:

printf '%s' '{"hook_event_name":"PostToolUse","tool_name":"ExitPlanMode","tool_input":{},"tool_response":{"plan":"# Test Plan\n\n## Overview\n\nTesting.\n\n## Goals & Objectives\n\nMake sure this works.\n\n## Scope\n\n### In Scope\n\nThe hook script.\n\n### Out of Scope\n\nEverything else.\n\n## Requirements\n\n### Functional Requirements\n\nIt writes a file.\n\n### Non-Functional Requirements\n\nIt does not crash.\n\n## Technical Specifications\n\nTypeScript, tsx, fetch."}}' | CLAUDE_PROJECT_DIR="$HOME/path/to/claude-obsidian-integration" npx tsx src/hook.ts

Run this way, any failure prints straight to the terminal in front of you, since you're the one running it directly rather than Claude Code running it on your behalf. CLAUDE_PROJECT_DIR gets set by hand here too, only because this test skips Claude Code entirely; normally Claude Code sets it automatically every time it spawns the hook, so the real, wired-in path never needs this.

A note showing up in the right folder in Obsidian means the whole pipeline works, independent of Claude Code entirely. Only after that's clean does it make sense to actually plan something small for real, approve it, and watch it land on its own.


One last piece ties the repo itself to everything just wired in. Every path in this post so far, .env, the log file, the target /hooks points the command at, lives under ~/.claude/hooks/, not inside the repo. The repo is where this actually gets written and versioned; a symlink is what connects that to the runtime location, rather than copying a file back and forth by hand every time something changes:

ln -s ~/path/to/claude-obsidian-integration/src/hook.ts ~/.claude/hooks/save-plan-to-obsidian.ts

Edit the repo from here on, and the symlink picks it up immediately, no rebuild or redeploy step to remember. That's the whole pipeline, start to finish: a plan gets approved, and a properly formatted spec shows up in Obsidian, no different than if I'd written it myself, except I didn't have to.