When Claude starts rummaging through your Obsidian vault, the last thing you want is a frozen chat pane while the agent is busy reading dozens of markdown files. I hit that exact wall a month ago—my Electron‑based prototype stalled the UI every time a tool call hit the file system. The fix was to hand‑off the heavy lifting to a truly async Python service and stitch it to a native Tauri front‑end via a non‑blocking bridge. The result? A snappy desktop app where Claude streams its responses in real‑time and you can edit notes simultaneously without stepping on each other’s toes.
Below is a battle‑tested, end‑to‑end guide that takes you from a blank Tauri project to a production‑ready Claude‑Obsidian agent. You’ll see the exact async plumbing, conflict‑resolution tricks, and packaging steps that keep the UI alive and the vault consistent.
- Use Tauri 2.x + a Python 3.11 async service for the Claude agent.
- Wrap the Anthropic SDK with exponential‑backoff retries.
- Expose tool calls (read/write/search/summarize) as async functions reachable via Tauri commands.
- Stream Claude’s JSON‑chunks to the UI through a Tokio‑based WebSocket bridge.
- Synchronize note edits with file‑system watchers and resolve conflicts with a simple version hash.
Before you start: Install Python 3.11+, Rust 1.73+, Node 20 (for Tauri), the Anthropic Python SDK v0.14+, and obtain a Claude 3.5 Sonnet API key. You’ll also need an existing Obsidian vault on your machine and the Tauri 2.x CLI (`cargo install tauri-cli`).
Integrating Claude AI Agent with Obsidian: a standalone GUI
To integrate Claude AI with Obsidian, you build a standalone desktop app (using Tauri or PyQt) that hosts the Claude agent. The app uses the Anthropic SDK for tool calls (read/write/search notes) and connects to your Obsidian vault via the file system, enabling AI‑powered PKM with a custom GUI and robust state management outside Obsidian’s plugin sandbox.
System architecture & workflow
The app is split into three logical layers:
- **Frontend (Rust‑compiled WebView)** – renders HTML/JS UI, holds UI state with Zustand, and subscribes to a WebSocket for streaming responses.
- **Bridge (Tauri commands + Tokio WebSocket)** – marshals messages between the Rust UI thread and the Python agent, ensuring non‑blocking I/O.
- **Backend service (Python 3.11 asyncio)** – runs the Claude client, implements the four PKM tools, and talks to the filesystem.
flowchart LR
UI[UI (HTML/JS)] -->|invoke| TauriCmd[Tauri Commands]
TauriCmd -->|push| WS[Tokio WebSocket]
WS -->|receive| PySrv[Python Async Agent]
PySrv -->|tool call| Vault[Obsidian Vault]
PySrv -->|stream| WS
WS -->|push| TauriCmd
TauriCmd -->|update| UI
*My take:* Using a WebSocket instead of Tauri’s `invoke`‑callback loop eliminates the “callback hell” that previously locked the UI when the agent performed a long‑running file read.
1️⃣ Scaffold the Tauri‑Python project
# Create a fresh Tauri project (Rust + Web assets)
cargo create-tauri obsidian-claude
cd obsidian-claude
# Enable the Python bridge (we’ll call it from Rust)
mkdir -p src-tauri/python
Add the following to `src-tauri/Cargo.toml` (trimmed for brevity):
[package]
name = "obsidian_claude"
version = "0.1.0"
edition = "2021"
[dependencies]
tauri = { version = "2.0", features = ["api-all"] }
tokio = { version = "1.38", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Create a minimal Rust command that spawns the Python process and opens a Tokio‑based WebSocket:
// src-tauri/src/main.rs
//! Rust side – version 1.73+, Tauri 2.x
use tauri::{Manager, Window};
use tokio::process::Command;
use tokio::net::TcpListener;
use serde_json::json;
use std::sync::Arc;
#[tauri::command]
async fn start_agent(window: Window) -> Result<(), String> {
// Launch Python async service
let python_path = "./src-tauri/python/agent.py";
Command::new("python")
.arg(python_path)
.spawn()
.map_err(|e| format!("Failed to start Python agent: {}", e))?;
// Open a local TCP listener for streaming messages
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("Listener error: {}", e))?;
let addr = listener.local_addr().unwrap();
// Tell the frontend where to connect
window
.emit("agent-ready", json!({ "wsUrl": format!("ws://{}", addr) }))
.map_err(|e| e.to_string())?;
// Accept the first incoming connection (Python will connect)
let (mut socket, _) = listener.accept().await.map_err(|e| e.to_string())?;
// Forward incoming JSON chunks to the UI
while let Ok(msg) = socket.readable().await {
let mut buf = vec![0u8; 4096];
match socket.try_read(&mut buf) {
Ok(0) => break, // connection closed
Ok(n) => {
let payload = String::from_utf8_lossy(&buf[..n]);
let _ = window.emit("agent-stream", payload);
}
Err(e) => {
eprintln!("socket read error: {}", e);
break;
}
}
}
Ok(())
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![start_agent])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
**⚠️ Warning:** The snippet above uses `try_read` inside an async loop. If you see `WouldBlock` errors, make sure the Python side flushes after each JSON line (see later).
2️⃣ Build the async Claude client in Python
# src-tauri/python/agent.py
# Python 3.11+, Anthropic SDK v0.14
import os
import json
import asyncio
import websockets
from pathlib import Path
from anthropic import Anthropic, AsyncClient
from anthropic.types import Message, ToolResult
API_KEY = os.getenv("ANTHROPIC_API_KEY")
VAULT_PATH = Path(os.getenv("OBSIDIAN_VAULT", "~/ObsidianVault")).expanduser()
# ---------- retry decorator ----------
def retry_async(backoff=1.0, max_tries=5):
async def wrapper(fn, *args, **kwargs):
attempt = 0
while attempt < max_tries:
try:
return await fn(*args, **kwargs)
except Exception as exc:
attempt += 1
jitter = (0.5 + asyncio.random.random()) * backoff
await asyncio.sleep(jitter)
if attempt == max_tries:
raise exc
return wrapper
client = AsyncClient(api_key=API_KEY, timeout=120.0)
# ---------- PKM tool implementations ----------
async def read_note(path: str) -> str:
note_path = VAULT_PATH / path
if not note_path.is_file():
return f"⚠️ Note {path} not found."
async with aiofiles.open(note_path, "r") as f:
return await f.read()
async def write_note(path: str, content: str) -> str:
note_path = VAULT_PATH / path
note_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(note_path, "w") as f:
await f.write(content)
return f"✅ Written {path}."
async def search_vault(query: str) -> str:
# Very naive grep – replace with ripgrep for production
results = []
for md in VAULT_PATH.rglob("*.md"):
async with aiofiles.open(md, "r") as f:
text = await f.read()
if query.lower() in text.lower():
results.append(str(md.relative_to(VAULT_PATH)))
return "\n".join(results) or "🔍 No matches."
async def summarize(path: str) -> str:
note = await read_note(path)
resp = await client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=200,
system="You are a concise summarizer.",
messages=[Message(role="user", content=note)],
)
return resp.content[0].text
# ---------- Claude tool schema ----------
TOOLS = [
{
"name": "read_note",
"description": "Read a markdown file from the vault.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
},
{
"name": "write_note",
"description": "Create or overwrite a markdown file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]},
},
{
"name": "search_vault",
"description": "Full‑text search across all markdown files.",
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]},
},
{
"name": "summarize",
"description": "Summarize a note, returning a short paragraph.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
},
]
SYSTEM_PROMPT = """You are Claude, a personal knowledge‑management assistant embedded in Obsidian.
You have access to four tools: read_note, write_note, search_vault, summarize.
When a tool is needed, call it with exact arguments and wait for the result before responding to the user.
All edits must preserve existing front‑matter and be idempotent."""
# ---------- main event loop ----------
async def handle_message(ws, user_input: str):
# Stream Claude's response chunk‑by‑chunk
async for chunk in client.messages.stream(
model="claude-3-5-sonnet-20240620",
max_tokens=1024,
system=SYSTEM_PROMPT,
messages=[Message(role="user", content=user_input)],
tools=TOOLS,
):
# If Claude wants to use a tool, the SDK returns a "tool_use" block
if chunk.type == "tool_use":
tool_name = chunk.name
args = json.loads(chunk.input)
result = await dispatch_tool(tool_name, args)
# Feed the result back into the conversation
await client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=0,
system=SYSTEM_PROMPT,
messages=[
Message(role="assistant", content=chunk.content),
Message(role="tool_result", content=result, tool_use_id=chunk.id),
],
)
else:
await ws.send(json.dumps({"type": "delta", "text": chunk.text}))
async def dispatch_tool(name: str, args: dict) -> str:
if name == "read_note":
return await read_note(**args)
if name == "write_note":
return await write_note(**args)
if name == "search_vault":
return await search_vault(**args)
if name == "summarize":
return await summarize(**args)
return f"⚠️ Unknown tool {name}"
@retry_async()
async def start_server():
# Expose a WebSocket on an OS‑assigned port; Rust side will discover it via stdout
async with websockets.serve(lambda ws, _: websocket_handler(ws), "127.0.0.1", 0) as server:
port = server.sockets[0].getsockname()[1]
print(f"WS_PORT={port}", flush=True) # Tauri reads this line
await asyncio.Future() # run forever
async def websocket_handler(ws):
async for message in ws:
payload = json.loads(message)
if payload.get("type") == "user":
await handle_message(ws, payload["content"])
if __name__ == "__main__":
import aiofiles, sys
asyncio.run(start_server())
**Explanation of key patterns**
- **Retry decorator** implements exponential backoff with jitter (see FAQ for rate‑limit handling).
- **Tool dispatch** is a single `dispatch_tool` function; adding new tools is just a new entry in `TOOLS` and a branch here.
- **Streaming** uses `client.messages.stream` which yields delta objects. Each delta is forwarded to the WebSocket as JSON; the UI renders them live.
3️⃣ Connect the UI – Zustand state & streaming display
Create `src/index.html` (the default Tauri web asset) and a tiny Svelte‑free bundle using vanilla JS:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Claude + Obsidian</title>
<style>
body{font-family:system-ui;margin:0;display:flex;flex-direction:column;height:100vh;}
#chat{flex:1;overflow:auto;padding:1rem;background:#fafafa;}
#input{display:flex;padding:0.5rem;border-top:1px solid #ddd;}
textarea{flex:1;margin-right:0.5rem;}
</style>
</head>
<body>
<div id="chat"></div>
<div id="input">
<textarea id="msg" rows="2" placeholder="Ask Claude…"></textarea>
<button id="send">Send</button>
</div>
<script type="module">
import { create } from 'https://cdn.jsdelivr.net/npm/zustand@4.4.0/+esm';
const { getState, setState