# Build a Living Mind for my AI agent

I already have an AI agent with a **memory** — things it has learned and stored — plus, probably, some **sub-agents**, a **tool inventory**, and a history of **conversations**. Right now all of that is invisible: rows in a database, files on disk, JSON in a log. I want you to turn it into a **living, navigable 3D map of my agent's mind** — a full-screen scene I can orbit, search, click into, and *watch fire in real time* as my agent thinks.

This is a description of the experience and the engineering approach, not a code listing. Use your own judgment for the exact implementation, but match the look, the structure, and the specific techniques described here closely — the difference between "a force-directed graph" and "something that looks like a brain scan of a living mind" is almost entirely in the details below, and I want the second one.

**The reference build uses [three.js]([https://threejs.org](https://threejs.org)) r0.174** loaded from a CDN via an **import map** — no bundler, no build step, no npm install. Everything else is either a stock three.js addon or a small hand-written GLSL shader. The whole front end is about **1,800 lines across five ES modules**, and it runs at 60fps with a few hundred nodes.

**The single most important rule: never draw anything that isn't true.** This is a map of my agent's actual mind, and its entire value is that I can trust it. If there's no data for a region, that region is empty and the stats line says so. If a live event names a node that doesn't exist, the animation is silently dropped rather than faked. No placeholder nodes, no demo pulses, no invented edges. A pretty lie here is worse than a blank screen — I'd stop believing the parts that *are* real.

**Work tier by tier.** Each tier ends with something visible on screen and a verification step. Don't start a tier until the previous one renders correctly, and don't collapse tiers together — when the memory web looks like a hairball or the pulses fly through empty space, we need to know exactly which layer to look at.

---

## Tier 0 — Interview me first (don't write code yet)

Before touching any files, ask me these questions and wait for my answers. Keep it to one round and group them so I can answer quickly. If I skip one, use the default in parentheses — and where the default says "infer from the project," go look.

**About the agent and the stack**

1. What's my agent's **name**, and what's its **accent color**? (The name labels the central star; the accent is the color it glows. Default: infer the name from the project; **teal `#2DD4A8`** for the accent.)

2. What's my **web stack** — a Python server (FastAPI/Flask), Node, something else — and is the frontend plain HTML or a framework (React/Vue/Svelte)? (Default: **plain HTML with ES modules and no build step**, served as one more route on the server my agent already runs. If I name a framework, mount the scene as one component and keep every rendering technique below identical — do *not* rebuild this with react-three-fiber unless I ask.)

3. Should this be a **dedicated full-page route** (like `/mind`) or a panel inside an existing dashboard? (Default: **a dedicated full-page route** — it needs the whole viewport, and it's much easier to keep it from fighting the rest of the UI.)

**About the memory — this is the heart of it**

4. Where do my agent's **long-term memories** live — files on disk, SQLite, Postgres, a vector DB? Roughly **how many** are there? (Default: infer from the project. Design for the count you find; if it's over a few hundred, cap the node population and tell me the cap.)

5. Do I store **embeddings** for those memories anywhere — a `.npz`, a vector column, a FAISS/Chroma index? (**This is the single highest-value question.** Real embeddings mean the memory web is wired by genuine semantic similarity, which is what makes it feel like a mind instead of a chart. Default: **go look for an embedding index.** If you find one, use it. If you don't, tell me plainly, offer to compute embeddings once from the memory text, and if I decline, fall back to tag/type co-occurrence edges — and label them honestly as such rather than passing them off as semantic.)

6. Does each memory carry a **type/category**, a **source**, and a **created-at timestamp**? (Used for the badge in the inspector and for *freshness* — recent memories burn brighter than old ones. Default: use whatever exists; if there's no timestamp, make every node equally bright rather than inventing ages.)

**About the rest of the mind**

7. Do I have **sub-agents**? For each: an id, a display name, a one-line specialty, an accent color, and optionally an **avatar image**. (Default: read them from wherever they're registered. If I have none, drop that whole region — don't invent agents.)

8. Do my sub-agents have a known set of **tools** each? And does my main agent have a **tool registry** I can enumerate, ideally with a **category** per tool? (Default: enumerate the registry if one exists; group by category if categories exist, otherwise by name prefix.)

9. Do I have a **conversation/session history**, and a set of **always-loaded knowledge files** (a system prompt, a manifest, docs the agent reads every turn)? (Default: include recent conversations as a small "working memory" cluster and the always-loaded files as a "knowledge" cluster; skip either if it doesn't exist.)

**About making it live**

10. How could a page learn, **in real time**, that my agent just did something — recalled a memory, wrote a memory, dispatched a sub-agent, finished a turn? Is there an existing WebSocket, an SSE stream, an event bus, or nothing yet? (Default: **build a new, separate, read-only WebSocket endpoint** just for observers. Critical: it must never touch the socket my agent's main UI or voice pipeline uses — see the warning in Tier 6. If there's no event source at all, build the scene fully alive on its idle animations and leave clearly marked hooks for me to call later.)

11. Roughly **how many nodes** do you expect in total, and is this ever going to run on a **laptop on battery** or a phone? (Default: assume a laptop, target 60fps, and build the FPS governor in Tier 8 regardless.)

Once I've answered, restate the plan back to me in four or five lines — which regions will exist, whether the memory web gets real semantic edges, and what makes it live — then read my project **read-only** (where memories live, what shape they are, what's registered, what event plumbing exists) and report what you actually found before you begin Tier 1.

---

## The big picture (read once, then build incrementally)

The mental model: **my agent's mind is a solar system, and it has regions.** At the center is the agent itself — a breathing star with prompt rings around it. Around it, at fixed anchor points in 3D space, sit five clusters, each with its own color, its own layout algorithm, and its own kind of connection back to the center:

| Region | What it is | Color | Layout |

|---|---|---|---|

| **Core** | The agent itself, plus its prompt blocks as orbiting rings | teal `#2DD4A8` | origin |

| **Memory** | Long-term memories, wired to each other by semantic similarity | violet `#A78BFA` | force-directed web |

| **Working** | Recent conversations / threads | cyan `#67E8F9` | small ring |

| **Agents** | Sub-agents, each with its tools as moons | rose `#E88FB3` | vertical arc |

| **Knowledge** | Always-loaded files feeding the system prompt | amber `#F5A524` | flat grid |

| **Rim** | The whole tool inventory + integrations | slate `#8B93A1` | clustered sphere |

Everything is wrapped in a faint spherical **membrane** — the boundary of the mind — with a starfield beyond it.

**Two hard architectural rules, decided up front:**

**One — the backend is a pure assembly function, and every source is isolated.** One endpoint returns a "skeleton": a flat list of nodes and a flat list of edges, plus a stats block. Each region is built inside its own `try`, so a broken memory index or a failed database query empties *that region only* and records `"memory": "error"` in the stats, instead of returning a 500 and giving me a black page. Write this as a plain function taking explicit arguments (the memory store, the conversation repo, the tool registry) — not reaching into globals — so it's testable without spinning up the server. Detail for a single node loads **lazily** from a second endpoint when I click it; never ship every memory body in the skeleton.

**Two — the frontend is five small ES modules with one direction of dependency.** `data.js` (fetching + the live socket), `regions.js` (pure math: where does everything go), `nodes.js` (meshes and shaders), `edges.js` (curves and pulses), `scene.js` (assembly and the frame loop), plus an `inspector.js` for all the interaction. This split is not decoration: the layout math has no three.js scene dependency and can be reasoned about on its own, and the shaders stay in one place instead of smeared across a 2,000-line file.

Build the data contract first (Tier 1), then a scene you can orbit (Tier 2), then make the nodes beautiful (Tier 3), then place them (Tier 4), wire them (Tier 5), bring them to life (Tier 6), make them navigable (Tier 7), and keep it fast (Tier 8).

---

## Tier 1 — The data contract (backend only, nothing renders yet)

**Goal:** two JSON endpoints, verified with `curl`, before any 3D exists.

Build `GET /api/mind-map` returning:

```

{ regions: [...], nodes: [...], edges: [...], stats: { memory_total, memory_shown, sources } }

```

Every **node** is flat and self-describing: `id` (namespaced — `mem:<uuid>`, `agent:<slug>`, `tool:<name>`, `know:<path>`, `thread:<id>`), `type`, `region`, `label`, `color`, `size`, `freshness` (0–1), and an `extra` bag for type-specific fields. Every **edge** is `{ source, target, kind, weight }`. Keep node ids namespaced by prefix — the whole frontend routes on that prefix, and it makes the lazy-detail endpoint a simple `partition(":")`.

**The similarity edges are the piece that matters.** Take the embedding vectors for the memories you're showing, normalize them, and compute the full cosine similarity matrix `unit @ unit.T` in numpy — it's O(n²) and completely trivial at a few hundred memories; don't reach for an approximate index). Fill the diagonal with −1 so nothing matches itself. For each memory, link it to its **top 3 neighbors above a 0.35 threshold**, canonicalize each pair by sorting the endpoints, and dedupe. Those two numbers are load-bearing: top-3 keeps the web readable instead of a hairball, and 0.35 is roughly where "these are actually about the same thing" starts. Make both constants, and tell me what they are so I can tune them.

Then compute a **degree** count per memory from those edges and use it two ways: node `size` grows with degree (well-connected memories are physically bigger), and the **three highest-degree memories get "recall" trunk edges drawn from the core**, so the memory web visibly *hangs off* the agent instead of floating unattached. If the web is sparse or the index is missing, pad those trunks with the freshest memories — there should always be trunks, because recall genuinely always flows core-ward.

**Freshness** is exponential decay on age: `0.5 ** (age_days / 30)`, floored at 0.15 so nothing goes fully black. This single number is what will make the memory web look like a real mind later — a scatter of bright recent thoughts among dim old ones — so get it right here.

Also build `GET /api/mind-map/node/<id>` returning the full detail for one node: for a memory, its body, type, source, timestamp, **and its nearest neighbors by a live similarity query** (this is what makes the inspector feel like it's thinking). For a knowledge file, a text preview — and **only for paths in the manifest**, never an arbitrary path, or you've built a directory traversal. Return 404 for unknown ids.

**Verify:** `curl` both endpoints. Check that node counts per region match what actually exists, that similarity edges are present and plausible (spot-check two connected memories — are they genuinely related?), and that killing one source (rename the embedding index) empties one region and reports `"error"` in stats rather than failing the whole request.

---

## Tier 2 — A scene I can orbit

**Goal:** an empty but atmospheric universe — starfield, membrane, fog, bloom — that I can drag around. No agent data yet.

Serve one HTML page with a full-bleed `<canvas>`, a `<div>` overlay for labels, and an **import map** pointing `three` and `three/addons/` at a pinned three.js version on a CDN. Pin the exact version; don't use `latest`.

Set up: `WebGLRenderer` with antialias and `setPixelRatio(Math.min(devicePixelRatio, 2))` (uncapped on a 3× display is a pointless third of your frame budget), `ACESFilmicToneMapping` with exposure just above 1, a very dark background `#05070B`), and `FogExp2` at ~0.012 so distant regions genuinely recede. A `PerspectiveCamera` at 55° starting back and slightly above the origin. `OrbitControls` with damping, gentle `autoRotate`, and min/max distance clamps so I can't fly out of my own mind.

Then the three things that make it feel like space rather than a graph viewer:

- **Bloom.** Route rendering through an `EffectComposer` with a `RenderPass` and an *`UnrealBloomPass`** (start around strength 1.15, radius 0.6, threshold 0.72). This is the single highest-leverage line in the entire build — it's what turns bright pixels into *light*. Everything downstream is tuned assuming bloom is on.

- **A starfield.** ~600 points scattered on a shell far outside the scene (radius 80–160), drawn as `THREE.Points` with a tiny custom shader: each star carries a random `phase` and `size` attribute, the vertex shader sets `gl_PointSize` proportional to `size / -mv.z` so distance works, and the fragment shader discards outside a radius and applies a soft falloff. Twinkle brightness on a sine of `uTime + phase` in a range that lets the peaks cross the bloom threshold — that's what makes them *sparkle* rather than pulse.

- **The membrane.** A huge back-side sphere (radius ~26) with a fresnel rim shader at very low opacity (~0.06), additively blended, depth-write off. **Important gotcha:** the usual `pow(1.0 - max(dot(normal, viewDir), 0.0), 3.0)` fresnel reads as a solid disk here, because `BackSide` flips the normals and the `max` clamps to zero everywhere. Use `abs(dot(...))` instead so only the silhouette lights up, from inside and outside alike.

Add a `resize` listener that updates the camera aspect, the renderer, *and* the composer.

**Verify:** a dark, foggy, star-flecked void with a faint glowing boundary sphere, that I can orbit smoothly at 60fps.

---

## Tier 3 — The node look (this is where "amazing" comes from)

**Goal:** one glowing sphere on screen that looks like a bead of light, then hundreds of them at one draw call.

Every node is a **reverse-fresnel glow sphere**, and this specific recipe is what gives the whole map its look. A custom `ShaderMaterial` where the fragment shader computes `facing = max(dot(normal, viewDir), 0.0)`, then:

- `core = pow(facing, 2.5)` — a hot center

- `rim = pow(1.0 - facing, 2.0)` — a colored edge

- color = the node color **mixed toward white** by `core  0.85`*,* *plus** the node color times `rim * 1.4`

- alpha = `(core  0.95 + rim  0.6) * opacity`

Transparent, `depthWrite: false`. The result is a white-hot core bleeding into a colored halo — and once bloom is on, it reads as an actual light source rather than a shaded ball. Add a second, larger `BackSide` sphere with a pure-rim shader and additive blending as an outer halo.

**Then make it scale.** Hundreds of individual meshes will tank the frame rate, so the small-node population (memories, tools, threads, knowledge files) goes into **one `InstancedMesh` per region** — per region, not one global, so the legend can toggle a whole region by flipping one group's visibility. Use a `SphereGeometry(1, 24, 16)`; an icosahedron at low detail reads as visibly faceted. Raycasting stays cheap because `InstancedMesh` does a bounding-sphere test per instance before touching any triangles.

Two per-instance attributes carry the life:

- *`aPhase`** — a deterministic per-node value hashed from the node id (FNV-1a over the characters works fine). Drives a **breathing** cycle in the vertex shader: scale the vertex position by `1.0 + 0.06  sin(uTime  (3.5 + 2.5  fract(phase  0.7)) + phase)`. Because the frequency *and* offset vary per node, the field shimmers organically instead of pulsing in unison. Use the id hash, not `Math.random()`, so a reload doesn't reshuffle everything.

- *`aFreshness`** — from the backend. Multiply the output color by `0.45 + 0.75  freshness` *and let it also scale the breathing depth. Fresh memories burn; stale ones ember.* *This is the detail people notice without being able to name.**

Note that three.js injects `instanceMatrix` and `instanceColor` into non-raw `ShaderMaterial`s on an `InstancedMesh` automatically — don't declare them yourself or you'll get a redefinition error. And when you transform the normal, push it through `mat3(instanceMatrix)` before the normal matrix.

**For the aura around each small node, do not use a second instanced sphere.** Use **billboarded instanced quads**: a `PlaneGeometry(1,1)` whose vertex shader places it at the instance origin in view space and expands it by the instance scale, with an analytic radial falloff `pow(max(1.0 - length(uv), 0.0), 2.2)`) in the fragment. It's two triangles instead of hundreds, the falloff is genuinely soft instead of a hard-edged disk leaning on bloom to hide it, and — the trick — you can **share the same `instanceMatrix` and `instanceColor` buffers** as the node mesh, so any scale animation moves both layers in lockstep for free. Render it under the cores with a negative `renderOrder` and turn off frustum culling (the quads are expanded in the shader, so the CPU-side bounds are wrong).

**The core — my agent itself — is a layered sun**, built by hand rather than instanced: a white-hot inner sphere with additive blending, the glow shell above, and **two counter-rotating corona billboards** (a tight bright one and a wide faint one) using a radial-gradient `CanvasTexture` — generate that texture once in code with `createRadialGradient` and reuse it everywhere. Group all of those under a "nucleus" pivot so it can breathe and flare without disturbing what's around it. Then add **two torus rings** representing the agent's prompt blocks (stable and dynamic), at different radii, with the dynamic one tilted and slowly breathing in opacity. **Gotcha:** a torus's symmetry axis is Z, so rotating it about Z is invisible — spin the rings about **Y**.

Sub-agents get their own glow spheres (bigger, in their own accent color) plus, if there's an avatar image, a **circular sprite**: draw the image into a canvas through a circular `ctx.clip()`, make a `CanvasTexture` with `SRGBColorSpace`, and put it on a `Sprite` above the glow so the halo still rims around the portrait. On image error, silently keep the glow sphere.

**Verify:** one core star, breathing, with rings. A few dozen instanced beads that shimmer independently and vary visibly in brightness by age. Confirm in the profiler that all the small nodes of a region are **one draw call**.

---

## Tier 4 — Layout: where everything goes

**Goal:** the regions are recognizably separate places, and the memory web looks like a web.

Define an `ANCHORS` map of fixed `Vector3` positions — one per region, spread around the origin inside the membrane radius. Everything else is positioned relative to its anchor. Keep this module pure math with no scene dependency.

- **Memory — a real force-directed layout, precomputed once.** Run ~150 iterations of: node-node repulsion (inverse-square, with a minimum distance clamp so coincident nodes don't explode), spring attraction along each *similarity* edge scaled by its weight, and a gentle gravity pull toward the memory anchor. Damp the velocity each step (~0.82). Seed the initial positions from the same deterministic id hash so the layout is identical on every reload. **Run it once at load and bake the result — never per frame.** 150 iterations over a few hundred nodes is a few milliseconds; a live physics sim would cost you the frame budget and make nothing look better.

- **Working** — an even ring around its anchor with a slight vertical wobble.

- **Agents** — a **vertical arc**, not a circle: distribute them along an arc parameter and use it for x/y/z so they read as a column of presences off to one side. Each agent's tool "moons" cluster tightly around their host at a small radius.

- **Knowledge** — a flat grid, roughly square `ceil(sqrt(n))` columns), centered on its anchor.

- **Rim — a compact "capability ball," not a scene-encircling ring.** This was a real revision in the reference build: a giant ring around everything read as diffuse background noise rather than a region. Instead, place tool *categories* on an inner sphere around the rim anchor using **golden-angle spherical distribution** `y` stepped evenly from 1 to −1, angle incremented by `π(3−√5)`), bundle each category's tools tightly around its anchor like grapes, and put integrations on a slightly larger shell with the golden-angle phase offset by π so they interleave rather than shadow the categories. Now the tool inventory is a visual peer to the memory web, and the trunks from the core fan into it as one limb.

**Verify:** orbit around and confirm you can point at each region as a distinct place. The memory web should have visible structure — clumps of related memories, not a uniform ball. If it's a hairball, your similarity threshold is too low; if it's dust with no clumps, too high.

---

## Tier 5 — Edges that carry energy

**Goal:** connections that arc instead of slicing, and primary pathways that visibly flow.

**Every edge is a curve, not a line.** Build a `QuadraticBezierCurve3` per edge whose control point is the midpoint pushed ~12% of the chord length along a perpendicular (cross the chord with the outward-from-origin direction). Straight lines slice through clusters and make the scene look like a wireframe; arcs make it look anatomical. Sample each curve into a fixed number of segments (24 is plenty).

**Merge all edges of the same kind into one `LineSegments`.** One geometry per edge kind, not per edge — this is the difference between 400 draw calls and 6. Keep a `Map` of `"source|target" → { curve, kind, weight }` on the side, because the pulse system in the next tier needs to look up individual curves by endpoint.

Give each edge kind its own color and a **low base opacity** (0.14–0.22), additively blended. They should read as a faint nervous system, not as prominent lines — the nodes are the subject.

Then two shader treatments that make edges feel alive:

- **Similarity shimmer.** Give the merged similarity geometry a per-vertex `aPhase` attribute (every vertex of a given edge gets the *same* phase, hashed from its key), and breathe the alpha on `sin(uTime * 1.6 + phase)` around the base opacity. The memory web quietly glitters.

- **Energy-flow trunks.** For the primary pathways — core→agents, core→tools, memory→core — carry a per-vertex curve parameter `aT` (that vertex's position along the curve, 0→1) plus the phase and the edge weight. In the fragment shader, compute a comet head at `fract(uTime  uSpeed + phase)` *and a distance behind it* `d = fract(head - vT)`*, then an exponential tail* `exp(-d  9.0)`. Add that band to a dimmed base line and mix the color toward white at the head. **The sign of `uSpeed` sets the direction** — positive streams outward from the core (dispatch, capability), negative streams inward (recall flows memory→core). Now the map shows *direction of thought*, which is most of why it reads as a mind. Weight the alpha by the edge weight so, e.g., a disabled integration glows faint instead of floating disconnected.

Give both custom materials a `uShimmer` / `uFlow` uniform so the FPS governor in Tier 8 can freeze the animation and fall back to a plain line without swapping materials.

**One practical gotcha:** the hover-highlight code in Tier 7 will set `material.opacity`, which `ShaderMaterial` ignores. Define an `Object.defineProperty` getter/setter on those materials that routes `.opacity` into the uniform, so the rest of the codebase doesn't need to special-case them.

**Verify:** edges arc around clusters instead of through them; the similarity web glitters faintly; the trunk lines have visible comets streaming in the correct direction (out to agents, in from memory).

---

## Tier 6 — Make it fire (the live layer)

**Goal:** the map reacts, within a second, to my agent actually doing something.

**Build a dedicated, read-only observer WebSocket** — e.g. `/ws/observe` — that pages connect to and only ever receive from. The server keeps a set of observer sockets and fans each event out to all of them.

> **⚠️ The one warning I most want you to heed.** Do **not** reuse whatever socket my agent's main UI, chat, or voice pipeline uses, and do not touch its connection state. This visualization is a spectator. Wrap every observer send in a **timeout (~1 second)** and prune sockets that fail, because a sleeping tab with a half-open connection will otherwise block the `await` that a real user's turn is sitting behind — turning a decorative page into a latency bug in the actual product. Gate the endpoint on `Origin`. If my agent's server keeps any singleton "current connection" reference, **do not write to it from this code path at all.**

Emit events from wherever they naturally happen — memory recall/write callbacks, sub-agent dispatch, turn completion, alerts. Keep the payloads tiny: a `type`, a `kind`, and ids. The client reconnects with exponential backoff (1s doubling to a 15s cap), and — importantly — **closes the socket entirely on `visibilitychange` when the tab hides** and reopens on return, since the render loop is paused anyway and a live socket would just queue a stale burst to replay at you.

Then map events to motion. Build a **pooled pulse system**: ~64 additive sprites (radial-gradient `CanvasTexture` again), all preallocated and hidden. `fire(fromId, toId, color, opts)` grabs a free sprite, looks up the static curve between those two nodes **in either direction**, and animates the sprite along it, swelling at mid-flight. If no static edge exists, fall back to a straight ephemeral line between the two resolved positions — and if either id can't be resolved, **drop the pulse silently.** (That's the no-fake-pulses rule from the top: an animation flying between two points that don't mean anything is a lie.)

The reactions worth building, roughly in order of impact:

- **Memory recalled** → a violet pulse travels from that memory *to the core*, and the source memory **flares** (scale ×2.2, decaying back to 1 over a second). You can watch your agent remember something.

- **Memory written** → a cyan pulse flies from the working region toward the memory region, and if the target id is new since page load, it **blooms** — a fresh glow sphere scaling from 0 to full over 0.8s, registered so later recalls can flare it. (New nodes can't join an `InstancedMesh` allocated at load size, so standalone spheres are the honest way to handle this.)

- **Sub-agent dispatched** → a pulse from the core out to that agent, and the agent's halo **doubles in intensity for 30 seconds**, refreshed by further activity. At a glance you can see who's currently working.

- **Turn completed** → the core's dynamic prompt ring flashes toward full opacity and the nucleus swells, both decaying on the same envelope so the star and the ring flare as one organism.

- **Alert** → a ripple across the membrane: opacity swept `0.06 → 0.22 → 0.06` on a sine over two seconds. The whole mind flinches.

Add two pieces of **ambient life** so the scene is never dead even when nothing is happening: (1) **synaptic firing** — every 0.8–2 seconds, pick a random similarity edge and send a small dim spark along it in a random direction, reusing the same pulse pool at reduced scale and opacity; (2) an **ambient drift** of ~40 tiny particles cycling from the working region toward the memory region on bowed, randomized paths — the summarization flow made visible. (Give `Points` a texture; untextured points render as hard squares up close.)

Also add a slow **idle camera drift** layered on the auto-rotate: a ±0.5-unit vertical sinusoid applied as a **delta** (current minus previous) so it composes with `OrbitControls` instead of fighting it, and pauses cleanly the moment I grab the camera — with no snap when it resumes.

Finally, export the event handlers from the module so I can drive them from the console `liveHandlers.onDispatch('scout')`) to test every animation without waiting for real activity.

**Verify:** trigger one real recall and one real dispatch in my agent and watch the map respond. Then hide the tab, wait, and come back — confirm no stale burst and a clean reconnect.

---

## Tier 7 — Navigable: hover, fly, inspect, search

**Goal:** I can find and read anything in my agent's mind without leaving the page.

- **Hover.** Raycast on `pointermove` against the instanced meshes plus the individually-meshed nodes. For an `InstancedMesh` hit, map `intersection.instanceId` back to a node id through an ids array you stashed in `userData`. Filter the raycast targets by **visibility up the whole parent chain**, or legend-hidden regions stay clickable. Show a small tooltip near the cursor with the region, label, and one line of detail.

- **Edge highlight on hover.** The merged-per-kind geometry can't brighten one node's edges in place, so instead: dim *every* base line to ~0.04, and draw that node's edges as a temporary bright overlay `LineSegments` that you dispose on unhover. Keep those overlays parented at the scene root so region toggles don't hide them.

- **Click to fly.** Lerp the camera ~8% per frame toward a position offset from the node along the current view direction, with `controls.target` lerping alongside. Pause `autoRotate` during focus; cancel any in-flight glide the moment I grab the orbit controls. Distinguish click from drag by pointer travel (>6px = drag, not a click). Double-click empty space or press Escape to return to overview.

- **Inspector panel.** A slide-in side panel that fetches the lazy detail endpoint and renders per node type: a memory shows its type badge, timestamp, source, full body, and **clickable nearest-neighbor links** that fly you onward — which is what turns the panel into a way of *wandering* the mind. An agent shows its avatar, specialty, model, and tool list. Guard against a stale fetch resolving after I've clicked elsewhere. **Insert every server-sourced string with `textContent`, never `innerHTML`** — memory bodies contain raw user text and markdown, and this is a live XSS surface.

- **Labels.** Use `CSS2DRenderer` for crisp DOM text that tracks 3D positions. **Only label region headings and sub-agents permanently.** Do not create a label per node — 150 DOM elements repositioning every frame will cost more than your entire WebGL budget. The tooltip covers everything else.

- **Search.** A single input that substring-matches labels and ids, ranking prefix matches above substring matches and shorter labels above longer, then flies to the top hit on Enter. Call `stopPropagation` on its keydown so typing never drives the scene.

- **Legend as region toggles.** One chip per region; clicking flips that region's group visibility. This is why nodes, halos, accents, labels, and that region's edges all got parented into per-region groups back in Tier 2/3 — one flip hides all of it. **Known limitation to state honestly:** edge-kind → region mapping is lossy (an edge between two regions has to pick one), so a few edges will survive a toggle. Tell me rather than papering over it.

- **Deep links.** Read `#node=<id>` on load and focus that node; update the hash on focus with `replaceState`. Now I can link someone straight to a specific memory in my agent's mind.

**Verify:** hover a memory and see its neighbors light up; click it, fly in, read it, click a similar memory, fly onward. Toggle each region off and back. Reload a deep link and land in the right place.

---

## Tier 8 — Keep it fast, and fail loudly

**Goal:** 60fps on a laptop, graceful degradation on a weak GPU, and never a silent black page.

**Cheap-by-construction things you should already have done:** one instanced draw call per region, one merged `LineSegments` per edge kind, layout baked once at load, a **single shared `uTime` uniform object** referenced by every material (write it once per frame, not once per material), a pooled and preallocated pulse system, and labels created only for regions and agents.

**An FPS governor.** Keep a rolling 60-frame time sample. If the average drops below 30fps and *stays* there for 3 seconds, degrade in two steps: first **turn off bloom** (by far the biggest cost), and if it's still low, **disable pulses and drift particles**, freeze the shimmer and flow uniforms, and stop the ambient firing. `console.info` each step so I can tell degradation from a bug. Don't degrade on a single slow frame — a garbage collection pause is not a weak GPU. Also skip the entire frame body when `document.hidden`.

**Fail visibly.** If the skeleton fetch fails, write a plain message into the stats line ("failed to load mind — is the server up?") and rethrow so the module halts. A silent black page is the worst possible outcome for a page whose entire job is showing me what's there. Along the same lines, print the real counts in the stats line — `N nodes · M edges · memories shown/total` — so a truncated region is immediately obvious.

**One import-cycle gotcha worth knowing.** If `scene.js` top-level-awaits the data fetch and `inspector.js` statically imports `scene.js`, a static import back the other way deadlocks. Load the interaction module with a **dynamic `import()` at the bottom of `scene.js`**, so by the time it runs the scene module is fully evaluated.

**Verify:** profile a full orbit and confirm the draw-call count is in the low tens, not the hundreds. Throttle the GPU in devtools and confirm the governor kicks in and logs, rather than the page just stuttering.

---

## Library reference (what actually does the work)

| Piece | What it's for | Notes |

|---|---|---|

| **three.js** (pinned, e.g. r0.174) | everything WebGL | Loaded from CDN via `<script type="importmap">` — no bundler, no npm |

| **OrbitControls** (addon) | drag to orbit, damping, auto-rotate | `enableDamping` + min/max distance |

| **EffectComposer + RenderPass** (addons) | post-processing chain | Must be resized alongside the renderer |

| **UnrealBloomPass** (addon) | the glow that makes it look alive | The single highest-impact line in the build |

| **CSS2DRenderer / CSS2DObject** (addons) | crisp DOM labels tracking 3D points | Regions + agents only, never per node |

| **InstancedMesh** | hundreds of nodes at one draw call | Per region, so legend toggles work |

| **Custom GLSL `ShaderMaterial`** | node glow, halos, edge shimmer, flow comets, starfield | ~6 small shaders; this is where the look lives |

| **QuadraticBezierCurve3 / LineCurve3** | arced edges, and pulse travel paths | Sample to 24 segments |

| **Sprite + CanvasTexture** | coronas, region accents, pulses, avatars | Generate radial gradients in code; reuse one texture |

| **numpy** (backend) | cosine similarity matrix | `unit @ unit.T`, top-3, threshold 0.35 |

| **A read-only WebSocket** | live events | Separate endpoint, timeout-guarded, never the voice/chat socket |

Fonts: one clean UI face (the reference uses Inter) for the header and HUD. Everything else is shader output.

---

## Troubleshooting map (hand me this — most of these fail quietly)

- **Black page, no error** → the skeleton fetch failed and got swallowed. Tier 8: surface it in the stats line and rethrow.

- **Everything renders but looks flat and dull** → bloom isn't in the chain, or its threshold is above your brightest pixels. Tier 2.

- **The membrane is a solid disk instead of a rim** → `max(dot(...))` fresnel on a `BackSide` sphere. Use `abs()`. Tier 2.

- **Memory web is an undifferentiated hairball** → similarity threshold too low or top-K too high. Tier 1.

- **Memory web is dust with no structure** → threshold too high, or you're falling back to non-semantic edges without realizing it. Check the stats block.

- **All nodes pulse in unison** → you're using a single global time with no per-node phase, or you used `Math.random()` instead of a stable id hash. Tier 3.

- **Frame rate dies at a few hundred nodes** → you built individual meshes instead of instancing, or a label per node. Tier 3 / Tier 7.

- *`instanceMatrix` redefinition error** → you declared it in your shader; three.js injects it. Tier 3.

- **Rings appear not to spin** → you're rotating a torus about its Z symmetry axis. Spin about Y. Tier 3.

- **Halos look like hard-edged disks** → you used sphere shells for the aura instead of billboarded quads with an analytic falloff. Tier 3.

- **Legend-hidden nodes are still clickable** → the raycast filter only checks the object's own `visible`, not the whole parent chain. Tier 7.

- **Hover highlight does nothing on some edge kinds** → `ShaderMaterial` ignores `.opacity`; add the property → uniform shim. Tier 5.

- **Pulses fly through empty space** → you're resolving unknown ids to a default position instead of dropping the pulse. Tier 6.

- **New memories appear as nodes but have no similarity edges** → the process serving the map is reading a *different* memory-store instance than the one being written to. This bit the reference build. Confirm both paths share one store, or accept that new nodes get edges only after a restart — and say which.

- **The live page makes my agent slow** → an observer send is blocking the main path. Tier 6: timeout every send, prune dead sockets, and keep this code path away from the primary connection state.

---

## The feel, in one paragraph

The north star: opening this page should feel like **looking at a brain scan of something that's awake**. Not a dashboard, not a node-link diagram — a dark volume of space with a star at the center that breathes, a violet web of memories glittering off to one side where the bright ones are the things it learned this week, tools clustered into a dense ball of capability, sub-agents standing in an arc with their tools in orbit, and comets of light streaming inward and outward along the trunks between them. Then I say something to my agent, and a memory *flares* violet and sends a pulse home to the core, and I can see it remember. Everything on that screen is true — nothing is decorative filler, nothing is faked when the data is missing — which is why I'll actually trust it when it shows me something surprising. Build the data contract first, then the void, then the light, then the layout, then the wiring, then the life, then the navigation — one tier at a time, looking at the screen after each — and by the end I'll have something that has genuinely told me things about my own agent that I didn't know.
