Fediverse-powered Comments

Finally, after many months of off-and-on effort, I’ve finally gotten blog comments working on this site. It has been a long journey, and one that taught me a lot along the way. Many probably would respond saying “Why did it take you so long?” but it was a challenge trying to find the right combination of tools and processes that would make it easy for me to incorporate dynamic comments on a static site by either avoiding reliance on a backend service or reducing the reliance as much as possible, while preserving each commenter author’s content ownership.


  1. Background
  2. The Design
  3. The “Why?”
  4. Setting up the Development Environment
    1. OpenCode
    2. OpenRouter
    3. Zed
    4. Dev Containers
    5. Caddy
  5. Development
    1. Backend
    2. Frontend
  6. Looking to the Future
  7. Other resources

Background

I’ve not talked about it a lot here, but for years I’ve been a big proponent of decentralized services, data sovereignty and content ownership. Its a big topic and one that I’m definitely not an expert on, but perhaps at some point in the future I can share a little bit of my opinions on it here, especially given the current tech landscape and AI. However, since I migrated my blog away from Blogger, I have been seeking a better way to provide visitors to my site here with a way to leave comments on posts, despite the fact that the site is statically-generated. There are a lot of ready-made options out there, including Disqus and Discourse, but I wanted something that a) allowed users to use a ActivityPub Fediverse account (you do have one already, right?), and b) reduced the amount of security maintenance overhead.

It turned out to be a much more difficult challenge than I initially thought, especially given that the site is hosted on GitHub Pages and I didn’t want to really pay for and maintain a server to both host my low-traffic blog and handle comments. I also considered self-hosting using a spare Raspberry Pi I had, but I also didn’t want to risk the security of my home network. I stumbled on a pretty awesome solution years ago in the form of this blog post where the author cleverly utilized Mastodon’s API to obtain a list of replies to a given post and use those as the comments for the blog post.

Currently this blog is hosted on GitHub Pages, but given their current policy of using user content to train AI, I may move to a different static hosting provider in the future. My hope is that since I also use Cloudflare to front the site, the underlying hosting changes will go unnoticed to my visitors.

It was genius: publish a new blog post, publish a toot with a link to the post, and use the Mastodon API to fetch the replies to the toot, and render those replies as comments using frontend Javascript to visitors of the page. Furthermore, visitors could also easily leave comments by replying to the toot using their existing Fediverse accounts, thereby alleviating the need for a dedicated comments system and allowing visitors to retain ownership and security of their data. Awesome!

The Design

I could’ve used the functionality that the original author used, but there was a little bit of chicken-and-egg clunkiness that I didn’t like: anytime I published, I needed to publish my blog post first, then publish a toot with a link to the post, then obtain the toot’s ID and update the blog post with the toot ID so that the comments could be fetched. This back-and-forth process was error-prone and time-consuming. I wanted to streamline the process so that I could publish once and everything would fall into place automatically. Well, as any seasoned developer would quickly realize, automating this was no simple task.

After tabling the idea for way too long (years), n8n came along and presented a potential solution to the problem: I could publish a blog post, then use GitHub Actions to trigger a workflow, which could then publish my toot with the link to the blog post, obtain the toot’s ID and then run the process of updating the blog post with the toot ID. It seemed doable, but I still didn’t like it - while the process would now be automated, it still required double-publishing. I still asked myself “How could I just publish once?”

Around this time, I also realized something that I’d not noticed in a number of other posts that utilized a similar technique: with everything coming from the Fediverse, comment moderation is more-or-less not possible. With the exception of moderated content at the Fediverse-server level, everything else would be visible on the site so long as the Mastodon server’s API returned it as an identified reply to the toot. How could I intervene to prevent spam or unwanted content from being displayed on my site? At this point, I realized that I would not be able to get away without having something in place to filter content. But, again, I didn’t want to have to build a huge commenting system and still wanted to maintain the ability to leverage the Fediverse for commenting.

