Introduction
plym is an open source CMS built for a world where agents are first-class citizens. It helps you build blazing-fast blogs, optimised SEO and discoverability by LLMs like ChatGPT.
Updated Aug 04, 2026
On this page
The problem
Agents read the web on your readers' behalf, and they read the same HTML you built for browsers. On plym.io the article Introducing plym is 52,734 bytes of HTML and 4,189 bytes of markdown: the markup an agent downloads and then discards is 92% of the transfer. The workaround most teams reach for — a second markdown copy, maintained by hand — drifts from the source within a release or two.
Core philosophy
One source, two artifacts. You author markdown once. Both outputs are generated from it, so they cannot disagree.
Render at publish time, not request time. A published page is a file on disk. Serving it is a file read, not a template render plus three queries.
Highly configurable Templates, colors, fonts, caching, and SEO behavior come from config.yaml and a template directory. Changing how the site looks does not mean forking the application.
The publish pipeline
When a post moves to published, or when you call POST /api/posts/{id}/refresh, the render pipeline writes two files under storage/.generated/.
| File | Contents |
|---|---|
{category}/{slug}.html |
Markdown rendered to HTML, template applied, CSS bundle and Prism JS inlined, JSON-LD (BlogPosting, plus FAQPage when the post has FAQs), Open Graph and article meta tags, canonical URL. |
{category}/{slug}.md |
The post's markdown source, unmodified. |
Each file is written to a temporary path and moved into place, so a reader never sees a half-written page. Unpublishing or deleting a post removes both files.
Content negotiation
Caddy serves the markdown twin when text/markdown is the first media type in the request's Accept header. Every other request gets the HTML.
curl -H 'Accept: text/markdown' https://plym.io/blog/introducing-plym
Today we're releasing plym as open source. It's a CMS built from the ground up for
two audiences that used to be one: people reading your site in a browser, and the
growing number of agents reading it on their behalf.
## Why another CMS?
curl -H 'Accept: text/html' https://plym.io/blog/introducing-plym
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Introducing plym — plym</title>
Both responses carry Vary: Accept, so a shared cache keys the two variants separately.
What plym does not do
plym does not detect AI user agents. A request from ClaudeBot/1.0 carrying a browser Accept header receives HTML. Markdown delivery is opt-in by header: an agent that wants markdown asks for it.
This is by design. We previously served markdown content based on the user-agent. We had a list of 48 user agents who would see markdown content, always. In fact you can still do this by adding a simple snippet in the Caddy config.
This topic has attracted a lot of opinions recently. However, we decided to NOT do it because of two reasons:
-
A bot might intend to view the actual design and layout of the page. For example, a search engine crawler that rewards you for having good web fundamentals.
-
Content negotiation is gaining popularity and many AI agents are now requesting markdown content natively.
Further limits:
-
Accept: text/html,text/markdownreturns HTML.text/markdownhas to come first in the list. -
Only published posts have rendered files. Drafts and archived posts fall through to the application, which returns HTML or 404.
Benchmarks
Time to first byte, measured on a self-hosted install — Raspberry Pi 5, Docker, Caddy over loopback — 10 requests per row, median reported.
| Request | TTFB |
|---|---|
| Published page, HTML | 0.8 ms |
| Published page, markdown | 1.0 ms |
| Blog index, rendered by the API and cached in RAM | 6.2 ms |
Transfer size, three articles on plym.io, uncompressed:
| Article | HTML | Markdown | Reduction |
|---|---|---|---|
| Introducing plym | 52,734 B | 4,189 B | 92% |
| Creating templates for plym | 66,241 B | 11,678 B | 82% |
| How AI search actually works | 60,359 B | 10,714 B | 82% |
A rendered page carries roughly 48 KB of inlined CSS and Prism JS. That is what buys the single-request paint: the HTML references no external stylesheet and no external script. Self-hosted fonts, images, and the favicon are the only follow-up requests, and all three are served with Cache-Control: public, max-age=31536000, immutable.
Architecture
Three containers, orchestrated by Docker Compose.
| Service | Image | Role |
|---|---|---|
caddy |
caddy:2-alpine |
Front door. Compresses with zstd/gzip, serves .generated/ and static assets off disk, proxies everything dynamic to the API. |
api |
plymio/plym:latest |
FastAPI application: authoring API, render pipeline, auth, migrations, scheduled backups. |
db |
postgres:16-alpine |
Posts, users, media metadata, tags, categories. |
A fourth service, mcp, ships in the same image and stays off until you run plym enable mcp. It exposes the authoring API as an MCP server so agents can create and update posts.
On each boot the API applies pending SQL migrations, ensures the superuser exists, reconciles .generated/ against the database to delete orphaned files, then builds runtime assets: self-hosted webfonts, Prism for your configured languages, and a minified CSS bundle. Asset steps fail soft — a font that will not download logs a warning and serving continues.
How plym compares
| Renders on | Database | Publishing one post | |
|---|---|---|---|
| WordPress, Ghost | Every request, behind a cache | Yes | Cache invalidation |
| Hugo, Jekyll | Build | No | Rebuild and redeploy the whole site |
| Headless CMS | Your frontend, per request | Yes | Whatever your frontend does |
| plym | Publish | Yes | Re-renders that one post |
plym keeps what static generators lack — a running API, an admin portal, per-post rendering — and keeps what request-time systems lack: pages that are already files by the time a reader asks for one.
SEO output
Three paths are served on every install with no configuration.
| Path | Contents |
|---|---|
/blog/sitemap.xml |
Every published post with lastmod, read from the database in pages of 1,000 rows. |
/blog/robots.txt |
The Disallow list from config.yaml, plus a Sitemap: line. Set robots.serve: false to stop serving it. |
/blog/llms.txt |
Site name, description, and every published post as a markdown link with its excerpt. |
Per page you get a canonical URL, overridable per post, Open Graph and article meta tags, and JSON-LD.
Licensing and hosting
plym is MIT licensed and self-hostable: one curl command brings up the stack on any host with Docker. plym Cloud is the managed option, with a 99.99% SLA and 300+ edge locations. Both run the same application and the same rendering pipeline.