The Memory Dashboard: a browsable window into your fleet's mind
See, search, and govern your fleet's shared memory — from one HTML file and a reverse proxy.
In Part 1 we gave three Claude Code agents one shared, governed memory. They write decisions, recall each other's gotchas, and compound knowledge across sessions. It works.
But there's a problem you hit about ten minutes in: you can't see any of it. The memory is doing its job inside Postgres and a vector index, and your only window is curl. You can't answer simple questions — What does the fleet know? Who wrote that? Why does the docs agent believe X? What got superseded? — without hand-writing API calls.
So in this part we build a memory dashboard: a single-page app that browses every memory, runs semantic search, visualizes the knowledge graph, shows the audit trail, and lets you write and govern memories by hand. No framework, no build step — one index.html and a tiny nginx config, added to the same Docker stack from Part 1.
Everything here talks to the standard MemClaw REST API (
/api/v1/...). If you're on the managed platform or a different client, only the base URL and key change.
What we're building
A two-pane app served at http://localhost:8090:
- Browse & semantic search — every memory as a card, or paraphrase a query and get ranked hits with similarity scores.
- Sidebar — live stats (total, by type, by status — click to filter) and registered agents with their trust tiers.
- Write — a form that creates a memory (MemClaw enriches and dedups it).
- Govern — per-memory lifecycle transitions and soft-delete.
- Graph — a force-directed view of the entities and relations MemClaw extracted.
- Audit — the provenance chain of every write, transition, and delete.
It's ~350 lines of vanilla HTML/JS. The interesting engineering is one decision, so let's start there.
The one decision that matters: don't fight CORS, proxy around it
A browser app calling http://localhost:8000/api/v1/... from a page served somewhere else is a cross-origin request. You'd need MemClaw's CORS_ORIGINS to list your UI's origin, and the custom X-API-Key header triggers a preflight OPTIONS on every call. It's fiddly, and it leaks your API key into browser-visible JavaScript.
The clean fix: serve the SPA and the API from the same origin. Put an nginx in front that serves the static file and reverse-proxies /api/ to core-api. The browser only ever talks to nginx — no CORS, ever — and nginx injects the API key on the way through, so the key never touches the client.
┌─────────────┐ same origin (http://localhost:8090)
│ Browser │
│ index.html │──┐
└─────────────┘ │ GET / → static index.html
│ GET /api/v1/memories │
▼ ▼ proxy_pass + X-API-Key
┌──────────────┐ ┌──────────────────┐
│ nginx │────────▶│ core-api │
│ (ui sidecar)│ │ (MemClaw REST) │
└──────────────┘ └──────────────────┘Step 1 — the nginx config
ui/nginx.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Static SPA — fall back to index.html for client routing.
location / {
try_files $uri $uri/ /index.html;
}
# MemClaw REST API. proxy_pass with no path preserves the full /api/... URI.
# The standalone key is injected here, so the browser never handles auth.
location /api/ {
proxy_pass http://core-api:8000;
proxy_set_header Host $host;
proxy_set_header X-API-Key standalone;
proxy_read_timeout 60s;
}
}Two things to note: proxy_pass http://core-api:8000 without a trailing path preserves the incoming URI, so /api/v1/search lands at core-api:8000/api/v1/search. And core-api resolves by service name because the sidecar shares the compose network.
Step 2 — add the sidecar to the stack
In docker-compose.yml:
ui:
image: nginx:1.27-alpine
ports:
- "${UI_PORT:-8090}:80"
volumes:
- ./ui/index.html:/usr/share/nginx/html/index.html:ro
- ./ui/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
core-api:
condition: service_healthydocker compose -p medium-fleet up -d ui
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8090/ # 200
curl -s http://localhost:8090/api/v1/memories/stats
# {"total":4,"by_type":{"decision":1,"fact":1,"insight":1,"outcome":1},...}Because index.html is bind-mounted, editing it updates the live app on refresh — no rebuild while you iterate.
Step 3 — the REST surface the dashboard rides on
The whole app is built from nine endpoints. Worth knowing them even if you never build a UI:
| What | Call |
|---|---|
| Stats | GET /api/v1/memories/stats → {total, by_type, by_agent, by_status} |
| Browse | GET /api/v1/memories?tenant_id=default&limit=200 → {items:[…], next_cursor} |
| Semantic search | POST /api/v1/search {tenant_id, query, top_k} → {items:[…]} (each with similarity) |
| Agents | GET /api/v1/agents → trust tiers |
| Graph | GET /api/v1/graph?tenant_id=default → {nodes, edges} |
| Audit | GET /api/v1/audit-log |
| Write | POST /api/v1/memories {tenant_id, agent_id, content, …} |
| Transition | PATCH /api/v1/memories/{id}/status?tenant_id=default {status} |
| Delete | DELETE /api/v1/memories/{id}?tenant_id=default (soft) |
A fetch helper that surfaces MemClaw's error shape is all the plumbing you need:
const API = '/api/v1';
async function api(path, opts){
const r = await fetch(API + path, opts);
let body = null; try { body = await r.json(); } catch(e){}
if(!r.ok){
const m = (body && (body.detail || body.error?.message)) || ('HTTP ' + r.status);
throw new Error(typeof m === 'string' ? m : JSON.stringify(m));
}
return body;
}Step 4 — browse and semantic search
Browse is a plain list; search is the same render path with a similarity badge. The only rule to remember: REST /search caps top_k at 20 (it returns 422 above that — the MCP memclaw_recall tool has no such cap).
async function loadMemories(){
const data = await api('/memories?tenant_id=default&limit=200');
state.items = data.items || [];
render();
}
async function runSearch(){
const q = document.getElementById('q').value.trim();
if(!q) return loadMemories();
const data = await api('/search', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ tenant_id:'default', query:q, top_k:20 })
});
state.items = data.items || [];
render();
}Search is the part that sells the whole platform. Type "redis connection pool limits" and the top hit is "Redis pool size cap (10) can starve under load" — a memory whose text never says "limits." That's the hybrid retrieval (vector + keyword + graph) doing its job; the dashboard just shows the similarity it returns.
Each memory renders as a card: a color-coded type badge, title, author agent_id, visibility scope, a weight bar, status, tags, and an expandable body with the full content, the LLM summary, and (for insights) the recommendation.
Step 5 — stats and filters
The sidebar reads /memories/stats and turns by_type / by_status into clickable chips. Filtering is done client-side against the already-loaded set, so toggling a chip is instant:
function passesFilter(m){
if(state.agent && m.agent_id !== state.agent) return false;
if(state.type && m.memory_type !== state.type) return false;
if(state.status && m.status !== state.status) return false;
return true;
}/agents fills both the trust-tier list and the agent filter dropdown. (Heads-up: agents auto-register on first write, so an agent that has only ever recalled won't appear yet.)
Step 6 — write a memory
The form posts to /memories. Two things the API teaches you immediately:
agent_idis required — omit it and you get422. Every memory is authored by an identity; that's what makes governance possible.- Writes are deduplicated. Submit something semantically identical to an existing memory and you get
409. Treat that as success-ish in the UI, not a crash:
try {
const m = await api('/memories', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ tenant_id:'default', agent_id, content, fleet_id, visibility })
});
toast('Written: ' + m.memory_type + ' · ' + m.title);
} catch(e){
toast(/duplicate/i.test(e.message)
? 'Duplicate — MemClaw deduped it (not written)'
: 'Write failed: ' + e.message, true);
}You send raw text and a visibility (scope_team by default); MemClaw returns it enriched with an inferred memory_type, title, summary, tags, and extracted entities.
Step 7 — govern: transitions and soft-delete
Every card's detail panel gets a status dropdown and a delete button. The status enum is MemClaw's 8-stage lifecycle:
active · pending · confirmed · cancelled · outdated · conflicted · archived · deletedasync function transition(id){
const status = document.getElementById('st_' + id).value;
await api(`/memories/${id}/status?tenant_id=default`, {
method:'PATCH', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ status })
});
toast('Status → ' + status);
refresh();
}
async function del(id){
if(!confirm('Soft-delete this memory?')) return;
await api(`/memories/${id}?tenant_id=default`, { method:'DELETE' });
refresh();
}Delete is a soft delete — it sets status=deleted and a deleted_at, and the row drops out of normal listing and dedup. The audit log still has it. (Note: extracted entities outlive a deleted memory; there's no REST entity-delete in the OSS build today.)
Step 8 — the knowledge graph
MemClaw extracts entities and relations on every write. GET /graph returns {nodes, edges}:
{ "nodes": [ { "id":"…", "label":"jwt", "type":"technology", "memory_count":1 } ],
"edges": [ { "source":"…", "target":"…", "relation_type":"stored_in", "weight":1.0 } ] }A real force-directed layout is ~20 lines of vanilla JS: seed nodes on a circle, then run a couple hundred ticks of pairwise repulsion + edge attraction + gentle center gravity, and draw <line>/<circle>/<text> into an SVG.
for(let it=0; it<220; it++){
// repel every pair
for(let i=0;i<n;i++) for(let j=i+1;j<n;j++){
let dx=N[i].x-N[j].x, dy=N[i].y-N[j].y, d2=dx*dx+dy*dy+0.01, d=Math.sqrt(d2), f=2600/d2;
N[i].x+=f*dx/d; N[i].y+=f*dy/d; N[j].x-=f*dx/d; N[j].y-=f*dy/d;
}
// pull along edges
edges.forEach(e=>{ const a=idx[e.source], b=idx[e.target]; if(!a||!b) return;
let dx=b.x-a.x, dy=b.y-a.y, d=Math.hypot(dx,dy)+0.01, f=(d-90)*0.02;
a.x+=f*dx/d; a.y+=f*dy/d; b.x-=f*dx/d; b.y-=f*dy/d; });
// gravity to center
N.forEach(nd=>{ nd.x+=(W/2-nd.x)*0.012; nd.y+=(H/2-nd.y)*0.012; });
}Color nodes by entity type, size them by memory_count, put the relation_type in an edge <title> for hover, and you have a readable map of your domain. Our seeded fleet renders ~10 entities and ~8 relations — JWT, Redis, "user session tokens", "logout-everywhere", all linked by stored_in / mentions edges back to the memories that are evidence for them.
Step 9 — the audit log
GET /audit-log is a flat table render: time, action, agent, scope, memory id. This is the screen you open when someone asks "why does the fleet believe X?" — every agent_registered, create, entity_extraction, transition, and delete is there with its actor.
Gotchas we hit (so you don't)
- CORS — solved structurally by the same-origin proxy; don't bother allow-listing origins for a local tool.
agent_idrequired on writes — the body schema rejects writes without it (422).top_k > 20→422on REST/search. Cap it client-side.- Response key is
items, notresults. - Auth in standalone — REST needs no key, but injecting
X-API-Key: standaloneat the proxy is harmless and future-proofs you for a gated deployment.
Where this goes next
You now have eyes on the fleet. That matters for everything that follows, because the advanced features are things you want to watch happen:
- Part 3 — Governance & keystones: scopes, trust tiers, and mandatory policies. Watch a
scope_agentmemory vanish from another agent's view. - Part 4 — The Karpathy Loop: report an outcome and watch a memory's weight bar move (we've already seen 0.9 → 1.0).
- Part 5 — Hygiene: trigger the crystallizer and contradiction detection, and watch statuses flip to
outdated/conflictedin real time. - Part 6 — The graph: the view you just built, explained — entity resolution, relations, and how graph hops lift recall.
The full source is in stack/ui/ — one index.html, one nginx.conf, one compose service. Fork it, point it at your own MemClaw, and you've got an admin console for your fleet's memory.
caura-memclaw · Apache 2.0 — the whole engine is open source: storage layer, 12 MCP tools, OpenClaw plugin, audit trail. ⭐ Star on GitHub · Join Discord · memclaw.net
You can't govern — or trust — a memory you can't see. Now you can see all of it.
Building a Multi-Agent Fleet with MemClaw and Claude Code
Give three agents one shared, governed brain — in about 30 minutes, with zero custom code.
Governed memory: scopes, trust tiers & keystone policies
Who sees what, who can change the fleet's knowledge, and what every agent must obey — visibility scopes, trust tiers, and keystone policies.