Generative Plane

Building a personal syndication pipeline in Go

👋 Hi, I'm Ben. I've spent most of my recent career at Block working on Infrastructure and Developer Experience. I haven't actively blogged in almost 15 years, but I'd like to start writing again — about what I'm building, how I'm using LLMs, and the occasional philosophical post. These days I'm building out local inference and generalised compute on a Mac Studio, following privacy and personal data sovereignty principles. So naturally the first thing I needed was a way to publish.

I wanted a simple publishing workflow: write a post, review it, publish to my own site, and syndicate copies to Bluesky and Mastodon. No CMS, no third-party service, just a CLI tool backed by event sourcing and a static site.

This is how I built it with a small set of Go libraries I maintain collectively called axon.

The idea

POSSE — Publish (on your) Own Site, Syndicate Elsewhere — is a publishing model from the IndieWeb community. Every post lives on my static site first. Copies go out to social platforms. I keep the canonical version.

The system needs to handle three post types: short text, long-form articles, and image posts. Each platform has different constraints — Bluesky limits to 300 characters, Mastodon to 500 — so the syndication layer adapts the content per platform.

Event sourcing as the foundation

All state changes are events appended to an immutable log. The core interface from axon-fact:

type EventStore interface {
    Append(ctx context.Context, stream string, events []Event) error
    Load(ctx context.Context, stream string) ([]Event, error)
}

type Projector interface {
    Handle(ctx context.Context, event Event) error
}

An Event is a struct with a type string and a JSON payload. A Projector processes events to build read models. Projectors run synchronously within Append, so the caller sees the projected state immediately.

The post lifecycle produces these events:

post.created → post.revised (0..n) → post.approved → post.published → post.syndicated

Each event carries only the data needed. PostCreated has the full post body. PostRevised has the updated fields. PostApproved records who approved and when. PostPublished records the canonical URL. PostSyndicated records the platform, remote ID, and remote URL.

The projection builds a Post struct from these events. On startup, all events replay through the projection to reconstruct the current state. No separate database of "current posts" — the event log is the source of truth, the projection is a cached view.

The post store

The PostStore wraps the event store with domain methods:

store.Create(ctx, synd.Short, "hello world", synd.WithTags("go"))
store.Revise(ctx, postID, "updated text", "", "", nil, "web")
store.Approve(ctx, postID, "benaskins")
store.Publish(ctx, postID, canonicalURL)
store.Syndicate(ctx, postID, synd.Bluesky, remoteID, remoteURL)

Create uses functional options for optional fields like tags, title, and abstract. The rest take positional arguments. Each method constructs the appropriate event, appends it, and the projection updates immediately. Get, List, Drafts, ApprovedPosts read from the projection.

For persistence, there's a Postgres-backed event store that stores events in an events table under a synd schema. On startup it replays all events into the projection. An in-memory store using axon-fact's MemoryStore handles tests.

Static site generation

The site builder takes a list of posts and writes static HTML — an index page, individual post pages, an RSS feed, and a CSS file. Go's html/template does the rendering. The output goes to a git repo that's configured with GitHub Pages.

builder := synd.NewSiteBuilder(synd.SiteConfig{
    Title:   "Generative Plane",
    BaseURL: "https://generativeplane.com",
    Author:  "Benjamin Askins",
})
builder.Build(posts, siteDir)
synd.GitPublish(siteDir, "post: hello world")

GitPublish does git add -A, checks for changes, commits, and pushes. If nothing changed, it's a no-op.

Platform clients

Each platform gets a thin REST client. The Bluesky client authenticates via the AT Protocol's createSession endpoint and posts via createRecord. The Mastodon client uses an OAuth bearer token and posts via /api/v1/statuses.

Both follow the same shape:

client.Post(ctx, text) (id, url, err)
client.PostWithLink(ctx, text, linkURL) (id, url, err)
client.PostWithImage(ctx, text, imagePath, altText) (id, url, err)

The syndication layer decides which method to call based on the post kind and length. Long posts get truncated with a link back to the canonical URL.

Authentication