I’ve been wanting to build something using Cloudflare Workers for a while now as a way of experimenting with the service’s offerings and understanding it a bit more deeply. It was interesting to me that you could easily create APIs that didn’t require publishing a full-blown backend server to serve them, and that the deployment mechanism was globally-distributed the moment you deployed. And with different data storage capabilities available including Cloudflare Workers KV and caching out-of-the-box, it seemed like it would be a great fit.

Some seasoned engineers would probably immediately stop here and comment that I was overengineering the problem, but hear me out: the reason I chose to work this way was because, even if I took the Cloudflare Workers away, the API could easily be replaced with either a traditional or serverless backend, OR I could even render each of the responses from my API as static JSON documents and serve them statically like everything else on the site. So my ability to move away from Cloudflare would still be available. The only advantages here, then, to continuing to use Cloudflare Workers here would be that I didn’t have to create yet another pipeline to build and deploy the fake API, and I would be able to gain hands-on knowledge and experience working with Cloudflare’s service.

So, given the selection on technology, what was the plan? Well, I still wanted comments to load from Mastodon’s APIs, but I needed a way for the frontend pages to figure out which toot to load replies for. Inspired by this post, I could add front matter to my markdown source files for my blog posts, but instead of linking directly to the toot (which was the original idea), it would contain a random UUID that I create during the drafting process of the post that identified the blog post. Jekyll would bake the UUID into the static HTML output, and when the user opened the page, the frontend Javascript would read the UUID in the page and then call my proposed nexus service. The nexus service would then look up the UUID in a KV store, and return information about the hosting Mastodon server, toot ID and a list of comments I wanted to filter out.

TODO: Display Mermaid sequence diagram of the process

The “Why?”

Perfect, I now had a design. But, I also wanted to use this opportunity to learn a bit more on some things that were on my shame pile, and so I used this as an opportunity:

  • To learn how to set up and use OpenCode as an open-source alternative to popular development IDEs like Cursor, Claude Code, Codex, etc.
    • To learn how to use OpenCode with OpenRouter, where I could easily try different models while still imposing cost-saving limitations and other restrictions (e.g. prevent use of providers who train on content).
  • To learn how to set up and use a Dev Container for all TypeScript-based development, in an effort to reduce littering my personal machine with npm packages.
    • To learn Typescript, as I have been a backend Java developer for years and feel like I should have some awareness of how Typescript works.
  • To learn how to set up a new coding project and use the Zed IDE for full development rather than how I had been using it previously as more-or-less a basic text editor. I would normally reach for IntelliJ, but I wanted to use something more lightweight and gain a little more experience on using it for more than just basic editing.
    • To learn how to combine Zed with OpenCode and Dev Containers to handle all of the development lifecycle.
  • To learn a little bit about Caddy as a reverse proxy. I had experience using Nginx and Traefik, but saw this as an opportunity to also use Caddy, another container-friendly reverse proxy.
  • To learn a little bit of Ruby and the Liquid templating engine in order to understand the language and understand how to make customizations for Jekyll statically-generated sites.

And so, I finally had a full plan for myself: I would use Zed as an IDE for a Dev Container with OpenCode to develop a bispoke Mastodon-powered commenting system running as a Cloudflare Worker. I would also be modifying my Jekyll template - Hydejack - in order to properly inject the UUID and frontend Javascript used to load the data from the Cloudflare Worker-powered API and then also load from Mastdodon.


Setting up the Development Environment

Before I began, I would need to set up my development environment. Given my list of learning objectives, this meant setting up Zed to work with OpenCode and Dev Containers. I figured I would need to figure out the OpenCode component first, as that would be what I would use for generating the CloudFlare Worker code.

OpenCode

