25 KiB
Obsidian RAG Plugin for OpenClaw — Technical Design Document
Date: 2026-04-10 Status: Approved Author: Santhosh Janardhanan
Overview
An OpenClaw plugin enabling semantic search through Obsidian vault notes using RAG (Retrieval-Augmented Generation). The plugin allows OpenClaw to respond to natural language queries about personal journal entries, shopping lists, financial records, health data, podcast notes, and project ideas stored in an Obsidian vault.
Problem Statement
Personal knowledge is fragmented across 677+ markdown files in an Obsidian vault, organized by topic but not searchable by meaning. Questions like "How was my mental health in 2024?" or "How much do I owe Sreenivas?" require reading multiple files across directories and synthesizing the answer. The plugin provides semantic search to surface relevant context.
Architecture
Approach: Separate Indexer Service + Thin Plugin
KnowledgeVault → Python Indexer (CLI) → LanceDB (filesystem)
↑ query
OpenClaw → TS Plugin (tools) ─────────────┘
- Python Indexer: Handles vault scanning, markdown parsing, chunking, embedding generation via Ollama, and LanceDB storage. Runs as a CLI tool.
- TypeScript Plugin: Registers OpenClaw tools that query the pre-built LanceDB index. Thin wrapper that provides the agent interface.
- LanceDB: Embedded vector database stored on local filesystem. Data directory resolved cross-platform via
os.path.expanduser(Linux/macOS:~/.obsidian-rag/, Windows:%APPDATA%/obsidian-rag/). No server required.
Layered Protocol Model
The system is structured in four protocol layers, each with its own contract:
┌─────────────────────────────────────────┐
│ TRANSPORT LAYER │
│ Tool registration, error normalization, │
│ request/response envelope │
├─────────────────────────────────────────┤
│ SESSION LAYER │
│ Health state machine, vault watcher, │
│ auto-sync scheduling │
├─────────────────────────────────────────┤
│ TOOL LAYER │
│ search, index, status, memory_store │
│ with parameter/response schemas │
├─────────────────────────────────────────┤
│ DATA LAYER │
│ LanceDB client, indexer bridge, │
│ chunking pipeline, embedding │
└─────────────────────────────────────────┘
Technology Choices
| Component | Choice | Rationale |
|---|---|---|
| Embedding model | mxbai-embed-large (1024-dim) via Ollama |
Local, free, meets 1024+ dimension requirement, SOTA accuracy |
| Vector store | LanceDB (embedded) | No server, file-based, Rust-based efficiency, zero-copy versioning for incremental updates |
| Indexer language | Python | Richer embedding/ML ecosystem, better markdown parsing libraries |
| Plugin language | TypeScript | Native OpenClaw ecosystem, type safety, SDK examples |
| Config | Separate .obsidian-rag/config.json |
Keeps plugin config separate from OpenClaw config |
Transport Layer
Tool Registration Flow
The plugin follows OpenClaw's standard plugin lifecycle:
- INSTALL — Link plugin to
~/.openclaw/extensions/obsidian-ragor<workspace>/.openclaw/extensions/obsidian-rag. OpenClaw readsopenclaw.plugin.jsonon discovery. - REGISTER — Plugin registers 4 tools with OpenClaw's Tool Registry:
obsidian_rag_search,obsidian_rag_index,obsidian_rag_status,obsidian_rag_memory_store. Each tool declares: name, description, parameterSchema, requiredPermissions. - SERVE — OpenClaw agent calls tools based on user intent.
- HEALTH CHECK —
Plugin.onLoad()probes Ollama, LanceDB, and the vault, then reportshealthy | degraded | unavailable.
Standardized Response Envelope
Every tool response uses the same envelope:
{
"status": "healthy" | "degraded" | "unavailable",
"data": T | null,
"error": {
"code": string,
"message": string,
"recoverable": boolean,
"suggestion": string
} | null,
"meta": {
"query_time_ms": number,
"chunks_scanned": number,
"index_version": string,
"vault_mtime": string
}
}
The three-state health model (healthy, degraded, unavailable) lets the agent adapt behavior:
- healthy: Full service, no caveats.
- degraded: Partial service (e.g., Ollama down — searches still work using pre-computed vector similarity on the query text via LancDB's full-text search fallback, but embedding-based semantic search is unavailable. Results are returned with a staleness warning).
- unavailable: Cannot serve requests (e.g., no index — suggests remediation).
Error Normalization Matrix
| Failure | Status | Code | Recoverable |
|---|---|---|---|
| Ollama down | degraded | OLLAMA_UNREACHABLE |
yes — retry after start |
| Empty vault / no index | unavailable | INDEX_NOT_FOUND |
yes — run index first |
| LanceDB corrupted | unavailable | INDEX_CORRUPTED |
yes — run reindex |
| Path traversal attempt | unavailable | SECURITY_VIOLATION |
no |
| Sensitive content blocked | degraded | SENSITIVE_FILTERED |
yes — with confirmation |
| Indexer CLI crash | unavailable | INDEXER_FAILED |
yes — retry once |
OpenClaw's agent never sees raw exceptions. All errors are normalized with a code, human-readable message, recoverable flag, and remediation suggestion.
Session Layer
Health State Machine
The plugin transitions through three health states:
HEALTHY ⇄ DEGRADED ⇄ UNAVAILABLE
Transitions:
- HEALTHY → DEGRADED: Ollama health check fails (probed every 30s)
- DEGRADED → HEALTHY: Ollama responds again
- HEALTHY → UNAVAILABLE: Index deleted or DB corrupted
- UNAVAILABLE → HEALTHY: Successful reindex after recovery
- DEGRADED → UNAVAILABLE: Index corrupted while Ollama was down
- UNAVAILABLE → DEGRADED: Index restored but Ollama still down
Vault Watcher: Auto-Sync Protocol
The TS plugin runs a chokidar file watcher on the vault path, respecting deny_dirs and allow_dirs configuration.
Detection → Batching → Sync → Result:
- DETECTION: chokidar watches vault_path. Debounces 2s after last change event to avoid triggering sync for every keystroke.
- BATCHING: Collector window of 5s (configurable). Groups add/update/delete events into a changeset — single sync call for multiple files changed at once.
- SYNC TRIGGER: After debounce + collect, spawns indexer with
obsidian-rag sync. Indexer processes only modified files (mtime comparison). On completion, writes status JSON to the data directory (sync-result.json). - SYNC RESULT: Plugin reads
sync-result.jsonon next tool call. Updates health state: HEALTHY if indexed >0 docs, UNAVAILABLE if 0. If last sync is >1h old and vault files changed, next search returnsstatus: "degraded"with a staleness warning — results use LanceDB full-text search fallback instead of semantic embedding.
Session Lifecycle
| Phase | Action |
|---|---|
| ON LOAD | Read config → Probe Ollama → Probe LanceDB → Probe Vault → Set health state |
| REGISTER | Register 4 tools with OpenClaw → Start vault watcher |
| SERVE | Handle tool calls → Auto-sync on file changes → Health re-probe every 30s |
| ON SHUTDOWN | Stop vault watcher → Wait for in-flight sync → Clean exit |
Tool Layer
obsidian_rag_search
Primary semantic search tool for OpenClaw agent.
Parameters:
query(required, string): Natural language questionmax_results(optional, int, default 5, range 1-50): Max chunks to returndirectory_filter(optional, string[]): Limit to subdirectories (e.g.,["Journal"])date_range(optional, object):{ "from": "2025-01-01", "to": "2025-12-31" }tags(optional, string[]): Filter by hashtags
Response data:
{
"results": [
{
"chunk_text": "...",
"score": 0.87,
"source_file": "Journal/2024-01-15.md",
"section": "#mentalhealth",
"date": "2024-01-15",
"tags": ["#mentalhealth", "#therapy"],
"chunk_index": 3
}
],
"sensitive_detected": false
}
When sensitive_detected=true, the agent confirms with the user before displaying. A memory_suggestion object may also be included with a suggested key/value for the agent to offer storage.
obsidian_rag_index
Trigger indexing from within OpenClaw.
Parameters:
mode(required, enum):"full"|"sync"|"reindex"
Response data:
{
"indexed_files": 677,
"total_chunks": 3421,
"duration_ms": 45230,
"errors": []
}
Long-running operation. Returns two responses:
- Immediate response (on spawn):
{ "job_id": "abc-123", "status": "started" }— agent polls progress viaobsidian_rag_statuswhich includesactive_job: { id, mode, progress }. - Completion data (available via
obsidian_rag_statusafter job finishes):{ "indexed_files": 677, "total_chunks": 3421, "duration_ms": 45230, "errors": [] }.
obsidian_rag_status
Check index health — doc count, last sync, unindexed files, Ollama status.
Parameters: (none)
Response data:
{
"plugin_health": "healthy",
"total_docs": 677,
"total_chunks": 3421,
"last_sync": "2026-04-10T14:30:00Z",
"unindexed_files": 3,
"ollama_status": "up",
"active_job": null
}
obsidian_rag_memory_store
Commit important facts to OpenClaw's memory for faster future retrieval.
Parameters:
key(required, string): Identifier for the factvalue(required, string): The fact to remembersource(required, string): Source file path in vault
Response data:
{
"stored": true,
"key": "debt_to_sreenivas"
}
Auto-suggest logic: When search results match financial/health/commitment patterns, the plugin flags sensitive_detected=true and includes a memory_suggestion with a suggested key/value. The agent decides whether to commit.
OpenClaw Interaction Protocol
Intent → Tool mapping:
| User Intent Pattern | Tool | Agent Behavior |
|---|---|---|
| "What did I write about X?" / "How was my Y in Z?" | search | Query vault, synthesize answer from chunks |
| "Index my vault" / "Update the search index" | index | Choose mode (sync default, full on no index), poll status |
| "Is the index up to date?" / "How many docs indexed?" | status | Report health, suggest index if stale |
| "Remember this" / Auto-suggest after search | memory_store | Confirm with user, store key+value+source |
Protocol sequence:
- Agent receives user query with vault-related intent
- Agent calls
obsidian_rag_status(if first interaction or >5min since last check)- Skips if status already cached and healthy
- If unavailable → agent suggests running
obsidian_rag_index(mode: "full") - If degraded → agent warns user but proceeds with search
- Agent calls
obsidian_rag_searchwith appropriate filters - If
sensitive_detected=true→ agent confirms before displaying - Agent synthesizes answer from chunks + existing knowledge
- If
memory_suggestionpresent → agent asks if user wants to store- User confirms →
obsidian_rag_memory_store
- User confirms →
Data Layer
LanceDB Table Schema
Table: obsidian_chunks
| Column | Type | Description |
|---|---|---|
vector |
FixedList float32[1024] | Embedding from mxbai-embed-large via Ollama |
chunk_id |
string | UUID v4, primary key |
chunk_text |
string | Raw markdown text of the chunk |
source_file |
string | Relative path from vault root (e.g., "Journal/2024-01-15.md") |
source_directory |
string | Top-level directory name (e.g., "Journal") |
section |
string | null | Section heading for structured notes (e.g., "#mentalhealth") |
date |
string | null | ISO 8601 date parsed from filename |
tags |
list<string> | All hashtags found in the chunk |
chunk_index |
int32 | Position within source document (0-indexed) |
total_chunks |
int32 | Total chunks for this source file |
modified_at |
string | File mtime, ISO 8601 (for incremental sync) |
indexed_at |
string | When this chunk was indexed, ISO 8601 |
Indexer Bridge: TS ↔ Python
The TypeScript plugin invokes the Python indexer as a subprocess:
Invocation:
child_process.spawn("obsidian-rag", [mode], { env: { ...config } })
Communication:
- stdin: Not used
- stdout: NDJSON progress lines (streamed to agent via status)
- stderr: Error output (logged, surfaced on non-zero exit)
- Exit codes: 0 = success, 1 = partial failure, 2 = fatal
Progress events (stdout NDJSON):
{"type":"progress","phase":"embedding","current":150,"total":677}
{"type":"progress","phase":"storing","current":150,"total":677}
{"type":"complete","indexed_files":677,"total_chunks":3421,"duration_ms":45230,"errors":[]}
Sync result file: Written to {data_dir}/sync-result.json on completion. Read by the plugin on next tool call to update health state. Also serves as the source for status tool responses. Concurrent access: The indexer writes to sync-result.json.tmp then atomically renames to sync-result.json. The plugin reads sync-result.json — the rename ensures the plugin never reads a partially-written file. On Windows, the rename is atomic within the same volume.
Chunking Pipeline
SCAN → PARSE → CHUNK → ENRICH → EMBED → STORE
- SCAN: Walk vault directories (respect deny/allow lists) → collect
*.mdfiles - PARSE: Extract frontmatter, headings, tags, dates from filename/path
- CHUNK:
- Structured notes (Journal entries): Split by section headers (
#mentalhealth,#finance, etc.). Each section becomes its own chunk. - Unstructured notes (shopping lists, project ideas, entertainment index): Sliding window chunking (500 tokens, 100 token overlap).
- Structured notes (Journal entries): Split by section headers (
- ENRICH: Attach metadata:
source_file,section,date,tags,chunk_index - EMBED: Batch chunks → Ollama
/api/embed(mxbai-embed-large, 1024-dim). Batch size: 64 chunks per request. - STORE: Write vectors + metadata to LanceDB (upsert by
chunk_id).
Incremental Sync Logic
- Read
last_sync_mtimefromsync-result.json - Walk vault, compare each file's mtime vs
last_sync_mtime - New/modified files → full chunk pipeline
- Deleted files → remove chunks by
source_filefrom LanceDB - Unchanged files → skip entirely
Security & Privacy
Four Defense Layers
Layer 1 — Path Traversal Prevention (Boundary)
All file reads restricted to vault_path. Reject:
- Any path containing
..components - Absolute paths not under
vault_path - Symlinks resolving outside
vault_path
Implementation: path.resolve(vault_path, input) must start with vault_path.
Layer 2 — Input Sanitization (Clean) All vault content treated as untrusted before embedding:
- Strip HTML tags (prevent XSS in
chunk_text) - Remove executable code blocks (
```...```) - Normalize whitespace (prevent injection via formatting)
- Cap
chunk_textat 2000 chars (prevent oversized payloads)
Layer 3 — Sensitive Content Guard (Gate) Detect and gate sensitive content in search results:
- Health:
#mentalhealth,#physicalhealth, medication, therapy - Financial: owe, owed, debt, paid, $, spent, spend
- Relations:
#Relations(configurable section list)
Action: Set sensitive_detected=true in response. Agent must confirm before displaying to user. Never transmit sensitive content to external APIs.
Layer 4 — Local-Only Enforcement (Network) All data stays on the local machine:
- Ollama: localhost:11434 only
- LanceDB: local filesystem only
- Config:
"local_only": trueenforced - Network audit test: verify zero outbound requests
If an external embedding endpoint is configured: require explicit user confirmation, block if sensitive content detected in payload.
Directory Access Control
Deny list (default): .obsidian, .trash, zzz-Archive, .git, .logseq — always excluded.
Allow list (optional): If set, only these directories are indexed. If empty, all directories except deny list are indexed. Example: ["Journal", "Finance"] → only those two directories.
Filter logic (Python indexer):
- If
allow_dirsis non-empty → only walk those directories - Skip any path matching
deny_dirspatterns - Skip hidden directories (starting with
.)
Filter logic (TS plugin):
directory_filterparameter validates against known directories- Reject unknown directories → prevent vault structure probing
Testing Strategy
Python Unit Tests (pytest)
| Test File | Coverage |
|---|---|
test_chunker.py |
Section splitting, sliding window, metadata extraction |
test_embedder.py |
Mocked Ollama, batch embedding, error handling |
test_vector_store.py |
LanceDB CRUD, upsert, delete by source |
test_security.py |
Path traversal, sanitization, sensitive detection |
test_indexer.py |
Full pipeline, incremental sync, config handling |
TypeScript Unit Tests (vitest)
| Test File | Coverage |
|---|---|
search.test.ts |
Parameter validation, filter logic, response shape |
index.test.ts |
Mode validation, subprocess spawn, progress parsing |
memory.test.ts |
Key/value storage, auto-suggest patterns |
vault-watcher.test.ts |
Chokidar events, debounce, batching |
security-guard.test.ts |
Directory filter validation, sensitive flag |
Integration Tests
| Test File | Coverage |
|---|---|
full_pipeline.test.ts |
Index vault → search → verify results |
sync_cycle.test.ts |
Modify file → auto-sync → search updated content |
health_state.test.ts |
Stop Ollama → degraded → restart → healthy |
openclaw_protocol.test.ts |
Agent calls tools, validates envelope |
Security Test Suite
| Test File | Coverage |
|---|---|
path_traversal.test.ts |
../, symlinks, absolute paths, encoded paths |
xss_prevention.test.ts |
HTML/script injection in chunk text |
prompt_injection.test.ts |
Malicious content in vault notes |
network_audit.test.ts |
Verify zero outbound when local_only=true |
sensitive_content.test.ts |
Pattern detection, flagging, blocking |
Mock vs Real
| Component | Unit Test | Integration Test |
|---|---|---|
| Ollama embedding | Mocked — fixed 1024-dim vectors | Real — requires Ollama running |
| LanceDB | Real — temp directory, cleaned up | Real — temp directory, cleaned up |
| Obsidian vault | Mocked — fixture markdown files | Real — temp vault with real files |
| Python CLI | Mocked subprocess | Real — actual CLI invocation |
| Chokidar watcher | Mocked events | Real — actual filesystem events |
| OpenClaw agent | N/A | Real — tool call envelope validation |
Integration tests require Ollama but skip gracefully if unavailable.
CLI Commands (Python Indexer)
| Command | Purpose |
|---|---|
obsidian-rag index |
Initial full index of the vault (first-time setup) |
obsidian-rag sync |
Incremental — only process files modified since last sync |
obsidian-rag reindex |
Force full reindex (nuke existing, start fresh) |
obsidian-rag status |
Show index health: total docs, last sync time, unindexed files |
Configuration
Config file resolved cross-platform via os.path.expanduser (Linux/macOS: ~/.obsidian-rag/config.json, Windows: %APPDATA%/obsidian-rag/config.json). For development, the config is at ./obsidian-rag/config.json relative to the project root.
{
"vault_path": "./KnowledgeVault/Default",
"embedding": {
"provider": "ollama",
"model": "mxbai-embed-large",
"base_url": "http://localhost:11434",
"dimensions": 1024
},
"vector_store": {
"type": "lancedb",
"path": "./obsidian-rag/vectors.lance"
},
"indexing": {
"chunk_size": 500,
"chunk_overlap": 100,
"file_patterns": ["*.md"],
"deny_dirs": [".obsidian", ".trash", "zzz-Archive", ".git", ".logseq"],
"allow_dirs": []
},
"security": {
"require_confirmation_for": ["health", "financial_debt"],
"sensitive_sections": ["#mentalhealth", "#physicalhealth", "#Relations"],
"local_only": true
},
"memory": {
"auto_suggest": true,
"patterns": {
"financial": ["owe", "owed", "debt", "paid", "$", "spent", "spend"],
"health": ["#mentalhealth", "#physicalhealth", "medication", "therapy"],
"commitments": ["shopping list", "costco", "amazon", "grocery"]
}
}
}
Project Structure
obsidian-rag-skill/
├── README.md
├── LICENSE
├── .gitignore
├── openclaw.plugin.json
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts
│ ├── tools/
│ │ ├── search.ts
│ │ ├── index.ts
│ │ ├── status.ts
│ │ └── memory.ts
│ ├── services/
│ │ ├── vault-watcher.ts
│ │ ├── indexer-bridge.ts
│ │ └── security-guard.ts
│ └── utils/
│ ├── config.ts
│ └── lancedb.ts
├── python/
│ ├── pyproject.toml
│ ├── obsidian_rag/
│ │ ├── __init__.py
│ │ ├── cli.py
│ │ ├── indexer.py
│ │ ├── chunker.py
│ │ ├── embedder.py
│ │ ├── vector_store.py
│ │ ├── security.py
│ │ └── config.py
│ └── tests/
│ ├── test_chunker.py
│ ├── test_security.py
│ ├── test_embedder.py
│ ├── test_vector_store.py
│ └── test_indexer.py
├── tests/
│ ├── tools/
│ │ ├── search.test.ts
│ │ ├── index.test.ts
│ │ └── memory.test.ts
│ ├── services/
│ │ ├── vault-watcher.test.ts
│ │ └── security-guard.test.ts
│ ├── integration/
│ │ ├── full_pipeline.test.ts
│ │ ├── sync_cycle.test.ts
│ │ ├── health_state.test.ts
│ │ └── openclaw_protocol.test.ts
│ └── security/
│ ├── path_traversal.test.ts
│ ├── xss_prevention.test.ts
│ ├── prompt_injection.test.ts
│ ├── network_audit.test.ts
│ └── sensitive_content.test.ts
└── docs/
└── superpowers/
└── specs/
Publishing
Published to ClawHub as both a skill (SKILL.md) and a plugin package:
clawhub skill publish ./skill --slug obsidian-rag --version 1.0.0
clawhub package publish santhosh/obsidian-rag
Install: Link plugin to ~/.openclaw/extensions/obsidian-rag or use openclaw plugins install --link /path/to/obsidian-rag
Windows Development Notes
The development environment is Windows 11. The following platform-specific considerations apply:
Development Vault
The project uses ./KnowledgeVault/Default as the sample Obsidian vault for development. This vault is located at the project root and contains real note directories including Journal, Daily Life Stuff, Podcast Scripts, Side Project, and others.
Path Handling
- All path operations use
path.resolve()andpath.join()with forward slashes for consistency. Node.js and Python both handle forward slashes correctly on Windows. - Config paths (
vault_path,vector_store.path) support relative paths resolved from the project root (e.g.,./KnowledgeVault/Default). - Data directory for production:
%APPDATA%/obsidian-rag/on Windows,~/.obsidian-rag/on Linux/macOS. For development:./obsidian-rag/relative to project root. - Atomic file writes use
.tmp+ rename pattern. On Windows, rename is atomic within the same volume (both temp and target must be on the same drive).
Filesystem Watcher (chokidar)
- chokidar uses
fs.watchon Windows which has reliable recursive watch support. - The 2s debounce and 5s collect window are sufficient for Windows file change events, which may fire multiple events for a single save operation (especially with editors that use atomic save: write to temp file → rename).
Python CLI on Windows
- The
obsidian-ragCLI entry point is installed viapip install -e .frompython/. On Windows, the console script is created in the Python Scripts directory. child_process.spawnon Windows requiresshell: trueif the command is a Python console script, or usepython -m obsidian_rag.clias the command instead.- Recommended: use
python -m obsidian_rag.clias the spawn command for cross-platform compatibility.
Testing on Windows
- Filesystem-based integration tests use
os.tmpdir()for temporary vault and LanceDB directories. - Path traversal tests must include Windows-specific vectors:
C:\Windows\System32,CON,NUL,..\\(backslash variant), UNC paths (\\server\share). - The
deny_dirslist includes.obsidianwhich is the actual Obsidian config directory name on all platforms.