The Simple Mental Model That Failed
Last week I finished writing an essay for this newsletter. I wrote it once. I published it three times — Substack (the original), Dev.to (English cross-post), Tistory (Korean rewrite).
My mental model going in was: write once, redistribute. Source of truth on Substack, automation handles the rest. The posts on Dev.to and Tistory are "the same post," just on different platforms.
By the end of the week I had written five essays, run the full three-channel pipeline on each one, and learned something I probably should have anticipated: there is no such thing as "the same post." Each platform rendered the same starting content into a different object, and the pipeline only started working when I accepted that and built for each platform's native primitive.
This post is what I noticed.
What "Primitive" Means Here
Each platform has an atomic unit the reader is actually consuming. Get the unit right, the rest of the post works. Get it wrong, and the post technically publishes but lands flat.
For the three platforms I ran this week:
Substack's primitive is the sentence, delivered to an email inbox. A subscriber opens their inbox in the morning. Their posture is sit-and-read. They committed to hearing from this author in this voice. What matters: the opening hook that survives "do I open this email?", the rhythm of the prose, the feeling of being written to rather than at.
Dev.to's primitive is the snippet, discovered in a feed. A developer opens Dev.to during a work break and scrolls. Their posture is scan-first, save-for-later, read-if-it-looks-useful. What matters: the first-paragraph payoff, the code blocks that can be screenshotted, the tags that make the post findable a week later.
Tistory's primitive is the local context, arrived at via Naver search. A Korean reader searches for something like "AI agent persona" in Korean and clicks the third result. Their posture is compare-with-other-sources, skim-for-answer. What matters: Korean sentence rhythm that doesn't feel translated, reference points a Korean builder would recognize, canonical URL so the SEO credit routes correctly.
"The same post" collapsed those three primitives into one thing in my head. The pipeline I built assumed that's what they were. It wasn't.
Where the Pipeline Broke
The breaks weren't dramatic. They were a series of small misfires that, taken together, forced me to redesign each platform's adaptation step.
Substack's default avatar became my cover image, once.
When an already-published Substack post has no cover image set, the social-card og:image pulls the generic publication avatar — a small gray sphere that, scaled up to 1456×819, looks like an out-of-focus moon. I tried retroactively updating the cover via the API; the draft state updated, but the public page rendering didn't. Substack appears to snapshot the post's social card at first publish. The cover decision has to happen before publish. I added cover generation to the pre-publish pipeline after losing one post's cover to this.
Dev.to rejected my default Python User-Agent with "Forbidden Bots."
The Dev.to API works fine with a valid token — but only if the request includes a non-default User-Agent header. python urllib sends Python-urllib/X.Y by default, and Cloudflare in front of Dev.to returns 403 for that string. The fix is a one-line header addition, but it cost me an hour of debugging "why is my valid token being rejected?" when the problem was never the token.
Tistory's editor has two editors, and I was writing to the wrong one.
The Tistory write page renders a TinyMCE iframe as the main editor. A CodeMirror instance also exists in the DOM as a backup for HTML-mode users. I found CodeMirror first and wrote my injected content there. The UI saved the TinyMCE content, which was empty. I got eleven empty posts published as drafts before I noticed. Now the injection routine tries TinyMCE's activeEditor.setContent(html) first, and CodeMirror is a fallback for HTML-mode users.
Tistory's visibility radio was clicking but not selecting.
The publish modal has three visibility radio labels — public, public-protected, private — each written in Korean. My Playwright script searched for the Korean word for "public" and clicked the first match. The click registered in the event log. The saved post was still private. The issue was that my selector was finding the first instance of the word — which turned out to be a header label elsewhere on the page, not the radio option. Fixing it meant scoping the text search to the modal and preferring [role="radio"] / <label> elements with exact text match.
The Korean translation came out technically correct but translation-y.
The first-pass Korean rewriter produced grammatically fine Korean that a native reader would immediately clock as translated. Common patterns — awkward passive constructions, the same noun-phrase ending repeated across three consecutive sentences, English word order showing through in Korean syntax. I added a second-pass editor that takes the first pass as input and specifically targets these translation-y patterns. I also added a smell score — a cheap regex-based heuristic counting six known patterns — so I could measure whether the second pass was actually improving output. On one post the first pass scored 14, the second scored 3. On another post, the first pass was already clean (scored 3), and the editor responsibly left it alone. I take the second result as more important than the first: the pipeline knows when not to edit.
What Each Platform Actually Needed
Once I accepted "same content" was a category error, the adaptations started looking like three different products.
Substack adaptation
- Title treated as email subject line, not blog headline. Punchy, specific, survives inbox clutter.
- Cover image generated before first publish. Typographic, dark navy with accent color, consistent series aesthetic.
- Paywall section markers — none yet, but placeholder structure so I can add them later without rewriting.
- Markdown → ProseMirror node tree (Substack's body format isn't raw markdown; the API needs it serialized to their doc structure).
Dev.to adaptation
- Tags normalized to lowercase alphanumeric (Dev.to strips anything else).
-
canonical_urlpointed to the Substack post so search engines credit the original. -
main_imagesourced from the already-uploaded Substack CDN URL. Dev.to has no image upload API — their parser fetches external URLs — so reusing Substack's CDN saved a redundant upload step. - Filter in the image URL extractor skips the Substack subscribe-card avatar so Dev.to doesn't pick up a blurry placeholder as the hero image.
Tistory adaptation
- Two-pass Korean rewrite, with the second pass measurable and skippable if the first pass is already clean.
-
canonical(as a blockquote link in the body) pointing back to Substack. Naver ignores canonical URLs for ranking, but Google still respects them, so the tag is more about cross-platform SEO hygiene than Naver ranking. - Netscape cookies merged from
tistory.comandkakao.com(Tistory auth goes through Kakao). - Idempotency check: if
blog_drafts.tistory_urlis already set, skip re-publish unless--force. Eleven duplicate test posts taught me this one. - Playwright UI automation as the transport layer. Tistory's Open API shut down in February 2024 and isn't coming back.
The Platforms Where I Stopped
Medium was on my list. I wrote the publisher module, added the env token field, and then discovered the Integration Token feature now requires Medium Partner Program membership — which isn't available to all accounts. Shipping it behind a paywall wasn't worth it for one channel.
Naver Blog has an official OpenAPI, but the content format is constrained enough (limited HTML, external link penalties) that automating it would require another rewrite pass — a third-pass "Naver blog format" rewriter. That's on the list for later, not this week.
I note both of these because the multi-platform question isn't just "which platforms work?" It's "which platforms are worth the adaptation cost?" A platform with a non-trivial rewrite pass costs more than the same reach on a platform that already speaks my primitive.
What I Actually Learned
Same content is a category error. The words are the same input. The artifact produced by each platform is a different object. Treating them as one job — "publish the post everywhere" — hides the fact that each adaptation is non-trivial and each platform's primitive is different.
Pipelines should speak each platform's native language. A Tistory-shaped post is not a machine-translated Substack post. It's a different artifact with different idioms, different reader context, different SEO concerns. The pipeline that glues them has to make the translation at the platform's level, not the language's level.
Measure the adaptations, not just the publishes. I almost shipped a Korean rewrite that was technically fluent but read as translated. The only reason I caught it was the smell-score regex I added as a sanity check. The pipeline's quality gate has to be at the rendered output, not at the API status code.
APIs die. Plan for UI automation. Medium and Tistory both used to have Open APIs that worked. Neither does now. Playwright-based publishing is uglier than API-based, but it survives policy changes that break APIs. Anything publishing-adjacent should have a Playwright fallback path.
For Other Builder-Writers Considering Multi-Platform
Three things I'd do differently if I were starting this week rather than ending it:
1. Write down each platform's primitive before adapting for it.
What is the reader's posture? Where did they arrive from? What format does the platform natively render well? The answer to those three questions determines most of the adaptation work.
2. Build idempotency from the first post, not the twelfth.
I published eleven duplicates on Tistory before I added "if already published, skip" logic. Ten minutes of upfront design would have prevented ninety minutes of cleanup.
3. Treat each platform's automation as a separate product with its own failure modes.
The Dev.to 403, the Substack cover re-render quirk, the Tistory editor ambiguity — none of these share a root cause. They each required platform-specific debugging. Pretending the automation is one system creates the illusion of a single code path where there are actually three.
The Close
The instinct to cross-post from one source to many channels is correct. The hidden cost is the adaptation work that you don't see until you ship. Same words in, different objects out.
After a week of running this in anger, my conclusion is that cross-posting is really cross-rendering. The same source, rendered by different primitives, into different platforms' native formats. The pipeline that makes this pleasant to run respects each platform's primitive rather than forcing uniformity across them.
If your source content is generic enough that the rendering difference doesn't matter — short announcements, product launches, pull-quotes — the naive approach works. For anything essay-length, opinion-driven, or audience-differentiated, the primitive shift is real, and the pipeline has to know about it.
Top comments (0)