← Back to Blog
pullmdopen-sourceagplclaude-codeMay 4, 2026 · 12 min read

PullMD Part 8: From My Tool to an OSS Project

PRIVATE → PUBLIC OSS · AGPL-3.0AI-generated image
Editorial diagram: PRIVATE on the left, PUBLIC OSS on the right, separated by a glowing threshold; AGPL-3.0 license boundary card and PullMD/MD nodes.Editorial diagram: PRIVATE on the left, PUBLIC OSS on the right, separated by a glowing threshold; AGPL-3.0 license boundary card and PullMD/MD nodes.

I had spent the morning of April 25 integrating the Trafilatura sidecar and fixing the bug that had been sleeping in a default parameter for weeks. Both are the subject of Part 5.

In the afternoon came the thing I had been pushing ahead of me for weeks: the public release on GitHub. The code was ready. The README wasn’t. And the codename still hadn’t been replaced by the real name in the source tree, for two reasons that had been lying around in my head since early March.

The Codename Doesn’t Fit Anymore (Reason 1)

The codename the tool had grown up under came out of the very first concept chat: I had asked Claude to “pick something” for the subdomain. And Claude delivered a suggestion, because the thing pulls Reddit content and the name is short enough for an app icon.

I took it because I wasn’t thinking about branding at that point. The service ran in my homelab, under me alone, not under a brand.

By now the tool was something else. Three extraction paths: Cloudflare-native markdown, the Reddit JSON path, and the Readability-plus-Trafilatura stack for everything else. Quality score. MCP server interface. Generatable skill bundle. PWA frontend with Web Share Target.

Each of those paths works for any URL, not just Reddit.

When someone reads the repo name and sees a Reddit reference, the first expectation is: Reddit-specific tool. That isn’t true anymore.

A name that sets the wrong expectations is, in the end, less a branding problem than a support question nobody has asked yet. For clarity alone, a neutral name was needed.

The Codename Was Legally Borderline (Reason 2)

The trademark problem wasn’t something I only had in mind at push time. A few days after the concept chat, in a separate AI conversation whose exact wording I no longer remember, it became clear to me that the phonetic proximity to a large brand from that consumer category was a concrete risk.

The company behind the brand is known for going after small projects too when a name sounds phonetically similar. That’s documented practice, not my interpretation.

The probability of that landing on a growing open-source project under a similar name was high enough that from then on I stopped treating the codename as the planned final name.

On March 8 I had already created the production deploy directory under the new name /home/user/pullmd/. The brand switch was effectively decided with that.

In conversations with Claude I had been talking about “PullMD” since mid-March. Only the source-code tree stayed pragmatically under the codename, because internally it didn’t matter at first: one user on one server, nobody else sees the repo structure.

Before the public push, “internally it doesn’t matter” turned into “this has to be cleaned up.” The choice was simple: 30 minutes of thorough source-tree cleanup, or the risk of unpleasant mail from a trademark firm.

The 30 minutes seemed more sensible.

PullMD: Neutral, Descriptive, Stepping on No One’s Toes

Pull and MD. The service pulls content down from URLs and hands back markdown.

The name describes what the tool does, and nothing beyond that. No wordplay, no pop-culture reference, no phonetic proximity to any brand in any category.

PullMD is boring in exactly the right way: anyone who reads the name immediately knows what to expect. That’s all a tool name has to do.

Directory Migration

On my Ubuntu Docker host there was nothing to do about it: production had been running under /home/user/pullmd/ since March 8, the path had carried the “real” name ever since the trademark realization.

What was open was the source-code tree and every internal reference in it: repo name on my Ubuntu development server, CLAUDE.md, memory files, README snippets, skill bundle config.

Plus the question of what happens to the commit history. That one was full of the old codename.

We went for the thorough route: source repo renamed, commit history rewritten, internal notes synchronized.

Basically just the long-overdue cleanup. The real name had been in use for seven weeks, the source tree was catching up now.

Rewriting Commit History: git filter-repo

Honestly: Claude Code simply did the rename plus history rewrite.

Which commands ran for it, how filter-repo actually works, what goes wrong when you do it wrong: none of that interested me at the time. It was migration plumbing, and I wanted to go public.