I chose OpenCode because it was an open source alternative to popular agentic IDEs like Cursor, Claude Code, Codex and others. I was looking forward to using it not only because it was open source, but it was designed as a generic, bring-your-own-model agent harness, giving me the flexibility to try different providers and models: as long as I had access to an OpenAI-compatible API, I could use anything I wanted - including Ollama for fully-local development. The other critical factor was that OpenCode supported the Agent Context Protocol (ACP), allowing me to choose which IDE I wanted to use while using OpenCode as the agent harness, including Zed and IntelliJ.

The process of setting up OpenCode was simple enough, but now I needed to provide a model. I had previously used Ollama for local AI generation, but I wanted to try something else. Enter OpenRouter.

OpenRouter

My experience with AI providers is currently very limited. I’ve had experience with Copilot and Cursor at work, but I wanted more control. Furthermore, because I am planning a wedding and generally try to be as frugal as I can, I didn’t want to sign up with a specific AI provider and wanted to the ability to choose different models and providers in order to research and experiment with which tasks worked with different models the best.

I came across OpenRouter as an answer to my search: it provides a router to different AI models and providers of those models, while providing mechanisms for controling which models and providers to use in a pay-as-you-go payment model. This was a huge find for me, as I don’t use AI tooling very often (I know how to search for things, and I do like DIY when it comes to tech things), but I do want the flexibility to spend the funds I use in the ways I want to use them.

OpenRouter is definitely a rabbit hole, and has a somewhat steep learning curve - or at least that is what it felt like for me. It does make some sense since I am a bit of a novice in the AI space. But after getting acquainted with the tool, it was nice to be able to compare models by modality (text, image, video, etc.), price, and (critically) understanding whether or not the model provider would train from the input or output data (and filtering them out from use) - which is pretty much all of the “free” models.

Initially, I selected DeepSeek v4 Pro Flash as, at the time it was the least-expensive model, but have more recently switched to using Ling 2.6 Flash as it became the leader in terms of cost vs coding capability for me. From here, it was easy enough to set OpenCode to use OpenRouter/Ling-2.6-Flash as the model.

Zed

I have been using Zed for a while as a replacement for the Pulsar text editor (spiritual successor fork of the discontinued Atom editor). I loved Atom as it was a simple, flexible and extensible text editor, but since Github was acquired by Microsoft, Github abandoned Atom in favor of VSCode, which I did not like using. Atom was starting to struggle, and I had discovered Pulsar as a fork of the original Atom, where volunteers had been working to keep it alive. However, I started to notice that Pulsar was taking longer and longer to start, and for a simple text editor, I was growing impatient. Furthermore, I disagreed with some of the design decisions that were made for Pulsar, so I began looking for an alternative.

I came across Zed, which was started by some of the original Atom authors. While its big claim is that it was developed in Rust, what attracted me to it was that it was very fast and decluttered. Though, more recently I have seen the project spending more time working on agentic-coding-based features rather than some critical useability features, so far it was sufficent.

For this project, it was important that Zed supported ACP, so it allowed me to connect Zed to Opencode.

Dev Containers

This was actually a surprisingly more difficult thing to handle than I was expecting. First off, in order to support CloudFlare Worker development in a Dev Container, I had to create a customized container that used the base Typescript Dev Container as a base, but installed CloudFlare’s wrangler command inside. The result was a custom Docker container:

FROM mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie

# Install Wrangler globally
RUN npm install -g wrangler

# Expose the Wrangler dev server port
EXPOSE 8787

Nothing complicated, but it was surprising to me that Zed was unable to leverage OpenCode within this Dev Container. It took a little bit for me to figure out, but it made sense: Zed’s Dev Container support is technically just a remote workspace and is fairly limited and less-than-obvious. There are some challenges to this, as Zed relies on the container runtime management to happen mostly externally (except when creating the container, but stopping or resuming must be handled externally). Furthermore, modifying the Dev Container requires the container to be shut down and re-created, which breaks Zed’s remote workspace tooling as it causes Zed to loose its “connection” to the underlying container. During the development of this Dev Container I was using, this was a somewhat frustrating experience of having to close Zed, stop and remove the container, and then restart Zed.

