MemClaw / docs
Tutorials

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, status, agent — click to filter) so you can slice the corpus instantly.
  • 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.

We build it in small snippets across Steps 3–9, then assemble them into one ui/index.html in Step 10. No framework, no build step.


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_healthy
# Use the SAME compose project as Part 1 (the default, named after the repo dir)
# so the sidecar attaches to the core-api you already have running.
docker compose 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, and two tiny helpers

The whole app is built from nine endpoints. Worth knowing them even if you never build a UI:

WhatCall
StatsGET /api/v1/memories/stats{total, by_type, by_agent, by_status}
BrowseGET /api/v1/memories?tenant_id=default&limit=200{items:[…], next_cursor}
Semantic searchPOST /api/v1/search {tenant_id, query, top_k}{items:[…]} (each with similarity)
GraphGET /api/v1/graph?tenant_id=default{nodes, edges}
AuditGET /api/v1/audit-log
WritePOST /api/v1/memories {tenant_id, agent_id, content, …}
TransitionPATCH /api/v1/memories/{id}/status?tenant_id=default {status}
DeleteDELETE /api/v1/memories/{id}?tenant_id=default (soft)

Two helpers carry the whole app. api() wraps fetch and surfaces MemClaw's error shape; el() builds a DOM node and sets its text via textContent — which is the important bit: memory content is attacker-controlled (any agent can write <img src=x onerror=…>), and textContent renders it as inert text, so building the UI with el() instead of innerHTML strings means no stored-XSS hole anywhere a memory is shown.

const API = '/api/v1';
const TENANT = 'default';
const STATUSES = ['active','pending','confirmed','cancelled','outdated','conflicted','archived','deleted'];
let activeFilter = { type:null, status:null, agent:null };
let allMemories = [];

const $ = s => document.querySelector(s);
const el = (tag, cls, txt) => { const e=document.createElement(tag); if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e; };
function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),2200); }
async function api(path, opts){
  const r = await fetch(API + path, opts);
  if(!r.ok){ let d=''; try{ d=JSON.stringify(await r.json()); }catch{} throw new Error('HTTP '+r.status+' '+d); }
  return r.status===204 ? null : r.json();
}

Step 4 — browse, search, and render the cards

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).

renderCards() builds each card with el() — type badge (color-coded), title, content, author, weight, an 8-state status dropdown, and a delete button. Every field goes through textContent, so nothing a memory contains can inject markup.

function badgeClass(t){ return 'badge b-'+(['fact','decision','insight','outcome','semantic'].includes(t)?t:'default'); }

