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:
-
The always-on collector node must never hold a render or publish
queue. A test asserts the role-restricted queue set, because
Elixir's
Configdeep-merges keyword lists and a partial override would have quietly unioned with the full one. - Renderer changes are judged by probes that isolate one behaviour and read pixels back, never by frames of a film. Three renderer defects sat under a six-frame editorial comparison at 5/255 before the probes existed.
- A hard-won operational rule is written where the tooling reads it, not in a document nobody opens twice: the project file that steers my coding assistant holds the invariants, each with the date it was paid for and what it cost.
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
- Root cause or nothing. A blurred frame turned out to be an inverted alpha in the compositor; two months of "flat motion" was one sign. The symptom-level fix would have been a bigger blur.
- "I don't know yet" is a complete sentence. Audio and motion calls on this system are made by ear and eye after a render, and a change ships with the verdict, not before it.
- Deployed means deployed. A change is not done when it is merged; it's done when the node is running it and a test pins what it fixed.
- The note is written the day the mistake is made, with the date and the cost, in the place the next person will read it.
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.