When you first plug a Claude‑powered assistant into a desktop, the conversation vanishes the moment you close the window. The AI becomes a glorified “stateless chatbot,” and every new session starts from zero. That’s fine for a quick demo, but useless once you expect the assistant to remember your past projects, favorite commands, or the outcomes of previous tool calls.
What makes it even worse is that most sample code stops at the CLI level. You end up with a working brain but no way to surface its memory, pause a long‑running plan, or keep the UI responsive while the vector store is searching. The result is a frustrating, half‑baked prototype that can’t survive real‑world usage.
- Local vector stores (Chroma or Qdrant) give Claude persistent, searchable memory.
- A clean separation of conversation memory and application state avoids data leakage.
- PyQt6 (or Tkinter) + QThread/asyncio keeps the UI fluid during API calls.
- Agentic loop follows Plan → Execute → Store → Reflect, with graceful error handling.
- Save/load the whole session (vector DB + tool state) to disk for true continuity.
Before you start: Python 3.12+, Anthropic Claude 3.5‑Sonnet SDK, SentenceTransformers 2.2.2, Chroma 0.5.0 (or Qdrant 1.5.0 via Docker), PyQt6 6.6, aiohttp 3.9, pydantic 2.7, and a valid Anthropic API key. Optional: Docker Desktop for Qdrant, ffmpeg for audio tool extensions.
2026 Memory‑Augmented Agents: Beyond One‑Off Chat Prompts
Memory‑augmented Claude agents use local vector databases like Chroma or Qdrant to store and retrieve past conversations and context. This lets the AI “remember” across sessions. A complete build integrates the Anthropic SDK for the agent, a local vector DB for memory, and a PyQt/Tkinter GUI for interaction, all coordinated by an async Python event loop.
The Problem: Stateless AI Assistants Forget
Claude’s API delivers a fresh stateless model on each request. It can’t retrieve anything you told it yesterday unless you resend that history. In a desktop assistant you typically want the agent to pull up the last “project plan” or the results of a previous tool call without you copy‑pasting logs. Without a memory layer, every new prompt becomes a cold start, and the user ends up re‑teaching the assistant over and over.
The Solution: Agentic Loop + Local Vector Memory
The pattern that works in production today is:
- **Plan** – Claude decides what it needs to do, possibly requesting a tool.
- **Execute** – Your code runs the tool (e.g., filesystem read, web search).
- **Store** – The interaction (prompt, tool output, Claude’s response) is embedded and upserted into a local vector store.
- **Reflect** – Before the next turn, the agent queries the store for the most relevant snippets and prepends them to the prompt.
This loop gives the assistant a “long‑term context” that persists between restarts. The heavy lifting—embedding and similarity search—happens locally, so the latency is predictable and you stay in control of privacy.
Why Chroma/Qdrant? Comparing Local Embedding Stores
| Feature | Chroma (pure‑Python) | Qdrant (Docker / native) |
|---|---|---|
| Setup complexity | `pip install chromadb` – instant | `docker run` – one extra step |
| Payload filtering | Basic metadata only | Rich filtering on any payload field |
| Scaling | Works up to ~10 k records comfortably | Designed for millions, sharding ready |
| Performance | Slightly slower for large vectors | Faster ANN search with HNSW |
If you just want to prototype, Chroma’s zero‑config mode is perfect. When you hit the 20 k‑record mark or need payload‑based pruning, Qdrant becomes attractive. I’ll show both; you can swap the implementation later without touching the agent logic.
Project Architecture & Tech Stack (Production‑Ready)
Core Components: Agent, Memory Index, GUI, Controller
User ↔ GUI (PyQt) ↔ Controller (async/event‑driven) ↔ Claude Agent
↕
Memory Index (Chroma/Qdrant)
- **Agent** – thin wrapper around the Anthropic Python SDK, responsible for plan generation and tool call formatting.
- **Memory Index** – stores `
` tuples. Retrieval returns the top‑k most relevant chunks. - **GUI** – a PyQt6 window showing chat history, a live “memory view”, and controls to pause/resume.
- **Controller** – orchestrates the agentic loop, handles rate‑limits, and emits Qt signals for UI updates.
Python as the Runtime Glue: Claude SDK, AsyncIO, and PyQt
Claude’s SDK is fully async. Pair it with Python’s `asyncio` event loop and run that loop in a dedicated `QThread`. The GUI thread stays free to repaint, handle mouse clicks, and display a spinner while the vector DB is searching.
State Management: Separating Conversation Memory from Application State
Conversation memory lives in the vector DB. Application state (open file handles, cached tool results, UI preferences) lives in a Pydantic model that can be serialized to JSON. By keeping them separate you avoid accidental “leakage” of sensitive tool state into the knowledge base.
graph TD
A[User Input] --> B[GUI Thread]
B --> C[Controller Queue]
C --> D[Async Event Loop]
D --> E[Claude Agent]
D --> F[Vector Store]
E --> G[Tool Executor]
G --> H[Result ↔ Vector Store]
H --> I[Memory Retrieval]
I --> E
E --> J[Response]
J --> B
Step 1: Setting Up Local Vector DBs with Python (Chroma/Qdrant)
Installing and Initializing Chroma in Persistence Mode
# Bash – run once
pip install "chromadb==0.5.0" "sentence-transformers==2.2.2"
# chroma_setup.py – Python 3.12
import chromadb
from chromadb.config import Settings
from sentence_transformers import SentenceTransformer
# Initialize persistent client (stores data in ./chroma_data)
client = chromadb.PersistentClient(
path="./chroma_data",
settings=Settings(anonymized_telemetry=False)
)
collection = client.get_or_create_collection(
name="claude_memory",
metadata={"hnsw:space": "cosine"}
)
# Load a fast, open‑source embedding model
embedder = SentenceTransformer(
"intfloat/multilingual-e5-large", # works for English + many languages
device="cpu" # switch to "cuda" if you have a GPU
)
**Robustness tip:** wrap the client creation in a `try/except` and fall back to an in‑memory client if the directory is unreadable. That prevents the whole app from crashing on a corrupted DB.
try:
client = chromadb.PersistentClient(...)
except Exception as exc:
print(f"⚠️ Chroma init failed: {exc}. Falling back to in‑memory store.")
client = chromadb.InMemoryClient()
Qdrant Local Setup: Docker vs. Pure‑Python Client
If you prefer Qdrant’s filtering, spin it up locally:
docker run -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:v1.5.0
pip install "qdrant-client==1.5.0" "sentence-transformers==2.2.2"
# qdrant_setup.py
from qdrant_client import QdrantClient
from qdrant_client.http.models import Distance, VectorParams
from sentence_transformers import SentenceTransformer
client = QdrantClient(":6333") # localhost default
collection_name = "claude_memory"
# Create collection if it doesn't exist
if collection_name not in client.get_collections().collections:
client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)
embedder = SentenceTransformer(
"intfloat/multilingual-e5-large",
device="cpu"
)
**Why both?** The code abstractions below use a tiny `MemoryStore` interface, so you can drop‑in either backend without touching the agent.
Defining Schemas: Storing Conversations, Tool Outputs, and User Context
Both Chroma and Qdrant accept arbitrary metadata. We’ll store:
- `type` – `”utterance”`, `”tool_result”`, `”system_note”`
- `timestamp` – ISO‑8601 string for chronological pruning
- `session_id` – UUID for multi‑session isolation
- `source_id` – optional link to a file or tool identifier
from datetime import datetime
import uuid
def make_metadata(kind: str, session_id: str, source_id: str | None = None) -> dict:
return {
"type": kind,
"timestamp": datetime.utcnow().isoformat(),
"session_id": session_id,
"source_id": source_id or "",
}
Step 2: Building the Core Claude Agent with Memory Retrieval
Anthropic SDK 2026: Configuring the Agent with Tools
pip install "anthropic[bedrock]==0.8.0"
# claude_agent.py – Python 3.12
import asyncio
import os
from anthropic import Anthropic, AsyncClient
from pydantic import BaseModel
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
client = AsyncClient(api_key=ANTHROPIC_API_KEY, timeout=60)
class ToolSpec(BaseModel):
name: str
description: str
input_schema: dict
# Example: a simple file‑read tool
FILE_READ = ToolSpec(
name="file_read",
description="Read a text file from the local filesystem.",
input_schema={"type": "object", "properties": {"path": {"type": "string"}}},
)
The SDK expects a list of `tool_definitions`. Claude can invoke them by name during the plan step.
TOOLS = [FILE_READ.dict()] # convert Pydantic to plain dict
The Memory Module: Embedding, Upserting, and Similarity Search
# memory_store.py – unified interface
from typing import List, Tuple
class MemoryStore:
async def upsert(self, ids: List[str], texts: List[str], metadatas: List[dict]) -> None:
raise NotImplementedError
async def query(self, query_text: str, k: int = 5) -> List[Tuple[str, dict]]:
raise NotImplementedError
Chroma Implementation
# chroma_memory.py
import uuid
import asyncio
from .memory_store import MemoryStore
class ChromaMemory(MemoryStore):
def __init__(self, collection):
self.collection = collection
async def upsert(self, ids, texts, metadatas):
# Chroma’s API is sync; run in a thread pool
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: self.collection.upsert(
ids=ids,
documents=texts,
metadatas=metadatas,
embeddings=[embedder.encode(t).tolist() for t in texts],
),
)
async def query(self, query_text: str, k: int = 5):
embedding = embedder.encode(query_text).tolist()
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: self.collection.query(
query_embeddings=[embedding],
n_results=k,
include=["documents", "metadatas", "distances"],
),
)
hits = []
for doc, meta in zip(result["documents"][0], result["metadatas"][0]):
hits.append((doc, meta))
return hits
Qdrant Implementation
# qdrant_memory.py
import uuid
import asyncio
from qdrant_client import QdrantClient
from .memory_store import MemoryStore
class QdrantMemory(MemoryStore):
def __init__(self, client, collection_name):
self.client = client
self.collection = collection_name
async def upsert(self, ids, texts, metadatas):
vectors = [embedder.encode(t).tolist() for t in texts]
await asyncio.to_thread(
self.client.upsert,
collection_name=self.collection,
points=[
{
"id": uid,
"vector": vec,
"payload": meta,
}
for uid, vec, meta in zip(ids, vectors, metadatas)
],
)
async def query(self, query_text: str, k: int = 5):
vector = embedder.encode(query_text).tolist()
response = await asyncio.to_thread(
self.client.search,
collection_name=self.collection,
query_vector=vector,
limit=k,
with_payload=True,
)
return [(hit.payload.get("text", ""), hit.payload) for hit in response]
**My take:** I start with Chroma for rapid iteration; once the index hits 30 k items I switch to Qdrant because its payload filters let me prune by `session_id` without re‑scanning the whole collection.
Orchestrating the Agentic Loop: Plan → Execute → Store → Reflect
# controller.py
import asyncio
import uuid
from typing import Any, Dict
class ClaudeController:
def __init__(self, client: AsyncClient, memory: MemoryStore):
self.client = client
self.memory = memory
self.session_id = str(uuid.uuid4())
async def _retrieve_context(self, user_msg: str) -> str:
hits = await self.memory.query(user_msg, k=4)
# Concatenate top snippets; keep under token budget
context = "\n---\n".join([doc for doc, _ in hits])
return context
async def process_user_message(self, user_msg: str) -> str:
# 1️⃣ Retrieve relevant memory
context = await self._retrieve_context(user_msg)
# 2️⃣ Build the prompt with retrieved context
system_prompt = (
"You are a helpful desktop assistant. "
"Use the retrieved context to answer, and you may call tools when needed.\n"
f"Context:\n{context}"
)
# 3️⃣ Call Claude with tool specs
try:
response = await self.client.messages.create(
model="claude-3-5-sonnet-202406",
max_tokens=1024,
temperature=0.0,
system=system_prompt,
messages=[{"role": "user", "content": user_msg}],
tools=TOOLS,
)
except Exception as exc:
# Graceful fallback
return f"❌ Claude error: {exc}"
# 4️⃣ If Claude wants to call a tool, dispatch it
if response.tool_calls:
tool_output = await self._handle_tool_calls(response.tool_calls)
# Store tool output as memory, then let Claude reflect
await self._store_interaction(
kind="tool_result",
content=tool_output,
source_id=response.tool_calls[0].name,
)
# Recursively ask Claude to incorporate the result
follow_up = await self.process_user_message(user_msg + "\nTool result: " + tool_output)
return follow_up
# 5️⃣ Normal text response → store and return
await self._store_interaction(
kind="utterance",
content=response.content[0].text,
)
return response.content[0].text
async def _handle_tool_calls(self, calls: list[Dict[str, Any]]) -> str:
# Currently we support only file_read; expand as needed
call = calls[0] # single call for simplicity