Only while writing this article did I look up what actually happened. Three operations:

  • Rewriting author identities: git filter-repo --mailmap mailmap.txt. A mailmap.txt with lines of the form New Name <new@email> = Old Name <old@email>, and filter-repo then swaps the author accordingly in every old commit. In my case: my personal email address from the codename days was replaced by the OSS persona, so the public history runs consistently under one identity.
  • Replacing text in old commits: git filter-repo --replace-text replacements.txt. A file with rules of the form old-string==>new-string, and filter-repo walks through every file in every old commit. That’s how the codename disappeared from commit diffs, code comments, and old CLAUDE.md snapshots still lying around in the history.
  • Resetting branches and tags afterwards: After filter-repo all old commits have new SHAs, the history is effectively new. Before the first public push that’s harmless: push once cleanly, done. With an already public repo you’d need --force-with-lease, and all existing forks and clones would diverge. Hence the standard advice: do history rewrites like this before the first public push if you possibly can.

The tool itself isn’t part of standard Git, it’s a separate Python package: pip install git-filter-repo. The official successor to the old git filter-branch, which had so many footguns that the Git maintainers themselves point at filter-repo.

Now I know. Back then: watched, waited, pushed.

If you trust the tool, that’s a legitimate mode. The operation is deterministic, and before the first public push there’s no damage you can’t take back.

If you don’t trust it, you should understand the commands before delegating, not afterwards while blogging.

The More Critical Piece: the Session Directory

The more critical piece was the Claude Code session directory. The sessions are the source this blog series itself is written from. Without them, no archaeological reconstruction of Part 2 or Part 5.

Claude Code stores JSONL transcripts in a path derived from the working directory: ~/.claude/projects/-home-user-projects-<directoryname>/.

If, after the migration, I accidentally start Claude Code from the old directory, new sessions land in the old path and are no longer together with the new session history. As a small guard against that case: _archive/sync-sessions.sh, a helper script that mirrors the new session history into the updated path.

And then there was the tar backup: the entire old session directory saved as _archive/claude-sessions-<codename>-2026-04-25.tar.gz (2.4 MB).

This archive, incidentally, is exactly what makes this blog series possible in the first place. The sessions from March 2 to 4, from March 20 and April 11, and from April 25, the ones Parts 2 through 7 and this article here are built on, would no longer be quotable without this backup. The tar.gz sits in _archive/, is never committed to the repo, but it is the source for everything that appears here as “quoted from the session.”

AGPL-3.0, Not MIT

With open-source projects the license choice often comes down to a quick trade-off: MIT if you want maximum reach, GPL if you’re ideologically convinced. And something in between if you don’t quite know what you want.

That isn’t a particularly useful categorization.

The actual question for PullMD was: what happens if someone forks it, builds in modifications, and offers it as a cloud service without sharing those modifications? With MIT the answer is: nothing.

That’s the whole idea of permissive licenses. Broad adoption, low friction, done.

For a self-hosted service the question is a different one. PullMD isn’t a passively consumed package: it’s a service that runs and answers requests.

If someone operates it as a cloud service and builds in improvements I never get to see, I lose the incentive to understand what the actual problems in my codebase are.

AGPL-3.0-or-later is exactly for this case: anyone who offers the service over a network and has made modifications has to publish those modifications under AGPL.

That isn’t an ideological statement about free software. It’s a question of incentive structure.

CLI helpers, libraries, toolchain packages: MIT or Apache-2.0 are the right tools. Self-hosted services where network use is the primary channel: consider AGPL.

The permissive dependencies deep in the stack (Express, cheerio, better-sqlite3) stay freely choosable; license layering isn’t a binary either-or. More nuance on the consequences of individual licenses without a lawyer: choosealicense.com.

Making It Self-Hostable

Up to this point the service ran only on my infrastructure, with my hostnames hardcoded in various places. For a public release that had to change.

The most important change was PUBLIC_URL as a configurable environment variable. The default value is https://${HOST_DOMAIN}. Anyone who sets HOST_DOMAIN for Traefik automatically gets the correct URL.

