← Alter

Engineering notes

Michael Norman · measured against the repository on 23 September 2026

Alter is the system behind two YouTube channels I run. It drafts episodes with a language model, generates the pictures, synthesises the voice, renders the film, publishes it, and then measures what the audience did and writes that back against every decision that went into the upload. I built it alone, starting 31 July 2026, and it has produced and shipped real video every week since August.

This page is what I'd want to read about someone else's system: what it is, how it's run, where it broke, and what's being built next. Numbers are measured, not rounded up. Where a claim is about work in progress it says so.

The shape of it

Elixir and Phoenix over Postgres, with Oban for background work: 390 modules, 111k lines of application code and 44k lines of test across 216 files and 2,505 tests. 86 migrations define 44 tables. 24 LiveViews make up the operator UI, 28 Mix tasks make up the command line, and 31 Oban workers run across 8 queues. The renderer that draws every frame is Rust on Skia/Metal, driven from Elixir, with linear-light compositing and a colour-grading path.

The pipeline is a chain of contexts, each owning its tables: Content (pieces, rounds, slots) feeds ImageGen and TTS, which feed Renders, which feeds Schedule and Publishing. Alongside them, Ledger records production choices, Insights keeps our own channels' analytics, and Corpus keeps the competitors'. Blobs go through one Storage behaviour with a local adapter and an S3-compatible one, so a storage change is a config change.

Release discipline

Nothing merges without mix compile --warnings-as-errors, a format check and a green suite, and nothing that is live is allowed to regress. The way I hold that line is that a lesson that cost time or money becomes a test that names it:

When something does fail in production I write down what actually happened, including when it was my fault and when the earlier note was wrong. The cron-leader rule on this system was reached after two outages; the note says so, and says what each one froze and for how long.

Data

Every migration in the repository has been run against the production database with users on it. That includes the rename of the core tenant model from personas to brands, swapping a column's meaning under running jobs, and widening a JSON column that a live worker was writing to. Where history matters the schema is append-only: a picture decision is a row in a log, the previous decision is never updated, and the ledger of what was generated is written before the provider is asked, so a call that fails still leaves its receipt.

Tenancy is structural. An organisation owns brands, brands own everything downstream, and every query in every context takes a scope. Integrations (OAuth tokens for a channel) are brand-scoped so a metric can always name the channel it came from. The one place scope is looser is documented as a known gap with the plan that closes it.

Infrastructure

I own the hosting end to end: the Postgres, the two Elixir nodes, the reverse proxy, TLS, secrets, access control and logging. Deploys are Kamal to hosts I run, in the EU. The public host serves this site; the collector runs on a homelab hypervisor on the same physical machine as the database, so there is no tunnel and no second database. The app node that renders and publishes runs where the GPU is.

Some choices were made with the log file in mind. Ecto query logging is off in production permanently, because a bound parameter for the integrations table is an OAuth refresh token and the schema's redaction covers inspect, not SQL params. Object deletes are disabled behind an explicit flag when the remote store is paired. Inbound webhooks are HMAC verified before they reach a controller. The content security policy is built per request so the presigned-media host is the only third-party origin allowed.

Measurement and prediction

The intelligence in this system is the part I care most about, because the making is a means to it. Three subsystems:

The corpus watches 15 channels in the same genre and currently holds about 750 of their videos. Every upload is discovered within the hour and then sampled for view velocity at 1 h, 6 h, 24 h, 48 h and 7 days from its own publish time, which is what makes an early reading comparable across videos. Public transcripts are fetched and run through a language-model structure pass (segment kind, hook, round boundaries) via the Anthropic Batch API, most-viewed first, capped per hour, deduplicated with an advisory lock, and a transcript refused twice is never resubmitted. A daily snapshot per channel records subscribers, views and video count so growth can be read against upload rate.

The ledger writes every production choice at the moment it is made and keys it so it joins to the outcome: one record per generated image candidate under a priced run, an append-only decision per picture change, a production receipt on every render (bed, renderer build, composition version, thumbnail source), and an upload ledger on every publish that records what was declared and what the public page later showed. Hypotheses are rows with arms, and a thumbnail A/B is an experiment, not a column.

The retention overlay places YouTube's audience retention curve on the exact scene timeline the render recorded, so "the choose beat is too long" is a question with a number rather than an opinion.

What all of that has said so far is worth stating plainly. Across 713 videos the within-channel spread from the 10th to the 90th percentile of views is 15x to 500x, and no feature of the video I could measure predicts where it lands: not hook wording, not title convention, not length, not time to the first round. The channels that win publish thirty long-form videos a month and collect the tail. Two claims an earlier plan had marked as measured were wrong, and that plan now says so. The consequence was a change of strategy from tuning openings to raising production rate, which is the cheaper answer and the honest one.

In progress. The velocity snapshots exist so a 7-day outcome can be forecast from a video's first six hours; the corpus now has enough rows to fit that and it's the next piece of analysis work. The ledger exists so the effect of a production choice on retention can be estimated once enough episodes carry receipts; the receipts have been landing since 7 September and the estimate is not yet made. I'd rather list these as open than dress them as done.

Working with language models

Drafting goes through plain tool use rather than constrained decoding. Measured on one 30-round set, constrained structured output gave about 2 usable sets in 12, a strict tool 0 in 2, plain tool use 6 in 6, because the constrained path strips array minimums and an empty list becomes a legal first move for the grammar. Output limits are set explicitly and a truncated response is refused rather than decoded, because a cut-off tool call still parses and arrives as a quietly shorter set. Batch work is submitted with a cost ceiling after learning that the provider will hold a batch it cannot yet bill and then bill it in full.

Most of this code was drafted with an AI coding assistant, and none of it merged unread. That's a way of working I'd bring anywhere: the assistant is fast and the review is strict, the invariants live in a file the assistant reads, and a fix that can't name its root cause isn't a fix.

How I work

Status

Alter runs my own channels and isn't taking new accounts. If you'd like to talk about the system, the code, or the work, write to me.