Author: hermes · Date: 2026-06-28 · Status: spec / build-ready (pending board placement)
Research basis: research/kg-3d-viz-second-brain-2026-06-28/ (REPORT + ADDENDUM-2d-renderers).
All library licenses/versions verified npm/GitHub 2026-06-28.
Lane: Hermes specs content/data/capability; Apollo builds the front-end viz + chat UI; backend
graph-DB + Mastra tools = separate owner (see §10). Visual/UX design = Apollo's call — this spec
fixes data shape + capability contract, not pixels.
A client-embeddable web component: a navigable, customizable 2D knowledge-graph explorer (the Obsidian-graph mental model, but with deeper customization) over a Graphify graph, paired with a "knowledge expert" chat powered by the existing Mastra agents on Railway. The agent reads the graph (summarize / analyze / suggest topics) and can write back (new nodes/edges/topics). Chat and graph are bidirectionally wired: agent answers highlight + fly-to nodes; clicking a node seeds a question.
Explicit non-goals (this MVP): 3D (dropped — overkill); giant-graph GPU mode (cosmos.gl) deferred to phase 2; auto-ingestion pipeline (Graphify already produces the graph); temporal memory (Graphiti) deferred.
| Layer | Choice | License | Why |
|---|---|---|---|
| Framework | Next.js (App Router) + React 19 | MIT | client stack; matches Apollo/ViskaFront |
| Renderer | Sigma.js v3 (sigma) |
MIT | WebGL-2D force renderer = Obsidian's architecture, opened up (custom programs + reducers) |
| React binding | @react-sigma/core v5 |
MIT | idiomatic React mount + hooks (useSigma, useLoadGraph, useRegisterEvents) |
| Graph model + algos | graphology (+ graphology-layout-forceatlas2, graphology-communities-louvain, graphology-metrics) |
MIT | data model, FA2 layout, Louvain communities (= Graphify clusters), centrality (for gap-detection) |
| Agent runtime | Mastra (existing, Railway) | — | already deployed; TS tool-calling + streaming |
| Chat UI | @ai-sdk/react useChat (+ optional assistant-ui) |
MIT/Apache | streams Mastra tool parts; renders tool-invocation states |
| Graph store | Neo4j or Postgres/pgvector (decision §10) | — | agent read/write target + node embeddings |
Deferred (phase 2, behind same data layer): @cosmos.gl/graph (MIT) for the 100k–1M+ big-graph
mode. ⚠ Do not use @cosmograph/* packages — CC-BY-NC non-commercial.
Graphify JSON {nodes:[{id,label,community,weight,...}], links:[{source,target,weight}]}
│ (one-time / on-update load)
▼
Graph store (Neo4j | Postgres+pgvector) ◄──── agent write-back (new nodes/edges)
│ read (graph + node embeddings) ▲
▼ │
Next.js app │
├─ <GraphExplorer> (Sigma v3 + @react-sigma) │
│ graphology graph ← store/JSON │
│ FA2 layout (worker) · Louvain color │
│ reducers: LOD · hover-highlight · search │
│ imperative API ⇄ chat (highlight/fly-to) │
└─ <ExpertChat> (useChat → Mastra agent) ───────┘
tools: query-graph · get-neighbors · suggest-gaps · write-node
stream: tool-output parts + custom data-graph-highlight parts
Graphify output already matches the {nodes, links} shape. Canonical node/edge fields the front-end
relies on (Hermes-owned contract; if Graphify field names differ, map at load — do not change Graphify):
type GNode = { id: string; label: string; community: number; // Louvain cluster id
weight?: number; x?: number; y?: number; // x/y present iff precomputed
degree?: number; kind?: string }; // optional metadata
type GEdge = { source: string; target: string; weight?: number; kind?: string };
type GraphifyExport = { nodes: GNode[]; links: GEdge[]; meta?: { generatedAt: string; version: string } };
community → color (one Map<number,hex> built at load; brand palette is Apollo's call).x,y present (server-precompute), render frozen; else run FA2 in a worker, then freeze.This is where "more customizable than Obsidian" is delivered. Required capabilities:
community; seed FA2 with per-community
centroid offset so clusters read by position, not just hue.labelRenderedSizeThreshold + a nodeReducer that hides
labels/small nodes when zoomed out, reveals them on zoom-in. (Obsidian does a weak version of this;
we expose the thresholds.)nodeReducer/edgeReducer dim non-neighbors; highlight the
hovered node's neighborhood. Click = "select" (persisted highlight + opens node detail).camera.animate({x,y,ratio}, {duration}) eased fly + zoom to the
matched node; highlight it.kind. This is the customization ceiling Obsidian doesn't expose. Apollo owns the visual language.GraphExplorer exposes a ref/context API the chat calls:
highlightNodes(ids: string[]), flyTo(id: string), clearHighlight(), getSelected(): string|null.Animation/aesthetic (Apollo discretion): node fade-in on load, FA2 settle animation, eased camera, hover ring, neighbor-edge emphasis. Keep it Obsidian-clean, not noisy.
Four tools, Zod-typed in/out so the client knows the result shape. TypeScript-native, no Python (the deliberate low-risk path: the graph already exists, so we own read/write tools over a graph DB rather than adopting a Python GraphRAG engine — see research §3).
// READ — graph-grounded answer + the nodes to highlight
queryGraph: {
input: { query: string; seedId?: string },
output: { answer: string; nodeIds: string[]; subgraph: { nodes: GNode[]; links: GEdge[] } }
}
// READ — ego expansion for click-to-explore
getNeighbors: {
input: { nodeId: string; hops?: 1|2; limit?: number }, // limit default 100
output: { nodes: GNode[]; links: GEdge[] }
}
// READ/ANALYZE — "suggest new topics to research" = structural gap detection (InfraNodus pattern)
suggestGaps: {
input: { scope?: 'global' | { communityId: number } },
output: { suggestions: { question: string; bridges: [number, number][]; rationale: string }[] }
// impl: low-connectivity community pairs / low-betweenness frontier nodes (graphology-metrics)
// → LLM phrases bridging research questions
}
// WRITE — agent persists a new node/edge/topic back to the graph (gated, §9)
writeNode: {
input: { node?: GNode; edges?: GEdge[]; provenance: string }, // provenance = required audit string
output: { written: { nodeIds: string[]; edgeCount: number }; warnings: string[] }
}
Read tools run Cypher (Neo4j) or SQL+pgvector (Postgres). queryGraph retrieves a subgraph (vector search
on node embeddings + k-hop expansion), answers grounded, returns cited nodeIds — the bridge to the viz.
tool-{key} message parts (states
input-streaming → input-available → output-available). Client useChat() maps message.parts; on
part.type === 'tool-queryGraph' & state:'output-available' → graphRef.highlightNodes(part.output.nodeIds)flyTo(nodeIds[0]). For a progressive "agent walking the graph" effect, the agent emits custom data
parts writer.custom({ type:'data-graph-highlight', data:{ nodeIds } }); client lights nodes as the
agent reasons, independent of the final answer.onNodeClick(node) → open node detail panel and offer
sendMessage({ text:\Tell me about ${node.label}`, data:{ seedId: node.id } }); the agent's retrieval
seeds onseedId` (ego-graph around that node).suggestGaps → render suggestions as chips;
clicking a chip highlights the two bridged communities + seeds the question.GraphExplorer MUST be dynamic(() => import('./GraphExplorer'),
{ ssr:false }); SSR throws window is not defined. #1 failure mode.'use client'; keep the graph a leaf so the rest of the page still SSRs.ExpertChat can drive GraphExplorer.meta.version — Graphify output is an immutable
build artifact; treat it as edge/CDN-cacheable.writeNode mutates business-of-record state. It must never be reachable as a public unauthenticated
mutating endpoint. Constraints:
- The Mastra write tool runs server-side (Railway), authenticated to the graph DB via a vaulted
credential (Hades) — never a browser-side DB connection.
- If any HTTP surface exposes write (webhook/API), it MUST authenticate the caller (header-auth / JWT /
edge HMAC) — path obscurity is not auth.
- writeNode requires a provenance string and writes to a quarantine/pending state by default;
promotion to the canonical graph is a separate gated step (mirrors the Viska memory-system §3 guardrail
posture — agent-written content is untrusted until validated). Zero-fabrication: written claims carry
source provenance.
- Browser → graph is read-only (RLS SELECT-only if Postgres); all mutation flows through the
authenticated agent tool.
| Item | Owner | Note |
|---|---|---|
| Front-end Sigma viz + chat UI shell + bidirectional wiring | Apollo | this handoff |
| Visual language (node/edge programs, palette, motion) | Apollo | Apollo's design domain |
| Graph store choice: Neo4j vs Postgres/pgvector | Proteus (DB governance) | Postgres if you want one store for graph+embeddings; Neo4j if you want native graph + mcp-neo4j. Decision blocks backend. |
| Mastra tools (query/getNeighbors/suggestGaps/writeNode) backend | TBD backend owner (not Apollo) | TS; could be Apollo full-stack or a backend agent — operator to assign |
| Write-path credential (graph DB) deposit | Hades | vaulted; agent-side only |
| Board placement | operator/Apollo | adjacent: proteus #207 Graph Viewer v1 (internal, different scope) — link as Related, don't merge |
dynamic(ssr:false) GraphExplorer, load a Graphify JSON sample into
graphology, Sigma mount, FA2 layout, Louvain color. Static graph renders.ExpertChat (useChat → Mastra), queryGraph + getNeighbors,
agent→graph highlight/fly-to, graph→agent click-to-ask. Talk-to-your-graph works.suggestGaps chips; writeNode (quarantined, gated per §9). Read+write.ssr:false leaf.ExpertChat streams a Mastra agent answer that highlights + flies to the cited nodes
(agent→graph), and node-click seeds a question (graph→agent).suggestGaps returns ≥1 bridging research question and highlights the bridged communities.writeNode writes to a quarantined state with provenance; browser path is read-only; no public
unauthenticated mutating endpoint (rule #19).Clean — adopt/integrate, not rebuild. Sigma.js, graphology, Mastra, the graph DB, and the Graphify graph all already exist. Net-new code = the React explorer component, the four Mastra tools, the bidirectional glue, and the write-gate. No engine is rebuilt; no existing solution duplicated.