But, to get OpenCode to work within the Dev Container, the OpenCode runtime had to be added to the container. I was about to run the installation of OpenCode in the container through the custom Dockerfile I had crafted for CloudFlare, but then I came across this Github project: effectively I could pull in the installation of OpenCode easily by specifying it as a feature of the container:

{
  "name": "Cloudflare Workers",
  "build": {
    // Path is relative to the devcontainer.json file.
    "dockerfile": "Dockerfile",
  },
  "forwardPorts": [8787, 8976],
  "postCreateCommand": "npm install",
  "features": {
    "ghcr.io/danzilberdan/devcontainers/opencode:0": {},
  },
  "mounts": ["source=${localWorkspaceFolder}/.opencode/auth.json,target=/mnt/opencode-auth.json,type=bind,consistency=cached"],
}

With the .opencode/auth.json set up externally, I now could open the workspace in Zed and perform all development within a Dev Container without having to install Typescript, wrangler and everything else on my host - just install the tools required to perform general development.

Add the .opencode/ directory to your .gitignore - never commit anything in this folder, especially the auth.json file.

The next challenge with the Dev Container was being able to use wrangler within the container. Normally, when using wrangler, you would need to run wrangler login, but when I tried to run it, I ran into a few problems with this:

  1. Each time the container was re-created, the container would lose the login, and
  2. Each time I tried to run wrangler login, the container would attempt to open a browser and fail.

I ended up having to run wrangler login from my computer instead of from the Dev Container in order to generate an API token, then stored that token in .devcontainer/cftoken in the format:

export CLOUDFLARE_API_TOKEN=[generated token]

Add the .devcontainer/cftoken file to your .gitignore - never commit this file.

While this mechanism is not ideal, it allowed me to recreate the Dev Container, and from the container’s shell I could run source ~/.devcontainer/cftoken in order to avoid having to run the wrangler login altogether. I know that there is likely a much better way of doing this, especially since there is Cloudflare documentation around running wrangler in a container, but I will have to revisit this later.

Caddy

While I didn’t figure this out at the beginning, I did realize later I also needed a way to direct traffic to two different containers due to CSRF restrictions in the browser when making calls to localhost on different ports. I couldn’t figure out how to do this easily with Jekyll, but I resigned to instead use a reverse proxy to front the Jekyll server (with its debug port) and also the Dev Container for local Cloudflare Worker development.

Caddy allowed me to redirect URL paths to different backends (one for Jekyll and the other for the Dev Container) and support multiple ports required for Jekyll (one for normal traffic, the other for auto-reload):

