Visual web UI for exploring Claude's vector memory system with semantic search, knowledge graphs, and real-time analytics
Claude's memory system stores episodic memories, learnings, trajectories, procedures, and benchmarks across multiple Qdrant collections and Supabase tables. While the MCP tools (memory_recall, memory_store, procedure, etc.) provide programmatic access, there's no visual interface for:
The memory system is mission-critical for agent context continuity, but without visibility into what's stored, how it's connected, and when it was learned, operators are flying blind.
memory_recall with guessed keywords to find anythingA localhost web UI that provides visual access to the entire memory system. Built with FastAPI (Python 3.12), HTMX for reactive updates, Chart.js for analytics, and D3.js for knowledge graph visualization. Docker-deployed for portability and isolation.
Key capabilities:
The dashboard is a three-tier web application with clean separation of concerns:
| Layer | Technology | Responsibility |
|---|---|---|
| Frontend | HTMX + Chart.js + D3.js | Reactive UI, charts, knowledge graph, pagination |
| Backend | FastAPI (Python 3.12) | API routes, Qdrant queries, Supabase access, embedding generation |
| Data | Qdrant + Supabase | Vector memories (8 collections), relational contacts/decisions |
Data Flow:
/api/memories, /api/search, etc.)The memory system uses 8 Qdrant collections, each with 768-dimensional embeddings (Ollama nomic-embed-text):
claude_memories # Generic memories (type: learning, decision, contact, etc.)
learnings # Extracted learnings from multi-step successes
trajectories # Action sequences with outcomes
procedures # Reusable step-by-step processes
episodes # Conversation contexts and summaries
memory_links # Relationships between memories (source_id → target_id)
audit_log # Governance decisions and approval gates
benchmarks # Performance baselines and test results
Common Metadata Fields:
type: learning | trajectory | procedure | episode | contact | decision | benchmarkproject: Project identifier (e.g., "governance-plugin", "memory-system")tags: Array of keywords for filteringtimestamp: ISO 8601 creation timesensitivity: public | internal | confidential | restrictedrecall_count: Number of times recalled (incremented by auto-recall hook)127.0.0.1:8092 and is NOT exposed to external networks. No authentication layer needed since access requires local shell access.
Defense-in-Depth Protections:
supabase-py SDKhttp://localhost:8092/)Landing page with high-level overview cards and charts:
Refresh Behavior: Dashboard auto-refreshes every 30 seconds via HTMX polling to reflect new memories added by active agent sessions.
/memories)Paginated table view with advanced filtering:
/search)Natural language search across all collections using vector similarity:
http://localhost:11434/api/embeddings) with model nomic-embed-textSearch Settings:
# Example API call
POST /api/search
{
"query": "git workflow automation procedures",
"collections": ["procedures", "learnings", "trajectories"],
"score_threshold": 0.70,
"max_results": 20,
"max_sensitivity": "internal"
}
/graph)D3.js force-directed graph visualization of memory relationships:
memory_links collection, weighted by link_strength (0.0-1.0)/contacts)Specialized view for contact memories stored in Supabase contacts table:
***@domain masking. Click "Reveal" button to show full email (logged to audit_log).
/decisions)Audit log viewer for governance decisions from audit_log collection:
/analytics)Deep-dive charts and statistics:
127.0.0.1:8092 only (no external network exposure).
QDRANT_URL (default: http://localhost:6333) with connection pooling.
SUPABASE_URL and SUPABASE_KEY with SDK initialization at app startup.
nomic-embed-text model at OLLAMA_URL (default: http://localhost:11434) with 10-second timeout.
asyncio.gather().
hx-trigger="every 30s" on stats container.
<mark> tags with yellow background.
abc***@domain.com with "Reveal" button that logs to audit_log.
GET /health MUST return JSON with status: ok, Qdrant connection status, Supabase connection status, and Ollama availability.
GET /api/stats MUST return collection counts, type distribution, top 10 projects, last 90 days timeline, and sensitivity breakdown.
GET /api/memories MUST accept query params: type, project, tags, after, before, sensitivity, page, limit.
POST /api/search MUST accept JSON body with query, collections, score_threshold, max_results, max_sensitivity.
GET /api/graph-data MUST accept query params: types, project, min_link_strength, max_nodes.
GET /api/contacts MUST query Supabase contacts table with filters and return masked email addresses.
GET /api/decisions MUST query audit_log collection with filters: event_type, after, before, operator, outcome.
POST /api/contacts/:id/reveal-email MUST return full email and log action to audit_log with event_type=email_reveal.
GET /api/export/decisions MUST return CSV file with all decision records matching filters.
http://localhost:8092 only.
/tmp.
dashboard, redis (for scroll context cache).
Build a Memory Dashboard web UI for Claude's vector memory system.
## Tech Stack
- Backend: FastAPI (Python 3.12+), async/await handlers
- Frontend: HTMX (reactive updates), Chart.js (analytics), D3.js (knowledge graph)
- Data: Qdrant (8 collections), Supabase (contacts table)
- Deployment: Docker, localhost-only (127.0.0.1:8092)
## Qdrant Collections (768-dim nomic-embed-text embeddings)
1. claude_memories (generic memories with type field)
2. learnings (extracted learnings)
3. trajectories (action sequences)
4. procedures (reusable processes)
5. episodes (conversation contexts)
6. memory_links (relationships: source_id → target_id, link_strength)
7. audit_log (governance decisions)
8. benchmarks (performance baselines)
## Common Metadata Schema
- type: learning | trajectory | procedure | episode | contact | decision | benchmark
- project: string (project identifier)
- tags: array of strings
- timestamp: ISO 8601
- sensitivity: public | internal | confidential | restricted
- recall_count: integer (incremented on recall)
## Features
### 1. Dashboard (/)
- Collection count cards (8 collections)
- Type distribution pie chart
- Top 10 projects bar chart
- Last 90 days timeline (line chart)
- Sensitivity breakdown donut chart
- Recent 10 memories table
- Auto-refresh every 30s (HTMX polling)
### 2. Memory Browser (/memories)
- Paginated table (50 per page, infinite scroll)
- Filters: type dropdown, project autocomplete, tags multi-select, date range, sensitivity radio
- Columns: Type badge, Content preview (80 chars), Project, Tags, Created, Recall Count, Sensitivity
- Click row: Expand full content + metadata JSON
- "View in Graph" button: Jump to knowledge graph centered on memory
- Qdrant scroll API with Redis-cached scroll contexts (5-min TTL)
### 3. Semantic Search (/search)
- Natural language query input
- Query → Ollama embedding (nomic-embed-text) → Qdrant vector search
- Settings: collections filter (checkboxes), score threshold slider (0.0-1.0), max results (50 default, 200 max), max sensitivity dropdown
- Results: Cards with content, similarity score, type, project, tags, "Why matched" snippet highlighting
- Sort: similarity score desc, then recall_count desc
### 4. Knowledge Graph (/graph)
- D3.js force-directed graph (max 500 nodes)
- Nodes: Colored by type (learnings=blue #60a5fa, trajectories=green #2dd4bf, procedures=purple #a78bfa, episodes=orange #fb923c)
- Edges: From memory_links, weighted by link_strength (0.0-1.0)
- Interactions: Hover tooltip, click to highlight neighbors, drag to reposition, zoom/pan
- Filters: Type checkboxes, project dropdown, link strength slider
- Layout modes: Force-directed (default), Hierarchical (Dagre), Radial (concentric by age)
### 5. Contacts View (/contacts)
- Query Supabase contacts table
- Columns: Name, Organization, Role, Email (masked: abc***@domain.com), Last Contact, Tags, Notes Preview
- Filters: Organization, Role, Tags, Date Range
- Click row: Full details + memory timeline (all memories mentioning contact)
- "Reveal" email button: Show full email, log to audit_log (event_type: email_reveal)
### 6. Decisions View (/decisions)
- Query audit_log collection
- Columns: Timestamp, Event Type, Decision, Context, Operator, Outcome
- Event types: human_gate, data_classification_gate, jurisdiction_check, approval_override, memory_redaction
- Filters: Event Type, Date Range, Operator, Outcome (approved/rejected/deferred)
- CSV export button
### 7. Analytics (/analytics)
- Daily memory creation line chart (6-month view)
- Type distribution stacked bar chart (per project)
- Sensitivity heatmap (type vs sensitivity, cells=count)
- Recall frequency histogram
- Tag cloud (sized by usage count)
- Embedding quality scatter plot (norm vs recall_count)
## API Endpoints
GET /health
→ {status, qdrant_ok, supabase_ok, ollama_ok}
GET /api/stats
→ {collection_counts, type_distribution, top_projects, timeline_90d, sensitivity_breakdown}
GET /api/memories?type=&project=&tags=&after=&before=&sensitivity=&page=&limit=
→ {memories: [...], total, page, has_more}
POST /api/search
{query, collections, score_threshold, max_results, max_sensitivity}
→ {results: [{memory, score, snippet}, ...]}
GET /api/graph-data?types=&project=&min_link_strength=&max_nodes=
→ {nodes: [{id, type, project, recall_count, ...}], edges: [{source, target, strength, ...}]}
GET /api/contacts?org=&role=&tags=&after=&before=
→ {contacts: [...], total}
POST /api/contacts/:id/reveal-email
→ {email} + audit_log entry
GET /api/decisions?event_type=&after=&before=&operator=&outcome=
→ {decisions: [...], total}
GET /api/export/decisions (CSV download)
## Security
- Bind to 127.0.0.1:8092 only (no external exposure)
- Jinja2 autoescaping (XSS prevention)
- Parameterized Supabase queries (SQL injection prevention)
- SRI hashes on Chart.js, D3.js, HTMX CDN scripts
- CORS restricted to localhost:8092
- Rate limiting: 60 req/min default, 10 req/min for search
- No sensitive content in logs
- Docker: non-root user, read-only filesystem except /tmp
## Environment Variables
QDRANT_URL=http://localhost:6333
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_KEY=your-anon-key
OLLAMA_URL=http://localhost:11434
REDIS_URL=redis://localhost:6379
## Docker Compose
services:
dashboard:
build: .
ports: [127.0.0.1:8092:8092]
environment: [QDRANT_URL, SUPABASE_URL, SUPABASE_KEY, OLLAMA_URL, REDIS_URL]
healthcheck: [GET /health, 30s interval]
redis:
image: redis:7-alpine
ports: [6379:6379]
## Deliverables
1. FastAPI app with all 7 views + 9 API endpoints
2. HTMX templates with dark glassmorphism styling
3. Chart.js configurations for 6+ chart types
4. D3.js force graph with 3 layout modes
5. Dockerfile (Python 3.12 slim, multi-stage)
6. docker-compose.yml
7. README with setup, API docs, screenshots
## Design Requirements
- Dark theme (#0f0f0f background, #f5f5f5 text)
- Glassmorphism cards (rgba(255,255,255,0.03) bg, 0.08 border)
- Gradient accents (#2dd4bf → #60a5fa)
- JetBrains Mono for code/IDs
- Responsive layout (mobile-friendly)
- Loading skeletons during HTMX requests
- Error toasts for failed API calls
Build this as a production-ready localhost dashboard for memory system visibility and debugging.
Decision: Use HTMX for frontend reactivity instead of a JavaScript framework.
Rationale:
Trade-offs: HTMX is less suitable for highly interactive UIs with complex client-side logic. Knowledge graph uses D3.js directly for this reason.
Decision: Cache Qdrant scroll contexts in Redis with 5-minute TTL.
Rationale:
Alternative Considered: Store scroll context in session cookies. Rejected due to cookie size limits (4KB) and inability to share contexts across browser tabs.
Decision: Bind to 127.0.0.1:8092 with no authentication layer.
Rationale:
Future Extension: If remote access needed, deploy behind Tailscale/NetBird ZTNA mesh with mTLS, not password auth.
Decision: Use Ollama's nomic-embed-text model for search embeddings.
Rationale:
nomic-embed-text for memory storage embeddingsTrade-off: nomic-embed-text is 768-dim vs OpenAI's 1536-dim, potentially lower search quality. Acceptable for localhost debugging tool.
Decision: Use D3.js force-directed graph instead of Cytoscape.js.
Rationale:
Alternative Considered: Cytoscape.js has better default styling and graph analysis algorithms, but overkill for visualization-only use case.
Decision: Hard cap knowledge graph at 500 nodes, sorted by recall_count desc.
Rationale:
recall_count) surface in top 500Future Extension: Implement WebGL rendering with force-graph library for 10K+ node support.
Decision: Mask contact emails by default, require "Reveal" button click that logs to audit_log.
Rationale:
Implementation: Masking uses pattern first3chars***@domain on client side, full email stored in Supabase.
Connection: HTTP client via qdrant-client Python library.
Operations:
scroll(collection_name, limit=50, with_payload=True, with_vectors=False)search(collection_name, query_vector=embedding, limit=50, score_threshold=0.65)count(collection_name) for stats dashboardretrieve(collection_name, ids=[...]) for graph node details# Example scroll query with filters
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
client = QdrantClient(url=os.getenv("QDRANT_URL"))
response = client.scroll(
collection_name="claude_memories",
limit=50,
with_payload=True,
with_vectors=False,
scroll_filter=Filter(
must=[
FieldCondition(key="type", match=MatchValue(value="learning")),
FieldCondition(key="project", match=MatchValue(value="governance-plugin"))
]
)
)
memories = [point.payload for point in response[0]]
next_offset = response[1] # Cache in Redis
Connection: supabase-py SDK with API key authentication.
Schema:
CREATE TABLE contacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
organization TEXT,
role TEXT,
email TEXT,
last_contact TIMESTAMP,
tags TEXT[],
notes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
Query Example:
from supabase import create_client
supabase = create_client(
os.getenv("SUPABASE_URL"),
os.getenv("SUPABASE_KEY")
)
response = supabase.table("contacts")\
.select("*")\
.eq("organization", "Acme Corp")\
.gte("last_contact", "2026-01-01")\
.order("last_contact", desc=True)\
.execute()
contacts = response.data
Endpoint: POST http://localhost:11434/api/embeddings
Request/Response:
# Request
{
"model": "nomic-embed-text",
"prompt": "Docker networking issues on Ubuntu"
}
# Response
{
"embedding": [0.123, -0.456, 0.789, ...] # 768-dim array
}
Error Handling: 10-second timeout, retry once on connection error, fallback to empty results if Ollama unavailable.
Connection: redis-py async client.
Key Pattern: scroll:<collection>:<filters_hash>:<page>
import redis.asyncio as redis
import hashlib
import json
async def cache_scroll_context(collection, filters, page, offset):
r = redis.from_url(os.getenv("REDIS_URL"))
filters_hash = hashlib.md5(json.dumps(filters, sort_keys=True).encode()).hexdigest()
key = f"scroll:{collection}:{filters_hash}:{page}"
await r.setex(key, 300, offset) # 5-minute TTL
async def get_scroll_context(collection, filters, page):
r = redis.from_url(os.getenv("REDIS_URL"))
filters_hash = hashlib.md5(json.dumps(filters, sort_keys=True).encode()).hexdigest()
key = f"scroll:{collection}:{filters_hash}:{page}"
return await r.get(key)
Integration: Dashboard writes to audit_log collection when sensitive actions occur.
Events Logged:
email_reveal: User clicked "Reveal" button on masked contact emailexport_decisions: User downloaded CSV export of audit decisionssearch_high_sensitivity: Semantic search included restricted/confidential memories# Log email reveal
await qdrant_client.upsert(
collection_name="audit_log",
points=[{
"id": str(uuid.uuid4()),
"vector": [0.0] * 768, # Dummy vector
"payload": {
"event_type": "email_reveal",
"timestamp": datetime.utcnow().isoformat(),
"operator": "dashboard_user",
"context": {"contact_id": contact_id, "email": email},
"outcome": "approved"
}
}]
)
Parallel Access: Dashboard and MCP tools (memory_recall, memory_store) access the same Qdrant collections.
Consistency Guarantees:
Schema Compatibility: Dashboard expects standard metadata fields (type, project, tags, timestamp, sensitivity, recall_count). Gracefully handles missing fields with defaults.