I have a content pipeline that drafts articles, runs them past me, and publishes them to dev.to. Last week it published six in a batch. Two looked fine. The other four were live, indexed, public — and completely empty. Title, tags, cover, canonical URL, all present. Body: nothing. Four blank posts under a persona I'm trying to build credibility with.
This is the writeup of how that happened and how I fixed it, because the fix taught me something I keep relearning the hard way: the reliable automation path is almost never the obvious one, and the obvious one fails silently, which is worse than failing loud.
How four articles ended up blank
The pipeline's last step was "publish." It drove the dev.to web editor: open the new-post page, fill the title field, fill the markdown body field, hit publish. Standard browser automation. It had worked before, which is exactly why I trusted it and didn't check the output closely enough.
The bodies I was feeding it were long. Not novel-length, but 1,500+ words of markdown with code fences, the occasional non-ASCII character, em dashes, the works. And here's the thing the demos never show you: when you programmatically stuff a large string into a rich editor's input and immediately trigger save, you are racing the editor's own internal state. The editor has its own model of the document. Your injected text and its serialize-on-save don't always agree on timing. Sometimes the save fires against an editor that hasn't committed your injection yet.
When that happens, the platform doesn't error. It happily saves the document it currently believes in — which is empty. You get a 200. You get a published URL. You get nothing in the body.
That's the part that stings. There was no exception to catch. The automation reported success. The only way to know it failed was to read the published page, which I wasn't doing because, well, the step said it succeeded. A pipeline that lies about success is more dangerous than one that crashes. A crash you handle. A silent lie you ship.
So lesson zero, before any of the technical stuff: if an automation step produces an artifact, your pipeline has to read the artifact back and assert it's correct. Not "did the call return 200." Did the body actually land. I now treat any write-without-readback step as a known liability.
First fix attempt: just drive the editor better
The instinct is to fix the thing that broke. So I tried to make the editor automation more robust: wait for the editor to be ready, inject, wait again, poll the editor's internal value until it matched what I sent, then save.
This is where I want to be honest about time. I spent a couple of hours on this and it got better, not good. The editor is a moving target — its DOM, its internal state model, the events it listens to. I could get it to ~90% reliable, which for a publishing step is useless. 90% reliable means 1 in 10 of my posts is blank, and I won't know which one without checking all of them, which defeats the automation.
This is the moment that matters, and I almost always get it wrong: I was fighting the tool instead of changing the transport. When you find yourself adding wait-then-poll-then-verify scaffolding around a UI that wasn't built for you, that's not robustness, that's a smell. You're hand-stabilizing something inherently unstable. Timebox it. I now give myself a hard cap — if a tooling fight isn't won in roughly an hour, I stop and ask: is there a known-working transport I'm ignoring because it's less convenient?
There was.
The actual fix: drive edits through the API
dev.to has a real, documented write API. You can update a published article with:
PUT https://dev.to/api/articles/{id}
api-key: <YOUR_API_KEY>
Content-Type: application/json
{
"article": {
"body_markdown": "...the full markdown body..."
}
}
This bypasses the editor entirely. No racing an editor's internal state, no DOM, no save-button timing. You hand the platform the canonical markdown and it stores it. The four empty posts already existed with the right titles and tags; I just needed to PUT the bodies into them. So the repair plan was simple: for each blank article ID, PUT the correct body_markdown. Done.
Except it wasn't, and the two ways it wasn't are the genuinely useful part of this post.
Snag 1: the request has to come from inside a logged-in browser
My first move was the clean one: fire the PUT from a script, server-side, with the API key in the header. 401. Tried it a few different ways. Still 401.
I'm not going to over-claim the root cause here — I didn't fully reverse-engineer their auth posture, and you shouldn't trust a war story that pretends it did. What I observed, repeatably, is that the external/non-browser request was rejected, and the same PUT issued from within an already-authenticated browser session — the actual tab where I was logged in — went through. So that's what I did: I ran the request from inside the logged-in page's own context, where the session and the api-key together were accepted, instead of from a cold external client.
The reusable takeaway isn't "dev.to requires X." It's: when an API rejects you from outside but the platform clearly performs the same write from its own frontend, stop trying to replicate the auth from scratch and just borrow the context that already works. You have a logged-in browser. Issue the call from there. It's not elegant. It's reliable, which beats elegant for a repair job.
Snag 2: long unicode bodies got mangled into homoglyphs
Now the worse one. I had the transport working, so I tried to get the body into the request. The naive way is to inline the markdown directly into the call — encode the whole 1,500-word body and pass it along with the PUT.
The bodies came back corrupted. Specifically, characters had been swapped for homoglyphs — visually near-identical lookalikes from other Unicode blocks. Em dashes, quotes, and a handful of letters got silently substituted for characters that look the same in the editor but are different code points. A reader wouldn't notice at a glance. A code fence would. And it meant my "fixed" article was now subtly wrong in a way that's almost impossible to eyeball.
The cause was the path the big string took: shoving a large encoded blob inline through layers of escaping and re-encoding gave something, somewhere, the chance to normalize or transcode it. Every hop a string takes through quoting, shell escaping, JSON encoding, and re-decoding is a chance for a "helpful" substitution. With a short ASCII string you'd never see it. With a long unicode body, the corruption is statistically guaranteed.
The fix that finally worked, end to end, was to never inline the body at all. Instead:
- Put the full markdown on the clipboard.
- In the logged-in browser tab, paste it into a plain
<textarea>. - Read the textarea's
.valueback — now I have the exact string the browser holds, no inline-encoding hops. - PUT that value to the API.
Conceptually:
// 1) body is on the OS clipboard (put there by the pipeline)
// 2) inside the logged-in tab:
const ta = document.createElement('textarea');
document.body.appendChild(ta);
ta.focus();
// paste the clipboard into the textarea (real paste event)
// 3) read it back — this is the clean source of truth
const body = ta.value;
// 4) issue the PUT from this same authenticated context
await fetch(`/api/articles/${id}`, {
method: 'PUT',
headers: { 'api-key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ article: { body_markdown: body } }),
});
The clipboard-and-textarea step looks absurd. It is absurd. But it works for a precise reason: the clipboard → paste → .value round-trip keeps the string as a single opaque payload inside one runtime (the browser), instead of marching it through five layers of escaping where each layer is allowed to "correct" it. The textarea is just a clean holding pen that hands you back exactly what the browser received. No homoglyphs, because nothing in the path thought it was being helpful.
I checked all four repaired articles character-for-character against the source. Clean. Done.
The lesson, generalized
Strip away the dev.to specifics and here's what I'd tack to the wall above any agent-builder's desk:
1. A write step that doesn't read its result back is not done — it's a liability with a green checkmark. The empty articles shipped because "publish" returned success. Assert the artifact, not the status code.
2. The reliable transport is rarely the obvious one. The obvious path was the editor, because that's the UI a human uses. The reliable path was the API. The obvious path failed silently; the reliable one failed loudly (401) until I gave it the context it needed, which is exactly the failure mode you want.
3. Timebox tooling fights. If you're hand-stabilizing an unstable surface with wait-poll-verify scaffolding and you're past an hour, stop. Ask what known-working transport you're avoiding because it's less convenient. Convenience is not reliability.
4. Long unicode strings corrupt at every hop. Every escaping/encoding boundary is a chance for silent substitution. The fewer hops, the fewer homoglyphs. When in doubt, keep the payload opaque inside one runtime and read it back before you trust it.
None of this is the clever-architecture content the algorithm rewards. It's the unglamorous reality of running agents that touch real systems: the model is the easy part, and the last mile is full of silent string corruption and auth that only works from the right tab. You don't design your way around that up front. You hit it, you timebox the fight, you fall back to the transport that actually works, and you write down the smell so you recognize it faster next time.
Build first. The design converges later — usually right after the fourth empty article goes live.
— Sai
If this was useful: I packaged the prompts I actually use to run autonomous agents into two field packs — 100 Prompts for Autonomous Agents and Claude Code Power-User Prompts. Same build-first mindset, ready to paste into your terminal.
Top comments (1)
The 401 from a cold external client is a known Dev.to surface, but the one that bit me harder is GET /articles/{id} returning 404 for IDs that appear fine in the list endpoint. PUT works, but you can't read back the same article via direct GET, which forces you to either keep a local index.md as source of truth or scrape your dashboard. The clipboard route around Unicode corruption is clever - I solved the same problem by stripping smart quotes server-side before the PUT, but yours is more robust to whatever the editor mutates on the way in.