Building an Obsidian Vault Intake Pipeline with Claude Code

The Idea
A bash script + Claude Code CLI pipeline that watches an intake/ folder, automatically processing markdown files into an Obsidian vault — complete with frontmatter, tags, and wikilinks.
Drop a file, run one command, and Claude does the rest: reads the content, classifies it, creates or updates vault notes, links entities, and archives the source file.
The Problem
A Claude-managed vault is great for notes created during live conversations. But there's no good way to batch-process existing markdown files — meeting notes, brain dumps, exported docs — into the vault. An intake pipeline bridges the gap between "stuff you've written down" and "organized vault knowledge."
Architecture
Components
intake/folder — lives outside the vault so Obsidian doesn't index itintake.sh— the bash orchestration scriptintake-prompt.md— the system prompt that instructs Claude how to process each file
Flow
intake/meeting-notes.md
│
▼
./intake.sh intake/meeting-notes.md
│
▼
claude -p (reads file, queries vault state, creates/updates notes)
│
▼
vault/ gets new/updated notes with links
│
▼
intake/processed/2026-03-09-meeting-notes.md (source archived)
What Each File Produces
Each intake file generates:
- One primary dated note — the full content, classified and formatted
- Multiple entity notes — stub or updated notes for people, projects, and ideas extracted from the content
Example: a meeting notes file produces:
Notes/2026-03-09 Call with Alex.md(primary note)People/Alex Johnson.md(stub, taggedmeta/follow-up)- Updated
Projects/SomeProject.md(appended under a dated heading)
The Script
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INTAKE_DIR="$SCRIPT_DIR/intake"
PROCESSED_DIR="$INTAKE_DIR/processed"
PROMPT_FILE="$SCRIPT_DIR/intake-prompt.md"
DATE=$(date +%Y-%m-%d)
# Pre-flight checks
if [[ -n "${CLAUDECODE:-}" ]]; then
echo "Error: Cannot run intake from within a Claude Code session."
exit 1
fi
if ! obsidian files total vault="YOUR_VAULT" &>/dev/null; then
echo "Error: Obsidian is not running or vault is not accessible."
exit 1
fi
if [[ ! -f "$PROMPT_FILE" ]]; then
echo "Error: Intake prompt not found at $PROMPT_FILE"
exit 1
fi
if [[ $# -eq 0 ]]; then
echo "Usage: ./intake.sh <file1.md> [file2.md] ..."
exit 1
fi
mkdir -p "$PROCESSED_DIR"
for file in "$@"; do
if [[ ! -f "$file" ]]; then echo "SKIP: $file does not exist"; continue; fi
if [[ ! "$file" == *.md ]]; then echo "SKIP: $file is not a .md file"; continue; fi
if [[ ! -s "$file" ]]; then echo "SKIP: $file is empty"; continue; fi
filename=$(basename "$file")
echo "Processing: $filename"
if cat "$file" | claude -p \
"Process this intake file into my Obsidian vault. The file is named: $filename" \
--append-system-prompt-file "$PROMPT_FILE" \
--allowedTools "Bash(obsidian *)" \
--max-turns 25 \
--max-budget-usd 1.00 \
--model sonnet \
--no-session-persistence; then
mv "$file" "$PROCESSED_DIR/${DATE}-${filename}"
echo "DONE: $filename -> processed/${DATE}-${filename}"
else
echo "FAIL: $filename (left in intake/)"
fi
done
echo "Intake complete."
The System Prompt (intake-prompt.md)
This is where the intelligence lives. It instructs Claude to:
- Discover vault state first — query existing files and search for entities mentioned in the input before doing anything
- Classify the content — meeting, brain dump, project update, idea, decision, or reference
- Create the primary note — with proper frontmatter, tags from your taxonomy, and wikilinks
- Extract entities — identify people, projects, and distinct ideas
- For each entity:
- Search the vault to see if it already exists
- If it exists: append under a
## Update YYYY-MM-DDheading - If new: create a stub note with
meta/follow-uptag
- Cross-link everything —
relatedproperties and inline[[wikilinks]] - Print a summary of actions taken
The prompt should include your full tag taxonomy, frontmatter templates per note type, naming conventions, and entity resolution rules (always search before creating).
Key Design Decisions
Why claude -p (headless mode)? No interactive session needed — each file is a one-shot operation with a defined output.
Why sequential, not parallel? Files are processed one at a time so vault state stays consistent between files. If two files both mention the same new person, the second file will find the stub note the first file created.
Why --allowedTools "Bash(obsidian *)"? Locks Claude to only the Obsidian CLI and file reads. No arbitrary shell commands.
The CLAUDECODE guard: You can't invoke claude -p from within a Claude Code session (the env var is set). The script checks this upfront and exits with a clear error.
Obsidian must be running: The pipeline uses the Obsidian CLI as the single source of truth for vault writes rather than direct filesystem operations. That's a hard dependency — the script validates it before processing anything.
Edge Cases Worth Handling
- Existing frontmatter — treat as hints, apply vault conventions, preserve extra fields
- Duplicate detection — search before creating; if a very similar note exists, append instead
- Partial failure — created notes persist, source file stays in
intake/for retry - Large files — warn if file exceeds a reasonable size threshold
What It Costs
Each file runs ~$0.05–0.30 with Sonnet depending on length and how many vault queries Claude makes. The --max-budget-usd flag provides a per-file cost cap as a safety net.
The Result
One command turns a pile of unprocessed markdown into a linked, tagged, navigable vault. The value compounds — every entity note created becomes a connection point for future intake files.