Building Tendril: A Knowledge Graph for Blog Posts

Building Tendril: A Knowledge Graph for Blog Posts

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 of everything tagged infrastructure. 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.


The First Version (Coupled and Messy)

The initial implementation went straight into the ArgoBox codebase. Cytoscape.js for the graph engine, a big Astro component that pulled post data from the content collection, hard-coded layout parameters, and a lot of hope.

It worked. Posts showed up as nodes. Tags created edges between them. You could click a node and navigate to the post. The graph even had physics — nodes pushed each other apart, edges pulled connected nodes together, the whole thing settled into a shape that roughly reflected the structure of the content.

But it was welded to this site. The component assumed Astro’s content collection API. The styling assumed my specific CSS variables. The node colors were hard-coded hex values that matched my dark theme and nothing else. If someone else wanted a knowledge graph for their blog, they’d have to reverse-engineer the whole thing, tear out the ArgoBox-specific pieces, and rebuild the integration layer.

That’s not a library. That’s a prototype wearing a trench coat.


The Day I Extracted It

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 doesn’t know about React or Vue or Svelte or anything else. It takes an array of nodes and an array of edges and renders an interactive graph into a DOM element. That’s it. Whatever framework you’re using can handle 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. Dark mode? Light mode? Solarized? Whatever. If you’ve set --color-text and --color-bg, the graph picks them up.

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/          # @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. Import it, give it a container element and some data, get a graph. Framework-agnostic, dependency-light (just Cytoscape.js under the hood), works anywhere you can run JavaScript.

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 @tendril/graph. Copy it into your Astro project, adjust the data transformation, done.

One repo, two products. The library for people who want full control. The template for people who want it working in five minutes.


How the Graph Works

The core concept is simple: posts are nodes, connections are edges.

But what creates a connection? Two things.

Tags. If two posts share a tag, they’re connected. Posts tagged infrastructure cluster together. Posts tagged gentoo and build-swarm bridge between the Linux cluster and the DevOps cluster. Tags aren’t just metadata — they’re the connective tissue of the graph.

Related posts. The related_posts field in frontmatter creates explicit edges. I manually curate these. The Tailscale post links to the homelab post. The Gitea post links to the GitOps post. These are strong connections — the author is saying “these ideas are directly related.”

Tag edges and related-post edges get different weights. Related posts pull tighter. Tag connections are looser, more suggestive. The result is a graph where intentionally connected posts cluster close together, while thematically similar posts orbit in the same neighborhood without being glued to each other.


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 force-directed layout. 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 concentric circles based on degree (number of connections). Highly-connected nodes in the center, leaf nodes on the outside. Looks structured. Too structured. Lost the organic, exploratory feel I was going for. Felt like a corporate org chart instead of a knowledge map.

Breadthfirst. Tree-like hierarchy. Interesting if your content has a natural parent-child structure. Mine doesn’t. Posts aren’t chapters in a book — they’re neurons in a brain. Breadthfirst imposed a hierarchy that didn’t exist.

The one that almost worked. A preset layout with random initial positions and physics simulation on top. 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 after a few seconds.

The final choice: physics-based with custom parameters. I landed on a simulation where the center force is low (let nodes spread out), the repel force is high (clusters stay separated), and edge lengths vary based on connection type. Spine connections (post to hub tag) stay short and tight. Bridge connections (between hubs) stay long and loose.

That variable edge length was the key. Without it, every graph layout eventually collapses into a uniform blob. With it, you get the organic structure where clusters have internal density and inter-cluster connections are visible, stretched-out bridges you can actually trace.


The Click That Made It Real

Physics parameters are abstract until you see them with real data.

I loaded the graph with the actual ArgoBox posts. Sixty-something nodes. Hundreds of edges from tags and related posts.

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 — the development sprint, the v1 release, the hardening guide — 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 infrastructure. The knowledge graph post (meta, I know) connected to the digital garden posts and the Astro development posts.

The graph was showing me relationships I knew existed but had never visualized. The topology of my own writing, rendered as an interactive map. 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.


Theme Awareness

This bit is small but matters more than you’d think.

Most graph libraries hard-code colors. Blue nodes, gray edges, white background. Works fine if your site is white. Looks terrible on a dark theme. Looks bizarre on anything custom.

Tendril reads CSS custom properties from the document root. Node colors, edge colors, background, text — all pulled from your existing theme variables. Switch to dark mode and the graph switches with you. No configuration, no color props, no theme object to maintain in parallel with your CSS.

// The graph reads your CSS, not its own config
const styles = getComputedStyle(document.documentElement);
const textColor = styles.getPropertyValue('--color-text');
const bgColor = styles.getPropertyValue('--color-bg');

It sounds obvious. “Just read CSS variables.” But it means the graph never looks out of place on your site. It’s not a widget bolted onto the page — it’s part of the page.


Mobile (It Works, But…)

The graph renders on mobile. Touch interactions work — you can drag nodes, tap to navigate, pinch to zoom.

But a physics-based graph with 60+ nodes on a 375px-wide screen is… not the optimal experience. Nodes overlap. Labels collide. The graph needs room to breathe, and a phone screen doesn’t give it that room.

I debated hiding the graph on mobile entirely. Decided against it. It still works as a navigation tool even when it’s cramped. You can find posts by tapping through the graph, and the physics simulation 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.


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 I open-sourced it because hoarding a blog visualization tool felt absurd.

The repo lives at git.argobox.com/InovinLabs/tendril. Five commits, one day, one library, one template. Done.


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 with the standalone library and build the site integration on top. The extraction process was basically “delete all the assumptions,” and it would have been easier to never add them.

Test with different data shapes earlier. The graph looks great with 60 posts and diverse tags. What about 5 posts? What about 500? What about a blog where every post has the same three tags? I tested with my own data, which means I tested with one specific graph topology. Edge cases in graph visualization are literally edge cases.

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. That’s just how these things go.


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 to that problem. A small library that turns post metadata into an interactive graph. Framework-agnostic, theme-aware, physics-based. Built in a day because the prototype had been running for months and I was tired of it being trapped in one codebase.

If you’ve ever looked at your blog’s tag page and thought “this doesn’t capture how these posts actually relate to each other” — yeah. That’s why I built this.