All API and web routes require authentication. The server uses axon's RequireAuth middleware, which validates session cookies against a separate auth service (axon-auth, WebAuthn/passkey-based). API routes return 401 for unauthenticated requests. Web routes redirect to the login page instead, so opening a review link in a browser sends you through passkey login and back to the draft.

The CLI auto-provisions a service-user token on first run by calling the auth service's internal API, then persists it to ~/.config/synd/token. Subsequent requests attach it as a session cookie. The authenticated username is recorded in approval and revision events — no more hardcoded "cli" or "web" strings.

The approval gate

Posts default to draft status. The CLI sends a create request to the server, which generates a token (via axon-gate) and sends a Signal message with a review URL:

New draft for review

Kind: short
hello world

https://synd.studio.internal/drafts/{id}?token={token}

The token is 32 random bytes, base64url-encoded, stored in the post's created event. It's only sent via Signal — the API never returns it. The review URL opens a web page where I can edit the post and approve it. Token validation uses crypto/subtle.ConstantTimeCompare.

The web UI is Go templates embedded with //go:embed, served by a small HTTP handler. Three routes: show draft, save revision, approve. All three sit behind the auth redirect, so reviewing a draft requires both a valid session and the approval token.

The background worker

A goroutine polls the projection for approved posts every 10 seconds. For each one:

  1. Rebuild the static site
  2. Git commit and push
  3. Emit post.published with the canonical URL
  4. Syndicate to Bluesky and Mastodon
  5. Emit post.syndicated for each platform

The worker is idempotent. If it crashes between steps 3 and 4, it won't re-publish on restart because the post status is already "published". Syndication checks are also idempotent — if the syndication record already exists for a platform, it skips it.

The CLI

The CLI is a thin wrapper around the server's API. It builds requests, calls HTTP endpoints, and formats responses. It never writes to the database or event store directly — the server owns the projection and the store.

synd post "hello world"              # create a draft
synd post --long article.md          # long-form from markdown
synd drafts                          # list pending drafts
synd approve <id>                    # approve from CLI
synd serve                           # web UI + background worker

synd serve runs both the review web server and the background publish worker. Every other command talks to it over HTTP. Draft creation sends a Signal notification. Approval triggers the worker to publish and syndicate.

What I'd do differently

axon-fact provides the event sourcing interfaces and an in-memory store. For this project I needed Postgres persistence, so I built a PostgresEventStore inside axon-synd. It implements the same fact.EventStore interface, runs projectors synchronously on append, publishes asynchronously — the same contract as the memory store. It should live in axon-fact. It's completely generic. Same goes for the event construction helpers I wrote — axon-chat already has an EventTyper interface and an emitEvent function that do the same thing. These patterns emerged independently in two services, which is the signal to extract them into the shared library.

The static site generator is basic. It works, but the templates are inline Go strings. A real template directory with hot reload would be better for iteration.

The Signal notification is one-way. A reply-to-approve flow would be more natural than opening a browser, but the Signal REST API doesn't support receiving messages cleanly.

The polling worker is fine for one person's blog. For anything higher volume, a NATS subscriber reacting to post.approved events would be more appropriate — axon-fact already has a Publisher interface for this.

I'd also look at indielib — a Go library implementing Micropub, IndieAuth, and microformats Post Type Discovery. It's unopinionated about storage and architecture, so the Micropub Implementation interface could delegate straight to PostStore. That would give the pipeline a standards-compliant API — publish from any Micropub client, authenticate with your own domain via IndieAuth, and with a companion webmention library, send and receive cross-site replies.

The stack

About 3,000 lines of non-test Go across the domain package and CLI.

Is this simple?

I opened with the word "simple" and then described an event-sourced system with projections, background workers, and platform clients. That might seem like overkill for a blog. But the simplicity isn't in the line count — it's in the composability. Each axon module does one thing: axon-fact gives me event sourcing, axon-gate gives me approval tokens and notifications, axon-auth gives me passkey login. Assembling them into a publishing pipeline is mostly wiring.

And because the publishing tools are CLI commands, the whole workflow is drivable by a CLI-based coding agent. This post was drafted, revised, and published from a Claude Code session — synd post, review the draft on my phone, approve, done. The agent doesn't need a browser extension or an API integration to publish. It just runs a command. That's the kind of simple I was after.