async function browse(){
  const d = await api(`/memories?tenant_id=${TENANT}&limit=200`);
  allMemories = d.items || []; renderCards();
}
async function search(){
  const q = $('#q').value.trim(); if(!q) return browse();
  const top_k = Math.min(20, Math.max(1, parseInt($('#topk').value)||10));
  const d = await api('/search', { method:'POST', headers:{'Content-Type':'application/json'},
    body: JSON.stringify({ tenant_id:TENANT, query:q, top_k }) });
  allMemories = d.items || []; renderCards(true);
}
function renderCards(showSim){
  const box = $('#cards'); box.innerHTML='';
  const list = allMemories.filter(m =>
    (!activeFilter.type   || m.memory_type===activeFilter.type) &&
    (!activeFilter.status || m.status===activeFilter.status) &&
    (!activeFilter.agent  || m.agent_id===activeFilter.agent));
  if(!list.length){ box.appendChild(el('div','empty','No memories match.')); return; }
  list.forEach(m => {
    const c = el('div','card');
    const row = el('div','row');
    row.appendChild(el('span', badgeClass(m.memory_type), m.memory_type||'memory'));
    row.appendChild(el('span', 'vis '+(m.visibility||''), (m.visibility||'').replace('scope_','👁 ')));
    if(showSim && m.similarity!=null) row.appendChild(el('span','sim','sim '+m.similarity.toFixed(3)));
    c.appendChild(row);
    if(m.title) c.appendChild(el('div','title', m.title));
    c.appendChild(el('div','content', (m.content||'').slice(0,240)));
    const meta = el('div','meta');
    meta.appendChild(el('span', null, '👤 '+(m.agent_id||'?')));
    meta.appendChild(el('span', null, '⭑ '+(m.weight??'–')));
    c.appendChild(meta);
    const act = el('div','actions');
    const sel = el('select'); STATUSES.forEach(s=>{ const o=el('option',null,s); if(s===m.status)o.selected=true; sel.appendChild(o); });
    sel.onchange = async () => { try{ await api(`/memories/${m.id}/status?tenant_id=${TENANT}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({status:sel.value})}); toast('status → '+sel.value); m.status=sel.value; loadStats(); }catch(e){ toast(e.message); } };
    act.appendChild(sel);
    const del = el('button','del','Delete');
    del.onclick = async () => { if(!confirm('Soft-delete this memory?'))return; try{ await api(`/memories/${m.id}?tenant_id=${TENANT}`,{method:'DELETE'}); toast('deleted'); browse(); loadStats(); }catch(e){ toast(e.message); } };
    act.appendChild(del);
    c.appendChild(act);
    box.appendChild(c);
  });
}

Search is the part that sells the whole platform. Type "redis connection pool limits" and the top hit is 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.

Step 5 — the sidebar: stats and click-to-filter

The sidebar reads /memories/stats and turns by_type / by_status / by_agent into clickable chips. Filtering is client-side against the already-loaded set, so toggling a chip is instant:

async function loadStats(){
  const s = await api(`/memories/stats?tenant_id=${TENANT}`);
  $('#statTotal').textContent = s.total ?? 0;
  renderChips('#byType', s.by_type, 'type');
  renderChips('#byStatus', s.by_status, 'status');
  renderChips('#byAgent', s.by_agent, 'agent');
}
function renderChips(sel, obj, key){
  const box = $(sel); box.innerHTML='';
  Object.entries(obj||{}).sort((a,b)=>b[1]-a[1]).forEach(([k,v]) => {
    const c = el('span', 'chip'+(activeFilter[key]===k?' on':''));
    c.appendChild(el('span', null, k+' '));        // el() => textContent => safe even though k can be an agent_id
    c.appendChild(el('b', null, String(v)));
    c.onclick = () => { activeFilter[key] = activeFilter[key]===k?null:k; renderCards(); loadStats(); };
    box.appendChild(c);
  });
}

(Heads-up: agents auto-register on first write, so an agent that has only ever recalled won't appear in by_agent yet.)

Step 6 — write a memory

The form posts to /memories. Two things the API teaches you immediately:

  • agent_id is required — omit it and you get 422. 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 the engine declines it — treat that as success-ish in the UI, not a crash.
async function writeMemory(){
  const agent_id = $('#wAgent').value.trim(), content = $('#wContent').value.trim(), visibility = $('#wVis').value;
  if(!content) return toast('content required');
  try{
    await api('/memories', { method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({ tenant_id:TENANT, agent_id, content, fleet_id:'dev-fleet', visibility }) });
    $('#wContent').value=''; toast('memory written'); setTimeout(()=>{ browse(); loadStats(); }, 400);
  }catch(e){ toast(/dup/i.test(e.message)?'Duplicate — MemClaw deduped it (not written)':e.message); }
}

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

You already wired these into each card in Step 4 — the status <select> and the Delete button. The status enum is MemClaw's 8-stage lifecycle:

active · pending · confirmed · cancelled · outdated · conflicted · archived · deleted

The dropdown PATCHes /memories/{id}/status; Delete is a soft delete (DELETE /memories/{id}) — it sets status=deleted and a deleted_at, drops the row from normal listing and dedup, but 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 force-directed layout is a couple dozen lines of vanilla JS on a <canvas> — and since this is the view you'll keep open, make it live: nodes settle under a continuous force simulation, and you can drag a node (the layout re-flows around it), scroll to zoom, and drag the background to pan.

let graphData={nodes:[],edges:[]}, sim, view={scale:1,ox:0,oy:0}, drag=null, pan=null, alpha=1;
const nodeR = n => 6 + Math.min(7,(n.memory_count||1)*2);
async function loadGraph(){
  const d = await api(`/graph?tenant_id=${TENANT}`);
  graphData.nodes = (d.nodes||[]).map(n=>({...n,x:Math.random()*800+100,y:Math.random()*500+70,vx:0,vy:0,fx:null,fy:null}));
  graphData.edges = (d.edges||[]).map(e=>({source:e.source,target:e.target}));
  $('#graphInfo').textContent = `knowledge graph — ${graphData.nodes.length} entities, ${graphData.edges.length} relations · drag · scroll to zoom · drag background to pan`;
  view={scale:1,ox:0,oy:0}; alpha=1; runSim();
}
function runSim(){
  const cv=$('#graph'), ctx=cv.getContext('2d'); cv.width=cv.clientWidth; const W=cv.width,H=cv.height;
  const idx={}; graphData.nodes.forEach((n,i)=>idx[n.id]=i); cancelAnimationFrame(sim);
  const toWorld=ev=>{const r=cv.getBoundingClientRect(),sx=(ev.clientX-r.left)*(cv.width/r.width),sy=(ev.clientY-r.top)*(cv.height/r.height);return{sx,sy,x:(sx-view.ox)/view.scale,y:(sy-view.oy)/view.scale};};
  const hit=p=>graphData.nodes.find(n=>Math.hypot(n.x-p.x,n.y-p.y)<=nodeR(n)+3);
  cv.onmousedown=ev=>{const p=toWorld(ev),h=hit(p);if(h){drag={n:h};h.fx=h.x;h.fy=h.y;alpha=Math.max(alpha,.5);}else pan={sx:p.sx,sy:p.sy,ox:view.ox,oy:view.oy};};
  cv.onmousemove=ev=>{const p=toWorld(ev);if(drag){drag.n.fx=p.x;drag.n.fy=p.y;alpha=Math.max(alpha,.3);cv.style.cursor='grabbing';}else if(pan){view.ox=pan.ox+(p.sx-pan.sx);view.oy=pan.oy+(p.sy-pan.sy);}else cv.style.cursor=hit(p)?'grab':'default';};
  cv.onwheel=ev=>{ev.preventDefault();const p=toWorld(ev),k=ev.deltaY<0?1.1:1/1.1,ns=Math.max(.2,Math.min(4,view.scale*k));view.ox=p.sx-p.x*ns;view.oy=p.sy-p.y*ns;view.scale=ns;};
  if(!cv._bound){cv._bound=1;window.addEventListener('mouseup',()=>{if(drag){drag.n.fx=null;drag.n.fy=null;}drag=null;pan=null;});}
  function step(){
    const N=graphData.nodes;
    if(alpha>0.02){
      for(let i=0;i<N.length;i++){let a=N[i];for(let j=i+1;j<N.length;j++){let b=N[j],dx=a.x-b.x,dy=a.y-b.y,dd=Math.hypot(dx,dy)||1,f=4200/(dd*dd);a.vx+=dx/dd*f;a.vy+=dy/dd*f;b.vx-=dx/dd*f;b.vy-=dy/dd*f;}a.vx+=(W/2-a.x)*.002;a.vy+=(H/2-a.y)*.002;}
      graphData.edges.forEach(e=>{let a=N[idx[e.source]],b=N[idx[e.target]];if(!a||!b)return;let dx=b.x-a.x,dy=b.y-a.y,dd=Math.hypot(dx,dy)||1,f=(dd-110)*.02;a.vx+=dx/dd*f;a.vy+=dy/dd*f;b.vx-=dx/dd*f;b.vy-=dy/dd*f;});
      N.forEach(n=>{if(n.fx!=null){n.x=n.fx;n.y=n.fy;n.vx=n.vy=0;return;}n.vx*=.85;n.vy*=.85;n.x+=Math.max(-12,Math.min(12,n.vx*alpha));n.y+=Math.max(-12,Math.min(12,n.vy*alpha));});
      alpha*=.985;
    }
    ctx.setTransform(1,0,0,1,0,0);ctx.clearRect(0,0,W,H);ctx.setTransform(view.scale,0,0,view.scale,view.ox,view.oy);
    ctx.strokeStyle='#2b333d';ctx.lineWidth=1/view.scale;
    graphData.edges.forEach(e=>{let a=N[idx[e.source]],b=N[idx[e.target]];if(!a||!b)return;ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke();});
    N.forEach(n=>{ctx.beginPath();ctx.fillStyle=(drag&&drag.n===n)?'#7ee787':'#4cc2ff';ctx.arc(n.x,n.y,nodeR(n),0,7);ctx.fill();ctx.fillStyle='#e6edf3';ctx.font=(12/view.scale)+'px sans-serif';ctx.fillText(n.label||'',n.x+9,n.y+4);});
    sim=requestAnimationFrame(step);
  }
  step();
}

Color nodes by type, size them by memory_count, and you have a readable map of your domain — JWT, Redis, "payments API", linked by stored_in / uses edges back to the memories that are evidence for them. (Part 6 goes deep on how this graph lifts recall.)

Step 9 — the audit log

GET /audit-log is a flat table render — built with el() again, so it's safe by the same rule:

async function loadAudit(){
  const rows = await api(`/audit-log?tenant_id=${TENANT}`);
  const body = $('#auditBody'); body.innerHTML='';
  (rows||[]).forEach(r => {
    const tr = el('tr');
    tr.appendChild(el('td', null, new Date(r.created_at).toLocaleString()));
    tr.appendChild(el('td', null, r.agent_id||'–'));
    tr.appendChild(el('td', 'act', r.action));
    tr.appendChild(el('td', null, (r.resource_type||'')+' '+(r.resource_id||'').slice(0,8)));
    body.appendChild(tr);
  });
}

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.


Step 10 — the page shell, and run it

The functions in Steps 3–9 are the behaviour; they need a page to live in. Here's the shell — the styles, the elements those functions target (#cards, #q, the sidebar chips, #graph, the audit table, the tabs), and the wiring + initial load at the bottom. Paste the functions from Steps 3–9 into the <script> where marked, save the whole thing as ui/index.html (the path Step 2 bind-mounts), and refresh http://localhost:8090:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>MemClaw — Memory Dashboard</title>
<style>
  :root{ --bg:#0e1116; --panel:#161b22; --panel2:#1c232c; --line:#2b333d; --txt:#e6edf3;
         --muted:#8b949e; --accent:#4cc2ff; --accent2:#7ee787; --warn:#f0883e; --bad:#ff7b72; --chip:#21262d; }
  *{box-sizing:border-box}
  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--txt)}
  header{display:flex;align-items:center;gap:12px;padding:12px 18px;background:var(--panel);border-bottom:1px solid var(--line)}
  header h1{font-size:16px;margin:0;font-weight:600;letter-spacing:.3px}
  header .pill{font-size:11px;color:var(--muted);border:1px solid var(--line);border-radius:20px;padding:2px 10px}
  .tabs{margin-left:auto;display:flex;gap:6px}
  .tabs button{background:var(--chip);color:var(--txt);border:1px solid var(--line);padding:6px 14px;border-radius:6px;cursor:pointer}
  .tabs button.active{background:var(--accent);color:#04243a;border-color:var(--accent);font-weight:600}
  .layout{display:grid;grid-template-columns:250px 1fr;min-height:calc(100vh - 53px)}
  aside{background:var(--panel);border-right:1px solid var(--line);padding:16px;overflow:auto}
  aside h3{font-size:11px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin:18px 0 8px} aside h3:first-child{margin-top:0}
  .stat-total{font-size:30px;font-weight:700;color:var(--accent2)}
  .chips{display:flex;flex-wrap:wrap;gap:6px}
  .chip{background:var(--chip);border:1px solid var(--line);border-radius:14px;padding:3px 9px;font-size:12px;cursor:pointer;color:var(--muted)}
  .chip.on{background:var(--accent);color:#04243a;border-color:var(--accent)} .chip b{color:inherit}
  main{padding:18px;overflow:auto}
  .toolbar{display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap}
  input,select,textarea{background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:6px;padding:7px 9px;font:inherit}
  input:focus,textarea:focus,select:focus{outline:1px solid var(--accent)}
  .btn{background:var(--accent);color:#04243a;border:none;border-radius:6px;padding:7px 14px;font-weight:600;cursor:pointer}
  .btn.ghost{background:var(--chip);color:var(--txt);border:1px solid var(--line);font-weight:500}
  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:12px}
  .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:13px;display:flex;flex-direction:column;gap:8px}
  .card .row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .badge{font-size:10px;text-transform:uppercase;letter-spacing:.5px;padding:2px 8px;border-radius:10px;font-weight:700}
  .b-fact{background:#1f6feb33;color:#79c0ff} .b-decision{background:#a371f733;color:#d2a8ff}
  .b-insight{background:#7ee78733;color:#7ee787} .b-outcome{background:#f0883e33;color:#f0883e}
  .b-semantic{background:#56d4dd33;color:#56d4dd} .b-default{background:#8b949e33;color:#c9d1d9}
  .vis{font-size:11px;color:var(--muted)} .vis.scope_agent{color:var(--warn)} .vis.scope_org{color:var(--accent2)}
  .title{font-weight:600} .content{color:#c9d1d9;font-size:13px}
  .meta{font-size:11px;color:var(--muted);display:flex;gap:10px;flex-wrap:wrap;align-items:center} .sim{color:var(--accent2)}
  .card .actions{display:flex;gap:6px;align-items:center;margin-top:2px}
  .del{background:transparent;border:1px solid var(--bad);color:var(--bad);border-radius:6px;padding:4px 8px;cursor:pointer;font-size:12px}
  table{width:100%;border-collapse:collapse;font-size:13px}
  th,td{text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);vertical-align:top}
  th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px} td.act{font-weight:600;color:var(--accent)}
  canvas{background:var(--panel);border:1px solid var(--line);border-radius:10px;width:100%}
  .hidden{display:none}
  .form-row{display:flex;gap:8px;flex-wrap:wrap} .form-row textarea{flex:1;min-width:260px;min-height:48px}
  .empty{color:var(--muted);padding:30px;text-align:center}
  .toast{position:fixed;bottom:18px;right:18px;background:var(--panel2);border:1px solid var(--accent);color:var(--txt);padding:10px 14px;border-radius:8px;opacity:0;transition:.3s;pointer-events:none} .toast.show{opacity:1}
</style>
</head>
<body>
<header>
  <h1>🦅 MemClaw <span style="color:var(--muted);font-weight:400">Memory Dashboard</span></h1>
  <span class="pill">tenant: default</span>
  <div class="tabs">
    <button data-tab="memories" class="active">Memories</button>
    <button data-tab="graph">Graph</button>
    <button data-tab="audit">Audit</button>
  </div>
</header>
<div class="layout">
  <aside>
    <h3>Total</h3><div class="stat-total" id="statTotal">–</div>
    <h3>By type</h3><div class="chips" id="byType"></div>
    <h3>By status</h3><div class="chips" id="byStatus"></div>
    <h3>By agent</h3><div class="chips" id="byAgent"></div>
    <h3>Filter</h3><button class="btn ghost" id="clearFilters" style="width:100%">Clear filters</button>
  </aside>
  <main>
    <section id="tab-memories">
      <div class="toolbar">
        <input id="q" placeholder="Semantic search (e.g. redis rate limit)…" style="flex:1;min-width:240px"/>
        <input id="topk" type="number" min="1" max="20" value="10" title="top_k (max 20)" style="width:90px"/>
        <button class="btn" id="searchBtn">Search</button>
        <button class="btn ghost" id="browseBtn">Browse all</button>
      </div>
      <div class="toolbar" style="background:var(--panel);padding:10px;border-radius:8px;border:1px solid var(--line)">
        <div class="form-row" style="width:100%">
          <input id="wAgent" value="dashboard-user" style="width:180px"/>
          <select id="wVis"><option>scope_team</option><option>scope_agent</option><option>scope_org</option></select>
          <textarea id="wContent" placeholder="New memory content…"></textarea>
          <button class="btn" id="writeBtn">Write</button>
        </div>
      </div>
      <div class="grid" id="cards"></div>
    </section>
    <section id="tab-graph" class="hidden">
      <div class="toolbar"><span class="vis" id="graphInfo">knowledge graph</span></div>
      <canvas id="graph" height="600"></canvas>
    </section>
    <section id="tab-audit" class="hidden">
      <table><thead><tr><th>Time</th><th>Agent</th><th>Action</th><th>Resource</th></tr></thead><tbody id="auditBody"></tbody></table>
    </section>
  </main>
</div>
<div class="toast" id="toast"></div>
<script>
// ── paste here the functions from Steps 3–9 ──
//   helpers (api, el, $, toast + globals), badgeClass, browse, search, renderCards,
//   loadStats, renderChips, writeMemory, loadGraph, loadAudit

// wiring + initial load
$('#searchBtn').onclick = search;
$('#browseBtn').onclick = () => { $('#q').value=''; browse(); };
$('#q').addEventListener('keydown', e => { if(e.key==='Enter') search(); });
$('#writeBtn').onclick = writeMemory;
$('#clearFilters').onclick = () => { activeFilter={type:null,status:null,agent:null}; renderCards(); loadStats(); };
document.querySelectorAll('.tabs button').forEach(b => b.onclick = () => {
  document.querySelectorAll('.tabs button').forEach(x=>x.classList.remove('active')); b.classList.add('active');
  const t=b.dataset.tab; ['memories','graph','audit'].forEach(x=>$('#tab-'+x).classList.toggle('hidden',x!==t));
  if(t==='graph') loadGraph(); if(t==='audit') loadAudit();
});
loadStats(); browse();
</script>
</body>
</html>

With ui/nginx.conf and the compose service from Steps 1–2 alongside it, docker compose up -d ui serves the whole thing at http://localhost:8090 — no framework, no build step.


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.
  • Build the UI with el() + textContent, not innerHTML strings — memory content is attacker-controlled, so string interpolation into innerHTML is a stored-XSS hole. textContent renders it inert.
  • agent_id required on writes — the body schema rejects writes without it (422).
  • top_k > 20422 on REST /search. Cap it client-side (the search() above does).
  • Response key is items, not results.
  • Auth in standalone — REST needs no key, but injecting X-API-Key: standalone at 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_agent memory vanish from another agent's view.
  • Part 4 — The Karpathy Loop: report an outcome and watch a memory's weight move.
  • Part 5 — Hygiene: trigger the crystallizer and contradiction detection, and watch statuses flip to outdated/conflicted.
  • Part 6 — The graph: the view you just built, explained — entity resolution, relations, and how graph hops lift recall.

That's the whole console — the Step-10 shell filled with the functions from Steps 3–9, plus the ui/nginx.conf and compose service from Steps 1–2, all under your caura-memclaw/ui/ directory. 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.