Building Tendril: From Coupled Prototype to Open-Source Knowledge Graph
Blog posts are lonely by default.
You write a post about Tailscale. You write a post about your homelab. You write a post about your NAS. They're all connected in your head — Tailscale is what bridges the homelab to the remote NAS, the homelab is where the NAS lives, the NAS stores the data that the homelab processes. But on the blog? Three separate pages in a chronological list. Might as well be written by three different people.
Tags help. Sort of. You click "infrastructure" and get a flat list. Category pages do the same thing with a fancier template. Neither shows you the web of connections between ideas. Neither shows you that the build swarm post connects to the Gentoo post, which connects to the Argo OS post, which connects back to the homelab post through shared infrastructure concerns.
I wanted to see that web. So I built it. Then I rebuilt it. Then I spent 3 hours at 1 AM tuning physics parameters until it stopped looking dead. Then I open-sourced it.
This is the full story.
The Obsidian Problem
If you use Obsidian, you know the graph view. That floating, organic visualization of your notes. Tags forming clusters. Ideas connecting across your vault. You can spend hours just... looking at it.
Then you try to publish your notes to the web. The graph is gone. Or it's static. Or it's some sad approximation that doesn't feel right.
I wanted my blog to feel like my Obsidian vault. 181MB of notes. Thousands of connections. The graph view was addictive. My blog was just... a list. Chronological. Linear. Boring.
Part 1: The Coupled Prototype
The first version went straight into the ArgoBox codebase. I wanted:
- Physics-based dragging — Nodes follow each other like they're connected by springs
- Fullscreen content preview — Click a node, read the full post in a panel
- Real-time filtering — Toggle between posts, tags, and categories
- Responsive performance — Works on mobile without killing the battery
Data Structure
Every graph needs nodes and edges. In a blog context:
interface GraphNode {
id: string;
label: string;
type: 'post' | 'tag' | 'category';
url?: string;
category?: string;
tags?: string[];
content?: string;
}
interface GraphEdge {
source: string;
target: string;
type: 'post-tag' | 'post-post';
strength?: number;
}
At build time, we traverse all posts and extract relationships:
---
import { getCollection } from 'astro:content';
const posts = await getCollection('posts', ({ data }) => !data.draft);
const nodes = [];
const edges = [];
const seenTags = new Set();
posts.forEach(post => {
nodes.push({
id: `post-${post.slug}`,
label: post.data.title,
type: 'post',
url: `/posts/${post.slug}`,
category: post.data.category,
tags: post.data.tags || []
});
post.data.tags?.forEach(tag => {
const tagId = `tag-${tag}`;
if (!seenTags.has(tag)) {
nodes.push({ id: tagId, label: tag, type: 'tag' });
seenTags.add(tag);
}
edges.push({ source: `post-${post.slug}`, target: tagId, type: 'post-tag' });
});
});
---
Basic Cytoscape Setup
Cytoscape.js handles the rendering:
const cy = cytoscape({
container: document.getElementById('knowledge-graph'),
elements: elements,
style: [
{ selector: 'node', style: {
'background-color': 'data(color)',
'label': 'data(label)',
'width': 'data(size)',
'height': 'data(size)',
'font-size': '10px',
'color': '#E2E8F0'
}},
{ selector: 'node[type="post"]', style: { 'shape': 'ellipse' }},
{ selector: 'node[type="tag"]', style: { 'shape': 'diamond' }},
{ selector: 'edge', style: {
'line-color': 'rgba(226, 232, 240, 0.2)',
'curve-style': 'bezier'
}}
],
layout: {
name: 'cose',
nodeRepulsion: 500000,
gravity: 80,
numIter: 1000
}
});
The cose layout positions nodes using force-directed simulation. But it only runs once at initialization. For Obsidian-like interaction, we need continuous physics.
The Custom Physics Engine
This is where it gets interesting. Each frame, we calculate forces on every node:
const physicsConfig = {
springLength: 120,
springStrength: 0.04,
repulsion: 800,
damping: 0.85,
gravity: 0.02,
maxVelocity: 15,
minMovement: 0.5
};
function calculatePhysics() {
const nodes = cy.nodes();
const forces = new Map();
nodes.forEach(node => forces.set(node.id(), { fx: 0, fy: 0 }));
// REPULSION: Nodes push each other apart (inverse square law)
nodes.forEach(nodeA => {
const posA = nodeA.position();
nodes.forEach(nodeB => {
if (nodeA.id() === nodeB.id()) return;
const posB = nodeB.position();
const dx = posA.x - posB.x;
const dy = posA.y - posB.y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const force = physicsConfig.repulsion / (distance * distance);
forces.get(nodeA.id()).fx += (dx / distance) * force;
forces.get(nodeA.id()).fy += (dy / distance) * force;
});
});
// SPRINGS: Connected nodes attract (Hooke's law)
cy.edges().forEach(edge => {
const source = edge.source(), target = edge.target();
const dx = target.position().x - source.position().x;
const dy = target.position().y - source.position().y;
const distance = Math.sqrt(dx * dx + dy * dy) || 1;
const displacement = distance - physicsConfig.springLength;
const springForce = displacement * physicsConfig.springStrength;
const fx = (dx / distance) * springForce;
const fy = (dy / distance) * springForce;
forces.get(source.id()).fx += fx;
forces.get(source.id()).fy += fy;
forces.get(target.id()).fx -= fx;
forces.get(target.id()).fy -= fy;
});
return forces;
}
The animation loop uses requestAnimationFrame for smooth 60fps. The key insight: while dragging, the dragged node is excluded from physics updates. Its position is controlled by the mouse. But other nodes still feel its spring forces, so they follow along.
The Drag Magic
cy.on('grab', 'node', function(e) {
draggedNode = e.target;
draggedNode.addClass('highlighted');
draggedNode.neighborhood().addClass('highlighted');
startPhysics();
});
cy.on('free', 'node', function(e) {
draggedNode = null;
// Small random velocity for natural settling
const vel = nodeVelocities.get(e.target.id());
vel.vx = (Math.random() - 0.5) * 2;
vel.vy = (Math.random() - 0.5) * 2;
startPhysics();
});
In fullscreen mode, clicking a post node shows the full article in a side panel — content embedded in node data at build time, available instantly without fetching.
It worked. Posts showed up as nodes. Tags created edges between them. The physics ran. You could click and drag and navigate.
But it felt dead.
Nodes settled too quickly. Clusters were too uniform. The whole thing had a mechanical quality that Obsidian's graph never has.
And it was welded to ArgoBox. The component assumed Astro's content collection API. The styling assumed my specific CSS variables. The node colors were hard-coded hex values. If someone else wanted a knowledge graph for their blog, they'd have to reverse-engineer the whole thing.
That's not a library. That's a prototype wearing a trench coat.
Part 2: The Layout Experiments
This is where I lost most of my time.
Cytoscape.js supports multiple layout algorithms. I tried all of them.
Cola (force-directed). Classic. Looks great for small graphs. With 60+ posts and hundreds of tag connections? Hairball. An indecipherable mess of crossing edges and overlapping nodes. Like a bowl of spaghetti that's also a network diagram.
Concentric. Arranges nodes in rings based on connection count. Looks structured. Too structured. Lost the organic, exploratory feel. Felt like a corporate org chart.
Breadthfirst. Tree-like hierarchy. Interesting if your content has parent-child structure. Mine doesn't. Posts aren't chapters in a book — they're neurons in a brain.
Random + physics. Nodes flew apart, attracted along edges, settled into something organic. It had the right feel — clusters forming naturally, bridges stretching between them. But the parameters were wrong. Nodes settled too quickly. Clusters were too tight. The whole thing looked like a sea urchin.
I spent months tweaking parameters. Nothing felt right.
Part 3: The 3 AM Breakthrough
February 1, 2026. I decided to fix it once and for all.
Three hours later, I understood what was wrong: all my edges were the same length.
Obsidian's graph has variable connections:
- Spines (post to hub tag): Short, tight
- Bridges (tag to tag, hub to hub): Long, loose
This creates the organic geometry. Hubs with radiating satellites. Distant clusters connected by loose bridges.
// The fix that changed everything
const edgeData = edges.map(edge => {
const sourceDeg = degreeMap.get(edge.source);
const targetDeg = degreeMap.get(edge.target);
const isLeaf = sourceDeg <= 2 || targetDeg <= 2;
return {
...edge,
length: isLeaf ? 5 : 150, // Spines tight, Bridges loose
weight: isLeaf ? 1.5 : 0.1 // Spines strong, Bridges gentle
};
});
When I saw the result, I just stared at it. That was the moment it stopped looking like my graph and started looking like the graph.
The Click That Made It Real
I loaded the graph with actual ArgoBox posts. Sixty-something nodes. Hundreds of edges.
The simulation ran. Nodes pushed and pulled. And then clusters started forming.
Infrastructure posts — homelab evolution, Proxmox setup, NAS management — pulled together on one side. Build swarm posts clustered nearby but distinct. Argo OS posts formed their own group, connected to both infrastructure (because the OS runs on the infrastructure) and build swarm (because the swarm compiles the OS).
And then the bridges. The Tailscale post sat between the local infrastructure cluster and the remote management cluster, exactly where it belongs conceptually. The Gitea post bridged between development workflow and self-hosting.
The graph was showing me relationships I knew existed but had never visualized. Posts I thought were unrelated turned out to share three tags. Posts I thought were closely related were actually in different clusters, connected only through intermediary topics.
That was the moment it stopped being a feature and started being a tool.
Part 4: The Extraction
January 28th. I looked at the graph component, looked at my Gitea instance, and decided: this becomes its own thing today.
Five commits. One day. That's how long it took to go from "coupled component in a monorepo" to "standalone library with an Astro template."
The hard part wasn't writing code. The hard part was deciding what the library should not know about.
The library doesn't know about Astro. It takes an array of nodes and an array of edges and renders an interactive graph into a DOM element. Whatever framework you're using handles the data transformation.
The library doesn't know about blog posts. A node has an id, a label, a type, and optional metadata. Whether that metadata is a blog post, a wiki page, or a recipe for chicken parmesan is not the library's problem.
The library doesn't know about your theme. It reads CSS custom properties from the document and adapts:
const styles = getComputedStyle(document.documentElement);
const textColor = styles.getPropertyValue('--color-text');
const bgColor = styles.getPropertyValue('--color-bg');
Dark mode? Light mode? Solarized? Whatever. If you've set --color-text and --color-bg, the graph picks them up. It's not a widget bolted onto the page — it's part of the page.
This is the part that always takes longer than you think. Not adding features — removing assumptions.
The Monorepo Structure
npm workspaces. Two packages, one repo.
tendril/
packages/
graph/ # @argobox/tendril-graph - the library
src/
index.js
rollup.config.js
package.json
template/ # Astro template with KnowledgeGraph.astro
src/
components/
KnowledgeGraph.astro
package.json
package.json # workspace root
packages/graph/ builds to a standalone JavaScript bundle via Rollup. Framework-agnostic, dependency-light (just Cytoscape.js under the hood).
template/ is the reference implementation — a KnowledgeGraph.astro component that shows how to pull data from Astro's content collections, transform it into nodes and edges, and feed it to @argobox/tendril-graph. Copy it, adjust, done.
One repo, two products. The library for people who want full control. The template for people who want it working in five minutes.
Part 5: What Makes It Different
Live Physics Controls
This is the big one. Nobody else has this.
Quartz, Obsidian Publish, MindStone — they all render static force-directed graphs. The physics runs once during generation.
Tendril's physics run in the browser. In real-time. With sliders.
const graph = new TendrilGraph('#container', {
nodes: [...],
edges: [...],
physics: {
centerForce: 0.05, // Low - allows organic spread
repelForce: 4000, // High - cluster separation
linkForce: 1.0, // Strong - tight connections
linkDistance: 40, // Short base (spines override)
damping: 0.85 // Smooth settling
}
});
You can tune center force, repel force, link force, damping — and watch the graph reorganize live.
Tag Hierarchy
Automatic parent-child relationships between tags:
const TAG_HIERARCHY = {
'gentoo': 'linux',
'k3s': 'kubernetes',
'kubernetes': 'containers',
'docker': 'containers'
};
This creates structural backbone. gentoo connects to linux, which connects to other distro tags. The graph develops natural hierarchical clusters without forcing a hierarchy.
Smart Labeling
Only hub nodes show labels. Reduces visual clutter without losing navigability.
Comparison
| Feature | Tendril | Quartz | Obsidian Publish |
|---|---|---|---|
| Live physics controls | Yes | No | No |
| Inline content preview | Yes | No | Separate pane |
| Framework agnostic | Yes | No (Hugo) | N/A |
| Customizable forces | Yes | No | No |
| Open source | Yes (MIT) | Yes (MIT) | No |
| Cost | Free | Free | $8/month |
Tendril isn't trying to replace Quartz. If you need backlinks, wikilinks, and full Obsidian-to-web publishing, Quartz is probably better. But if you want the graph experience — the floaty, organic, Obsidian feel — that's what Tendril does.
Performance and Mobile
For Large Graphs
With hundreds of posts, the O(n²) repulsion calculation gets expensive. Solutions:
- Spatial hashing — Only calculate repulsion for nearby nodes
- Web Workers — Offload physics to a separate thread
- Reduce frame rate — 30fps is often enough
- Limit visible nodes — Filter by date or category
Mobile
The graph renders on mobile. Touch interactions work. But 60+ nodes on a 375px-wide screen isn't the optimal experience. I debated hiding it on mobile entirely. Decided against it — it still works as a navigation tool, and the physics gives you a sense of the overall structure even at small sizes. It's just better on a desktop where you can see the full topology spread out.
const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
if (isMobile) {
physicsConfig.maxVelocity = 8;
physicsConfig.damping = 0.95; // More friction = faster settling
}
Why MIT
Because the world has enough proprietary blog widgets.
If someone else wants a knowledge graph for their Astro site, or their Next.js site, or their hand-rolled static site generator — here it is. MIT licensed. Fork it, modify it, ship it, sell it, whatever. I built it because I needed it, and hoarding a blog visualization tool felt absurd.
What I'd Do Differently
Start with the library, not the prototype. I built the coupled version first, then extracted it. Next time I'd start standalone and build integration on top. The extraction process was basically "delete all the assumptions."
Test with different data shapes earlier. The graph looks great with 60 posts and diverse tags. What about 5 posts? 500? A blog where every post has the same three tags?
The physics parameters are still not perfect. They're good enough. The graph looks organic, clusters form naturally, bridges are visible. But "good enough" is the enemy of "exactly right," and I know I'll be back tweaking repel forces and damping coefficients at 1 AM some Saturday.
The Takeaway
Blog posts aren't pages. They're nodes in a network. Every tag, every related post reference, every shared concept creates a connection. Traditional blog navigation — chronological lists, tag clouds, category pages — flattens that network into something linear. A knowledge graph unflattens it.
Tendril is my answer. A small library that turns post metadata into an interactive graph. Framework-agnostic, theme-aware, physics-based. Built over months of iteration because I was tired of my blog feeling dead compared to my Obsidian vault.
Sometimes you spend months building something that "works" but doesn't feel right. Sometimes you need a 3-hour session at 1 AM to figure out why. Variable edge lengths. Spines and bridges. That was the secret.
Now when I look at the graph on this site, it feels like my vault. That's all I wanted.
Ideas that grow and connect.