How Our Auto-Poster Works
2026-08-23 · automation · engineering · infrastructure · social
Ten cron lanes, an HTML card rendered by headless Chrome, and one image fanned out to four platforms that each want something different - the architecture behind the Hockey Alchemy accounts, and the silent failures that shaped it.
At 9:00 every morning, a leaderboard card appears on the Hockey Alchemy accounts. At 10:00 a league-wide team chart follows, then a start/bench/cut card at 11:00, a player report card at 1:00, a depth chart at 2:00. Ten of these go out on a normal day, across four platforms, each with its own caption. Nobody makes them.
That is not a boast — it is the design constraint. This is a one-person project, and a social account that depends on someone feeling inspired is an account that goes quiet in February. So the posting is a system, not a habit: cron lanes call an API, the API picks a subject and builds a card, a headless browser screenshots it, and a delivery layer fans one image out to four platforms that each want something slightly different.
Here is how it fits together, and — more usefully — which parts of it were not obvious until they broke.
- Ten Cloud Scheduler lanes, one per card type, each POSTing to its own API endpoint on a fixed hour. No queue, no orchestrator — the schedule is the plan.
- Cards are HTML, rendered to PNG by headless Chrome in a separate scale-to-zero service. Every number on a card is read from the database at render time, so a card cannot drift away from the leaderboard it is describing.
- One image becomes four posts. X, Bluesky, Threads and Instagram disagree about caption length, hashtag norms, aspect ratios and file size, and each disagreement is handled explicitly.
- A single table,
social_posts, is the whole safety system — deduplication, rate limiting, subject rotation and the failure log all read from or write to it. - The interesting bugs were all silent ones. Posts dropped without an error, failures logged with a null reason, and the same player headlining two different cards on the same morning.
The shape: four layers
The system splits into four layers that only talk in one direction. Triggers decide when and what. Builders decide about whom and produce a data structure. A renderer turns markup into an image. A delivery spine — one module, the only code in the project allowed to talk to a social platform — turns that image into posts.
The rule that keeps this honest is that renderers never post and the spine never queries hockey data. Before that separation existed there were three different codebases that could each publish to X, which meant three places to fix a credential, three implementations of “have we already posted this,” and no single answer to the question of what went out yesterday.
Layer 1 — the trigger: cron, deliberately
Each card type gets its own Cloud Scheduler job, in the America/New_York zone, which makes an authenticated POST to a single-purpose endpoint on the API. The current weekday schedule:
| ET | Lane | What it posts |
|---|---|---|
| 9:00 | leaders-board | Ranked board for a rotating metric |
| 10:00 | team-chart | League chart; kind rotates by weekday |
| 11:00 | start-bench-cut | Three-player choice, rotating theme |
| 13:00 | report-card | A–F grades per facet from GAR percentiles |
| 14:00 | depth-chart | One team's projected lineup |
| 15:00 | explainer | Metric explainer, Mon–Fri, format by weekday |
| 16:00 | even-squads | “Which team would you rather?” |
| 17:00 | wowy-player | One player's 5v5 with-or-without-you |
| 18:00 | feature-card | A rotating “best available” feature |
| 19:00 | wowy-board | Top lines / pairs by 5v5 xGF% |
Alongside these sit the reactive lanes, which are not season-gated: a buzz scraper every hour, a transaction scraper every hour, and — during the season — a game-card bot that wakes every fifteen minutes through game hours to post recaps and milestones as games finish.
A queue with a content calendar would be more elegant. Cron won because of a property that only matters when nobody is watching: a cron lane that fails is still a cron lane tomorrow. There is no backlog to drain, no stuck job to clear, no state that has to be correct for the next run to work. A bad day costs exactly one card, and every lane is independently pausable — the offseason is four of these turned off with no deploy.
Weekday rotation happens one level down. The explainer lane picks its format from the day of the week; the team-chart lane picks its chart kind and metric the same way. That gives five distinct posts out of one cron entry, and it means the variety lives in code that can be read and tested rather than in a calendar someone has to maintain.
One global switch, POST_SCHEDULED_CONTENT, gates every scheduled lane. Reactive posts have their own gates. It has been worth having a single environment variable that stops all original content without touching a scheduler.
Layer 2 — the build: picking a subject without repeating yourself
Most lanes have to answer “about whom?” before they can build anything, and the naive answer — take the top of the relevant leaderboard — fails immediately. The leaderboard barely moves day to day, so the same three names would headline every card forever.
The first fix was per-card memory: each lane skipped subjects it had featured in the last fourteen days. That was not enough, because a lane can only see its own history. Over a 45-day window the system produced 3 same-day double-features and 22 repeats inside seven days — different cards, same player, same morning.
So the guard moved up a level. Every card that picks a subject now filters its candidate pool against the subjects any card has used recently. That turned out to be a string problem more than a scheduling one, because cards spell subjects three different ways in their identifiers — a slug (connor-mcdavid), title-case words (Alex_DeBrincat), and plain names. A raw substring test missed most collisions, and produced a memorable false positive in the other direction: the team code VAN matched inside “Donovan.” Everything is now collapsed onto one hyphenated alphabet and matched on whole tokens.
The last detail matters more than it looks: if every candidate is stale, the filter returns the pool untouched. A variety guard that can empty a slot is worse than a repeat. The 18:00 feature lane takes the same idea further — it starts at the weekday's assigned format and falls through the remaining formats until one produces a card, so “no eligible subject today” degrades into a different card rather than silence.
Layer 3 — the render: HTML, then a screenshot
Cards are built as a self-contained HTML document — inline CSS, no external assets except logos — and POSTed to a small Cloud Run service running headless Chrome, which screenshots a single .card element and returns a PNG.
POST /render { html, selector: ".card", width, height } -> image/png
Choosing a browser over a plotting library is the decision I would defend hardest. Cards are typography and layout — ranked rows, grade chips, badges, lineup grids — and CSS grid does that natively while a plotting library fights you at every step. It also means the card design system and the website's design system are the same thing, and a card can be iterated on in a browser tab before it ever touches the poster.
The costs are real and specific. The renderer scales to zero, so the first card of the day pays a cold start that can exceed the render timeout. The fix is a warm-up: hit the renderer's health endpoint before fetching any cards, which blocks until the container is up. That revealed a second cold start — for the cards that screenshot a live page rather than posted markup, the page itself fetches its data from the API, which also scales to zero. A cold API made those fetches exceed the renderer's wait, which surfaced as a failed render and a fallback to a plainer image. Both services now get woken before the first post.
Rendering is capped at two concurrent jobs with a 60-second timeout, which is less about the renderer's capacity than about not letting a wedged render hold an API worker thread while user traffic is trying to use it.
One property of this layer is worth stating plainly, because it is what makes an unattended system trustworthy: every number on a card and in its caption is read from the database at render time. Nothing is written into a template. An explainer card cannot claim a leader who has been passed, because it does not know who the leader is until the moment it is drawn.
Layer 4 — delivery: one image, four platforms
This is where most of the surprise lives. The four platforms are not four calls to the same idea; they disagree about nearly everything, and each disagreement is a rule in code.
| Platform | Caption | Image |
|---|---|---|
| X | Tight; emoji stripped; 1–2 team tags plus #NHL | Shows uncropped only between 4:5 and 16:9 — pad the short side |
| Bluesky | Truncated at 300 graphemes; one team tag | Native aspect kept; blob capped near 976 KB |
| Threads | One topic tag | PNG as-is |
| Name-rich; a block of up to 12 tags including #PlayerName | JPEG only, 0.8–1.91 aspect; letterboxed if outside |
Captions are assembled in three optional layers: a per-platform body override falling back to a shared text, an Instagram-only “featuring” line for search, and per-platform hashtags. The shared text deliberately carries no hashtags at all, so a caller that wants full control simply supplies its own and gets it verbatim.
The hashtag rules encode a real difference in platform culture. Instagram rewards a block, and most of its reach comes from player-name tags — which meant transliterating accents before stripping to alphanumerics, because the naive version turned Montréal into #MontralCanadiens. X and Bluesky punish tag stuffing, so they get the official fan hashtag each fanbase actually follows, and nothing else.
Aspect ratio caused the most visible failure. A tall card outside X's no-crop band gets cropped in feed, so an early fix padded everything to 16:9 — which letterboxed tall cards into a narrow center strip with enormous side bars. The correct rule is to pad the shorter side just far enough to enter the band, at full size, and to leave cards already inside it alone. Instagram gets the same treatment against its own band, with the padding sampled from the card's own edge pixel so the fill reads as part of the card rather than as empty space.
There is also an economic rule. X moved to pay-per-use API pricing in February 2026: $0.015 to create a post, and $0.20 if it contains a URL. A link makes the identical post more than thirteen times as expensive — and it costs reach on top of that. The size of the link penalty is not a published figure and the estimates vary wildly, but none of them are small: Musk has described it as 50–90%, and independent tests in early 2026 measured link posts reaching a fraction of an otherwise identical text-only post. So cards go out with no link in the body, and the link, when it is worth paying for, goes in a self-reply — which is also the workaround practitioners independently arrive at. The card has to be able to stand alone. That constraint improved the cards.
The safety system is one table
Everything that keeps an unattended poster from embarrassing itself reads from or writes to a single table, social_posts — one row per platform delivery, recording the post type, an entity id, the exact text, the resulting URL, and any error.
That entity id is the load-bearing part. It is built to be unique per subject and per day, like swarm_finishing_2026-08-23. Before posting, a lane checks whether that id has already been posted or is pending, which makes every endpoint idempotent — a scheduler retry, a manual re-run, and a duplicate delivery all collapse to the same no-op.
A rolling rate cap sits on top: at most ten cards an hour, five buzz posts a day, three trade posts an hour. That cap contained the single most instructive bug in the system. It counted rows, and every post writes one row per platform — so a four-platform fan-out counted as four cards, and every cap in the system was silently enforcing a quarter of its stated value. The counter now counts distinct entity ids. Nothing errored while this was wrong; the system simply posted less than it was configured to.
Which is the theme. When the cap does drop a card, it now logs loudly, because a scheduled post that vanishes is indistinguishable from one that was never scheduled.
Failures used to be recorded with no reason attached
Each platform poster logged exactly why it failed and then returned nothing. The reason existed — in the logs, for a while — but never reached the row. Over one seven-day window, 8 of roughly 192 platform deliveries failed (four Instagram, three Threads, one Bluesky) with the error column null. The post-mortem material was gone.
The obvious fix — thread an error return through every failure path in four different posters — was about fifty call sites of pure tedium. Instead, each delivery runs inside a context manager that attaches a temporary handler to the module logger, collects anything at warning or above, and hands the joined text to the ledger row when the delivery reports failure.
with _capture_failure() as cap:
url = self._post_to_bluesky(text, image)
self._record_post(..., status='posted' if url else 'failed',
error=None if url else cap.reason())
It is a slightly unusual use of the logging module, and I would not reach for it in a library. In an application where every failure path already logs its reason correctly, it converted a class of undiagnosable failure into a debuggable one for about twenty lines.
What the system deliberately will not do
Three limits are policy, not backlog. It never posts an opinion it cannot compute — every caption is assembled from numbers the card is already showing, which is the same house rule this blog runs on. It does not send unsolicited replies on X, which is against platform policy for automation; automatic replies are limited to inbound mentions, and anything more editorial is routed to a human for approval first. And it does not treat a quiet failure as success: dropped cards, failed deliveries and skipped lanes are all recorded with a reason.
There is also an admin path that renders a card and returns the exact per-platform captions it would post — without publishing and without writing to the ledger. Being able to see the real output of a change before it ships to four platforms is what makes the rest of it comfortable to run unattended.
What I would tell someone building the same thing
The scheduling is the easy part. The parts that took real work were the ones that decide whether the output is worth looking at: not repeating a subject across independent lanes, never letting a slot go empty, keeping every number live rather than templated, and respecting that four platforms are four products.
And the failure mode to design against is not the crash. It is the version where everything reports success and the account slowly says less than it should — a cap enforcing a quarter of its value, a card dropped without a log line, an error saved with a null reason. An unattended system is only as trustworthy as its ability to tell you it did nothing.
The cards themselves are built from the same models the rest of the site runs on — you can see the boards they read on the GAR leaders page, and how those numbers are produced on the methodology page.
Frequently Asked Questions
How does Hockey Alchemy post to social media automatically?
Each card type has its own Cloud Scheduler job that POSTs to a single-purpose API endpoint at a fixed hour. The endpoint picks a subject, builds a self-contained HTML card, sends it to a headless-Chrome service that returns a PNG, and a delivery layer publishes that image to X, Bluesky, Threads and Instagram with a caption tailored to each.
How do you stop an automated account from repeating itself?
Every published post writes a row to a social_posts table keyed by a per-subject, per-day entity id. Before posting, a lane checks whether that id already exists, and filters its candidate subjects against everything any card featured in the last seven days. If every candidate is stale the filter returns the pool untouched, so a slot repeats rather than going empty.
Are the numbers on the cards generated in advance?
No. Every number on a card and in its caption is read from the database at render time rather than written into a template, so a card cannot describe a leaderboard that has since changed.
Does a human review the automated posts?
Scheduled cards publish unattended, but the system only states things it can compute from the card's own data. Automatic replies on X are limited to inbound mentions, as platform policy requires, and anything more editorial is routed for human approval first.
More from The Lab
Talent vs Production: Why the Box Score Misleads
Two wingers score 20 goals; only one of them will do it again. The gap between what a player produced and the process underneath it is the most useful idea in hockey analytics - and it is the split our expected-goals and finishing models are built to make.
How Good Is Our WAR Model? We Ran Four Tests and Lost Two
We tested our WAR model against HockeyStats and Evolving Hockey on the four questions an honest player-value model has to answer, matched to each benchmark's published protocol. Summed to a team it loses both tests - it forecasts next season worse than simply reusing last season's standings. Measured per player it wins both, repeating at 0.77 against 0.62 and 0.46.
The Model That Earns Its Keep
Is our projection actually better than a simple average? We ran the honest test - forecasting one player's WAR next season, walk-forward across thirteen seasons - and beat the baseline that is supposed to be unbeatable. Then we ran it on goalies, where we lose.