This variable is substituted into the /help page and embedded into /web-reader.zip, a generatable skill bundle for Claude Code.

Without the substitution, self-hosters would see my hostname on the help page instead of their own: a poor experience and an unnecessary dependency.

HOST_DOMAIN controls the Traefik label in docker-compose.yml. Anyone who wants to deploy this on their own domain sets that one variable, and Traefik routes and issues certificates.

A clean .env.example as a template. The actual .env with OAuth credentials stays outside the repo.

The README was completely rewritten for public consumption: no internal notes, no eval workspace, no briefing documents.

.gitignore covers everything that shouldn’t go out: CLAUDE.md, SESSION_LOG.md, pullmd-improvements-briefing.md, docs/, .claude/, _archive/.

The Push

gh repo create AeternaLabsHQ/pullmd --public --source=. --remote=origin --push

The local initial-public-release commit was finished on April 25. But two more days of polish passed before the push actually ran. v1.0.0 and v1.0.1 were tagged locally, not pushed.

v1.0.1 was a Reddit comment-cache bugfix that had to go live on my Ubuntu Docker host before the repo was to become public.

On April 27 gh repo create went through. v1.0.2 is the first tag in the public history.

In the days that followed came UI polish commits: footer, version templating, help page layout. Those are the normal finishing-touch things that only occur to you once you look at your own service with real distance for the first time.

A tool that started in March as a Reddit toy was, eight weeks later, officially open source: for any URL, at github.com/AeternaLabsHQ/pullmd.

What Came After

The service has been running on its own domain ever since, and I use it every day: for Reddit posts, for articles, for documentation pages, for anything I want to bring into a chat context as clean markdown.

Underneath it sits everything that has come along since March: the three extraction paths with quality score, the MCP server for Claude Code, the generatable skill bundle, the PWA for the Android share workflow.

Rendering JavaScript Pages: the Playwright Sidecar

Since April 25, P3.1 has been collecting data on the side: a SQLite table with an extraction log, plus the /api/stats endpoint. The plan was to watch for a week and then decide whether P3.2 was worth it: a Playwright microservice for pages that give you nothing without JavaScript.

The trigger turned out to be a single page. Mistral’s price list came back as 452 kilobytes of HTML that didn’t contain the prices at all, because the browser only assembles them when it displays them. Readability got 19 headings and three paragraphs out of it, Trafilatura a whole 283 characters.

On top of that came the number from the log. Of 29 web fetches, static extraction had failed on four, which makes 14 percent. The threshold at which the effort is worth it at all was ten, according to the briefing.

What was built from that was the second sidecar, after Trafilatura from Part 5. Its own container with Python, FastAPI and a Chromium without a window.

It takes an address and returns the finished rendered HTML, out of which Readability and Trafilatura then do their best again. The container only hangs on the internal network and is considerably heavier than anything else in the stack.

When rendering happens at all is decided by a small predicate in lib/render-decision.js. It kicks in in three cases:

  • Readability has fallen back and fewer than 500 characters are left
  • there are many headings but barely any paragraphs
  • the quality score is below 0.5

If none of that applies, it stays with static extraction. For the cases where the heuristic gets it wrong, there are render=force and render=skip.

If the sidecar doesn’t answer, or runs into the 20-second time cap, the weak static result comes back, along with a note about what went wrong. Anyone who doesn’t start it up at all gets the same route.

Rendering simply takes noticeably longer than a normal fetch. That’s why the same version got a status line showing whether it’s currently fetching, extracting or rendering.

The plan didn’t hold two days. The Playwright sidecar was standing by April 27, a few hours after the gh repo create, and shipped as v1.1.0. That saved me the week of watching, because I would only have needed it if the number had been close.

What was still missing was the most obvious thing: showing the thing to somebody. One day after the push I wrote a post on r/ClaudeAI for exactly that.

What that set off is in Part 9.


Part 8 of 15 in the PullMD series.

← PullMD Part 7: The MCP Server | PullMD Part 9: Reddit Day and the First Issues →

Comments

Commenting takes an email confirmation or a GitHub login. Comments run on my own server, not on an ad provider's — details in the privacy policy.