:4000 {
    handle /api/* {
        reverse_proxy host.docker.internal:8787
    }

    handle /* {
        reverse_proxy site:4000
    }
}

:4001 {
    handle /* {
        reverse_proxy site:4001
    }
}

Development

Backend

Finally, I had all the pieces in place to actually build the custom comments system. Since I was new to Cloudflare Workers, I decided to start the project using a starter template. I chose to use a simple To-Do List starter template, as it also supported OpenAPI endpoint schema documentation generation, leveraging the Hono and Chanfana frameworks.

From here, I was able to use Zed’s AI Chat window to open a new session with OpenCode and provide simple prompts for each desired endpoint. For example:

Add a POST endpoint /api/comments/:postKey which takes a JSON body like: {"postId": "12345","host": "foo.tld","ignore": ["67890"]}

The development was iterative, as I hadn’t yet started using tooling and techniques like Spec Kit, especially since I was not yet familiar with it, but also because I didn’t exactly know what I wanted to build yet. I had to chat back-and-forth with the agent to indicate that I wanted it to also store the data in a Cloudflare Worker KV data store, and how to handle other error conditions. But as I started to become more comfortable with it, I did notice that it was very simple for me to generate new endpoints and make adjustments, accelerating my ability to get the project finished.

The process wasn’t perfect, however. It was a good thing that I went step-by-step through the process as there were several points where the generated code didn’t work, even with the simple prompt like the one above. Had I given a much more elaborate and complete prompt that handled multiple things, the agent would have likely required me to debug a huge amount of code - using frameworks that I’d never used before, in a language I was not as familiar with. The step-by-step also allowed me to commit the code in small pieces, so if I made a change that I was no happy with, I could easily roll it back.

After a couple of hour-long sessions, I had developed the APIs I needed. I used the agent to also create simple Bash scripts to test the endpoints, which helped me validate that the code was working the way I needed it to.

From here, I decided to add security to the endpoints that could modify data. For this, I decided to use JWT tokens for authentication and authorization, avoid hard-coded credentials and prevent replayability. This proved to be a bit more challenging with the AI agent than I expected. I prompted the agent to add authentication using JWT, but it decided to roll its own framework for handling the tokens, which ended up being extremely buggy. I had to do a bit of research and ended up finding out that Hono had its own JWT handling and decided to rip out the code that the agent wrote and replace it with the much simplier Hono JWT middleware.

After this, I had added Cloudflare cache handling, which would me maintain a free status with this simple service, which was also automatically handled by Hono.

Frontend

I am by no means a frontend dev, and have just enough knowledge of HTML, CSS and Javascript to get something simple working. As a result, I leveraged the code provided by Jan Wildeboer, and modified it enough to use my freshly-created comment nexus service and work with my current site theme. I won’t go into the detail of it here, since most of this work was done by hand, and, to be honest, I’m pretty embarrased by the code that I did write (which, again, wasn’t much).

The trick for this, however, was ensuring that I only displayed the comments on posts and pages where the frontmatter data indicated that comments was enabled. This did require digging in and understanding how Jekyll and themes work together in order to extend them for my own use and inject code based on that frontmatter. It was suprisingly simple once I understood how it worked, where the hooks were, and understanding how I could provide my own hooks.

Looking to the Future

All-in-all, the experience was pretty rewarding. While I understand that there is a lot of contention about the use of AI, I am thankful that it was available to me to not only get something working quickly, but helped me understand technologies that I was largely unfamiliar with. Some may say that there is a lot of documentation around that could have helped me, but I do recognize that I am both a learn-by-example sort of person, but also a learn-by-doing, and the unique combination of technologies that I wanted to use to do this project was not something that was really done before and would have been difficult to find documentation that would fit.

Also, as a person who often wants to start a side-project but ends up shelving the projects due to the amount of time investment required and the lack of time available between home and work commitments (or just generally having the energy to do a project), the prospect of being able to leverage AI to help me put together something I actually wanted helps keep me engaged to continue learning and moving forward.

With this success, I am now inspired to try to tackle projects that I’ve wanted to start, knowing that I could adjust the level of effort I want to put in: at times where I feel the most energy I could completely do it myself, or I could enlist an agent to tackle some items and help me still feel like I’m making progress, reducing frustration that often results from being stuck on a challenge or having to tackle boring boilerplate and thus causing me to lose interest. I feel like it is not much unlike working out: getting stuck trying to reach a new goal doesn’t mean stopping completely, but at least easing up on the difficulty until you feel the energy to try to really push yourself. I feel that AI helps with that: keeping you active and engaged while allowing you to make things easier when the energy and focus isn’t there.

For the site itself, I have a pretty solid set of things I’d like to accomplish now that I have things working:

  • I would like to support nested comments, like what appears on Carl Schwan’s page.
  • I would like to also clean up the frontend code a bit. I feel like its a little bit of speghetti and could be a little more elegant.
  • I would like to switch from using a shared secret JWTs to using JWK to minimize the use of shared secrets.
  • I would like to use n8n to handle automatically adding the comment nexus data on post publication, so everything is automated from the moment I run git push.

But, for now, I feel accomplished that - thanks to the help of AI - I was able to take this project off the shelf and finally get comments working here once again.


Other resources


© 2025 Sean Payne. All rights reserved.

Powered by Hydejack v9.2.1