# Awesome Content Publisher

> Publishes a prepared batch of scheduled posts to the user's own accounts through their live browser (Playwright MCP bridge): bridge and format preflights, per-platform login checks that never automate a login, a persistent ledger that prevents duplicate posts across restarts, timezone-mapped scheduling that can idle for days, sequential human-paced posting with read-back verification and a confirmation gate before anything goes public, plus an opt-in engagement harvest. Use when asked to publish a campaign, post prepared files to accounts, post on schedule, or 'опубликуй посты'. Do not use to write the posts (awesome-content-campaign, awesome-content-repurpose) or to learn a site's style (awesome-style-mimic).

- Skill: `khasky/awesome-content-publisher` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add khasky/awesome-content-publisher`
- Raw SKILL.md: https://api.skillmd.com/api/skills/khasky/awesome-content-publisher/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Marketing & Growth
- License: MIT
- Author: khasky (https://skillmd.com/u/khasky)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/khasky/awesome-content-publisher

---


# Post Publisher

Take a folder of dated post files (the output contract of `awesome-content-campaign`, or anything matching it) and publish them to the user's own accounts, on schedule, through the user's own logged-in browser — the Playwright MCP `--extension` bridge to their live Chrome, so real sessions are used and no credential is ever handled.

Why the ceremony: every post is an outward-facing, public action on an account the user cares about. A duplicate post is embarrassing; a burst of scripted posts can get a legitimate account rate-limited or flagged; a post to the wrong group is not deletable by pretending it didn't happen. Each gate below closes one of those doors before it opens.

## Core principle

**NOTHING POSTS UNTIL FIVE THINGS HOLD:** the bridge is verified, the source is validated, login is confirmed on that platform, the ledger says this post has not been attempted, and the user has approved the run plan. And four things never happen at all: this skill never types credentials or automates login/2FA, never solves or bypasses a captcha or bot challenge (pause and hand the browser to the user), never deletes or edits a published post except on the user's explicit per-item request, and **never calls a platform's API**.

Everything this skill does on a platform, it does the way a person does it: by looking at the page and clicking on it. Never navigate the tab to an API URL, never `fetch`/`XHR` an endpoint from injected code, never reconstruct a request the page makes — not to publish, not to count posts, not to settle whether something exists. These requests do not carry what the platform's own client sends, they arrive from an automation context, and the platform reads them as exactly that: one run pointed the tab at `minds.com/api/v1/entities/entity/<guid>` and got a Cloudflare block page, which is the account being noticed. A ban costs the user everything the account holds, and no verification is worth it.

Watching is not calling. `browser_console_messages` and `browser_network_requests` report what the *page itself* did — passive readings of the browser's own record, and the best diagnostics this skill has: wonderful.dev's silent publish failure was solved in one call by reading the page's console (`400 too_big, maximum 2000`). Read them freely. What is forbidden is issuing the request.

This is for the user's own accounts and own content — one account per platform. Not for mass-account posting, engagement faking, vote manipulation, or pushing promo into communities whose rules forbid it. The human pacing below exists because platforms rate-limit and flag rapid scripted bursts even on legitimate accounts; pacing keeps normal use inside a normal envelope. It is not a toolkit for operating accounts at a scale or in a manner the platform prohibits — asked for that, decline.

Bundled files (load on demand):

- `references/browser-interaction.md` — how to click, type, attach media and confirm submission on UIs that defeat ordinary Playwright actions: the click ladder, file-input scoping, submit polling, read-back baselines. Read this before the first composer of a run, not after the third timeout.
- `references/post-formatting.md` — getting the source's markdown into a composer that is not markdown: the plain-text / markdown-native / rich-editor classes, the HTML-paste route into TipTap, why bare URLs stay dead, paragraph shape, and the pre-submit format gate. Read this before the first composer too — the source files are markdown and most composers render none of it.
- `references/platform-posting.md` — per-platform posting notes: login-state signal, composer location, flow outline, read-back verification, quirks — plus the generic flow for when the live UI has drifted from the notes.
- `references/platforms.md` — not in this skill's folder: it ships with `awesome-content-campaign` and holds the canonical slug table with each platform's required target detail and media requirement. Phase 2 validates against it; the fallback when that skill is absent is the platform sections of `platform-posting.md`.

## Invocation

```
/awesome-content-publisher <posts-folder> [--dry-run] [--platforms <slug,slug>] [--now] [--harvest]
```

- `<posts-folder>` — folder of post files; a `campaign.md` manifest beside them is used when present. No argument → ask for the source (folder, or another location the user names).
- `--dry-run` — run every preflight and print the full run plan; nothing is posted.
- `--platforms` — restrict to a subset of the canonical slugs.
- `--now` — ignore scheduled times; publish the backlog in order, respecting the same-platform spacing in Phase 7 (still gated below).
- `--harvest` — publish nothing; read the engagement of posts already in the ledger (Phase 10).

## Phase 0 — Interview

Ask only what the flags didn't answer:

1. Source — the posts folder (or other source the user names).
2. Pacing — default: publish at each post's scheduled time from the filename/frontmatter. Alternatives: a fixed interval the user names, or `--now` backlog mode. Whatever the mode, per-platform safety spacing (Phase 7) still applies.
3. Overdue policy — posts whose scheduled time is already past: publish now in order with spacing (default) · skip and report · shift the whole schedule forward to start now. Never silently pick.

## Phase 1 — Preflight A: the bridge

The Playwright MCP `--extension` bridge to the user's own browser is required — that is where the logged-in sessions live. Extension mode attaches to Chrome or Edge; nothing else. List tabs first and read what you get:

- A lone `about:blank` → the bridge is not attached; you are on a spawned clean browser with no sessions, which would only hit login walls. Stop and have the user connect it. Extension missing entirely → point them at the install, in Chrome or Edge: <https://chromewebstore.google.com/detail/playwright-extension/mmlmfjhmonkocbjadbfplnigmagldckm> (source and setup: <https://github.com/microsoft/playwright/tree/main/packages/extension>), then re-run the preflight rather than proceeding on a spawned browser.
- A lone `connect.html` (the bridge's own relay page) → the bridge is attached and the user simply has no other tab open. This is normal. Never touch that tab; open one working tab beside it.
- The browser tools are listed but the session says the MCP server needs authentication → the bridge is configured and not connected. That is the token case, not a missing bridge and not a reason to look for another way to reach the platforms.

Run the target gate before anything else, and get a yes. A user may keep one browser for daily work and another holding the accounts this run posts to, each paired to its own Playwright MCP server with its own extension token — and the two are indistinguishable from a tab list. The full procedure is in `references/browser-interaction.md`: ask which bridge when the session exposes more than one, probe the engine and the signed-in identity, then state the browser and the profile and wait for confirmation. Naming it later in the run plan is not enough; by then the preflights have already run in whatever browser answered. Wrong browser, or no bridge at all → ask for that browser's `PLAYWRIGHT_MCP_EXTENSION_TOKEN`, shown on the extension's status page opened in it, put it in the MCP server entry for that browser, restart, and re-run the gate — the procedure and the status-page URL are in `references/browser-interaction.md`. Continuing in whatever browser happens to be attached is the one thing that is not allowed.

Open ONE working tab and reuse it for everything. Warn the user the browser is busy while a publishing pass runs.

## Phase 2 — Preflight B: source scan (hard stop on any defect)

Scan the source and validate every post file:

- Filename parses as `YYYY-mm-dd_HH-mm_<pub-timezone>_<title>_<platform>.<ext>` — exactly 5 `_`-separated fields, platform one of the canonical slugs. The slug table lives in `references/platforms.md`, shipped with `awesome-content-campaign`, and that one file is also where each platform's required target detail and media requirement are recorded; read it and validate against it. When that skill is not installed, fall back to the platform sections of this skill's own `references/platform-posting.md` — a slug with no section there is a slug this skill cannot post, which is a defect to report rather than a platform to improvise.
- Readable format: `.md` with frontmatter (preferred), `.txt`/`.html` with a metadata header block, `.csv` (header + row). `.pdf` is not machine-readable here — stop and point to the `.md` sources the campaign keeps alongside.
- Frontmatter agrees with the filename (platform, date, time, timezone); frontmatter is authoritative, but a mismatch is a defect, not a tiebreak.
- Required target detail present where the Target column of the slug table names one. A `facebook-wall` post with no target does not say which surface it is for, and guessing between a Page, a personal timeline and a group is not allowed.
- Declared `attachments` exist on disk — entries are a plain path or `{file, alt}`, resolved relative to the campaign folder; a platform the table marks media-required with no attachment is a defect. This skill never makes the missing image, and never picks one from the folder. Where the source folder holds a set of generated candidates and no post names one, the writing skill's pick gate was never answered: say so and send the user back to it, because choosing the campaign's picture is the user's call and it is not delegated to whatever runs last.
- An image in the folder that only some posts use is worth reporting. When the source carries media and posts for platforms whose Media column reads `optional` declare no attachment, list them: the writing skills attach one image everywhere the platform takes one, so the gap usually means a post file was written before that rule or edited by hand. It is not a hard stop — the post files are authoritative and this skill does not add attachments on its own — but the user should hear it before the run, while adding them is still cheap.

- Count each post's hashtags against its platform's row in the hashtag table (`references/platforms.md`) and carry the deltas into the run plan. This is a report, not a stop: the run plan states which platforms are off-norm and by how much, so the Phase 6 gate and the Phase 7 question are answered once, before the first composer, rather than post by post at 2 a.m. A post whose body length is already at the platform's cap is flagged here too — adding a tag there costs a sentence, and that is the user's trade to make, not the run's.
- Character caps are part of this scan, not a discovery for the composer. Measure every post against its platform's cap the way the platform counts (X and Mastodon bill a URL at a fixed 23; Bluesky counts graphemes; most others count characters) and report every over-cap file with the overshoot. A cap found at the composer costs a wasted fill; a cap found here is a decision the user makes with the whole picture in front of them. Some caps are server-side and invisible in the UI — `wonderful-dev` accepts a 2600-character body in its textarea and the API rejects it with `too_big, maximum 2000` — so the known ones live in the platform notes and the rest surface as a failed submit whose console says why.

**Zero posts found, or ANY validation error → hard stop.** List every defective file with what is wrong with it and how to fix it. A publisher that guesses its way past a malformed schedule posts the wrong thing at the wrong time.

Output of this phase: the platform set, post count per platform, date range — the input to the next two phases.

Reconcile any platform list the user named against that set before planning anything. `--platforms`, or a list given in conversation, is a *request*, not a fact about the folder. A user naming six platforms may be naming ones the campaign never wrote for, or one surface when the files target another. Report the difference explicitly and in the user's terms — "threads and telegram have zero posts in this campaign"; "the 28 facebook posts are `facebook-wall` targeting the Page, there are no `facebook-group` posts" — then plan only what exists. Never silently substitute a neighbouring slug, and never let a named-but-absent platform vanish from the report. If the gap means the user wants content that does not exist yet, say so: writing it is `awesome-content-campaign`'s job, not this skill's.

## Phase 3 — Preflight C: login per platform

For each platform in the set, navigate to it and read the logged-in state (signal per platform in `references/platform-posting.md`; read-only — no clicks into account settings). Classify: logged in · logged out · unknown (say why).

Any platform not logged in → present the list and offer the two honest options: wait (the user logs in manually in their browser — never in this skill's tool calls — then re-check) or skip those platforms and continue with the rest. Record the choice; skipped platforms appear in the final report as skipped, not silently absent.

Bio-link check — for platforms whose posts rely on a bio CTA ("link in bio" — typically `instagram`, `tiktok`; authoritative source: the manifest's Profile prerequisites section, falling back to the posts' frontmatter `links` when there is no manifest): open the user's own profile read-only and verify the bio actually contains the required URL. Missing → offer, in this order: the user sets it themselves (wait, then re-check) · this skill sets it — the ONE profile field it may ever edit, only after an explicit per-URL confirmation, done once before the first affected post and recorded in the ledger · continue anyway (the CTA will point at nothing — say so plainly) · skip the platform. A bio that points at a link aggregator (linktree-style) is never modified — that goes to the user.

## Phase 4 — Timezone map

Three timezones are in play and the ledger records all three: the publication timezone from the posts (IANA name from frontmatter), the browser's (`Intl.DateTimeFormat().resolvedOptions().timeZone` via a page evaluate), and the system's. Compute each post's due moment from its scheduled time in the publication timezone, converted per-date (IANA rules handle DST; never a fixed offset pinned at session start). Sanity-check: if browser and system disagree, say so — the schedule follows the publication timezone regardless, but the user should know their environment is split.

## Phase 5 — The ledger (persistent state, survives restarts)

`publish-state/ledger.json` beside the posts folder. Session memory is not state — a crashed or restarted session must resume without double-posting, and only a file on disk guarantees that.

Per post: file, platform, scheduled time (pub TZ) and computed due time, status (`pending` · `posted` · `unverified` · `failed` · `skipped` · `pending-approval` · `superseded`), attempt count, posted-at, post URL when captured, the URL the platform's own uploader returned for the attachment, the read-back evidence in one line, and `degraded` naming anything the post went out without (alt text, a prerequisite that was unmet). An entry that replaces another, or was replaced by one, carries the other's URL. Plus the timezone map, the interview answers, and an `incidents` list.

Recording the uploaded asset's URL is what makes a wrong image detectable. With it, the Phase 7 audit compares the published `img` against the URL this file's upload returned; without it, any image on the page passes for the right one — which is how an article shipped carrying a stranger's picture as its cover.

A `skipped` entry says whose decision it was. "The user chose to skip rather than log in", "the user has not decided which subreddit this belongs in", "the signing prompt is the user's to drive" are decisions. "The composer never opened" is a failure. The two read identically in a status column and mean opposite things to the person reading the report, so the reason goes in the entry and the report repeats it.

Write the ledger through a small helper beside it, not by re-emitting the file. Regenerating the whole JSON through the model on every state change is expensive and one interrupted turn away from a truncated ledger. A few-line script in `publish-state/` taking platform, status, URL, evidence and `degraded` keeps each write atomic and cheap; create it on the first write of the run.

`incidents` records outward-facing side effects this skill caused that were not one of the planned posts — a stray upload, an edit, anything visible on the account that the run plan did not promise. Each entry: when, platform, what happened, the cause, the evidence that identified it, the artifact's URL, and how the user chose to resolve it. A side effect that is only in the transcript is lost the moment the session ends; the ledger is the only durable record the user can act on later.

Rules: the ledger is consulted before EVERY post and written after EVERY state change. A post in `posted`, `pending-approval`, or `unverified` is never attempted again — `unverified` (submitted, but read-back could not confirm) is resolved by checking the platform feed first: found → promote to `posted`; provably absent → back to `pending`. "Provably absent" needs a platform that is telling the truth. Truth Social served an empty profile and an empty home feed for the better part of an hour after a post that had in fact published; acting on that emptiness produced a duplicate the user had to have deleted. Where a platform is known to lag, or the profile renders no posts at all, absence is not proof — leave the entry `unverified`, re-check later, and re-post only after a read-back has succeeded there at least once in this run. On start, an existing ledger means resume: reconcile it against the folder (new files → `pending`; missing files → flag) and continue.

## Phase 6 — Run plan and the gate

Present the plan: a table of upcoming posts (file, platform, scheduled pub-TZ time, computed local time, status), the overdue handling about to apply, per-platform counts, and the platforms being skipped. `--dry-run` stops here, having printed exactly what a real run would do.

Which platforms go out is the user's tick-list, always. Present every platform the folder carries as a multi-select checkbox question, nothing pre-selected and nothing filtered out — not the ones that look risky, not the ones that failed last run, not a shortlist the run considers sensible. Where the question UI caps the option count, split across questions rather than trimming the list. `--platforms` narrows what is offered only when the user passed it explicitly.

Confirmation gate: publishing is outward-facing and effectively irreversible — get an explicit yes on the plan before the first post of the run. Ask it as a structured question (publish this plan · change something first · cancel), like every other gate in this skill: the bridge target, the wait-or-skip on a logged-out platform, the bio-link decision. A gate phrased as a closing sentence — "say go and I'll start posting" — is narration the user can answer past without registering what they approved. One gate per run, not per post; the plan is what was approved, and any change to it (user edits a post, adds files) re-presents the plan.

## Phase 7 — Publishing loop (sequential, human-paced)

Strictly one post at a time, one platform at a time — never parallel tabs, never interleaved composers. Per due post:

1. Consult the ledger (Phase 5 rules).
2. Re-verify login on the platform (sessions expire mid-campaign); logged out → pause, offer wait-or-skip for this post.
3. Capture the read-back baseline *before* composing: the profile post count, wall post count, or whatever counter Phase 6 of `references/platform-posting.md` names for that platform. Without a number taken beforehand, step 7 is guesswork.
4. Open the composer per `references/platform-posting.md`; when the live UI does not match the notes, re-derive from an accessibility snapshot — the notes are hints, the live DOM is the source of truth. Mechanics for clicks that time out are in `references/browser-interaction.md`; climb its ladder instead of repeating a failing click.
5. Fill like a person works: type with natural cadence (the type tool's delay, not instant value injection), pauses of 2–8 seconds between distinct actions, scroll to elements rather than teleporting.
   - **Media goes through the composer's OWN file input, scoped to the composer's dialog subtree — never a page-wide `input[type=file]` lookup.** Pages carry album, avatar and cover uploaders too; the first match is routinely the wrong one, and uploading into an album is a public act you cannot take back by pretending. Verify the preview appears *inside* the composer and that the URL did not change before going on. A navigation right after the upload means you hit the wrong input: stop, establish what was created, and report it before anything else.
   - Wait for the platform's upload/processing state to finish — a submit racing an unfinished upload posts the text without its image.
   - Count the attachments before submitting, and upload only once. An absent `blob:` preview and an empty `input.files` are not evidence that an upload failed — several composers show neither while holding the file perfectly well. Verify instead by a signal the composer renders *per attachment* (a per-image alt/description control, a remove button, a gallery-count class) and require exactly the number the post file declares. Retrying an upload because a probe looked empty is how one post went out carrying four copies of the same picture while being reported as text-only; the count check catches both the duplicate and the false report. Wrong count → fix it in the composer, never publish and repair afterwards.
   - When the run has an image, every platform that accepts one gets it. This is not per-post discretion: if the user supplied a picture — named in the invocation, declared in frontmatter, or sitting in the source folder as the campaign's image — then a post going out without it on a platform that supports media is a defect, exactly like a truncated body. Before submitting, assert the attachment exists; where it does not, that is a stop to solve, not a note to file. Platforms whose Media column reads `optional` still mean *the platform allows it*, never *this run may skip it*.
   - The image goes at the TOP of the post, always. Above the first paragraph, above the body, wherever the platform's own layout puts a lead image — never wherever the caret happened to sit when the uploader fired. A Tumblr post shipped with the campaign picture at the bottom because `/image` was typed at the end of the body, and it read as an afterthought. In a block editor the recipe is: click into the first block, `Control+Home`, `Enter`, `ArrowUp` — that opens an empty block above everything — then insert, then delete the stray empty block left behind with one `Backspace` from the start of the paragraph below it. Where the platform has a dedicated cover/header field (Hashnode, dev.to), that field is the top and the body carries no duplicate. Verify by position on the published page, not by presence: the image's `top` must be smaller than the first paragraph's.
   - Never conclude a composer has no media support without enumerating its file inputs and its media controls. Three platforms were written off as text-only in one run — each had a working uploader that was simply never queried for: an `input[type=file]` in the composer, a per-textarea uploader keyed to the field's id, a toolbar control that spawns the input on click. Dump every `input[type=file]` and every button whose label mentions image/photo/media before deciding.
   - Where a composer offers media, attach first and type second. At least one composer (LinkedIn's sharebox) renders its media button only while the editor is empty and removes it once text is present, so writing first makes the control vanish and the post ship bare. Attaching first is also the safer default elsewhere: several composers grow when the image lands, moving the submit button, so the layout settles before the text goes in.
   - Read the validation text next to the submit before repeating a click. A submit that does nothing is usually refusing, not broken: a required category, a missing tag, an over-length body. One run clicked Bastyon's Post button six ways across two sessions and reported the platform unpublishable; a screenshot showed a red "Please add Tags" beside the button the whole time. After the first click that changes nothing, read the composer's own text — and take a screenshot, which shows the coloured warning a DOM text dump can bury.
   - When a platform returns a hosted URL for the upload, take it from the uploader's own output and prove it is yours. Read the widget's copyable field or the network response — never regex the page for a CDN-looking pattern, because editor pages carry other articles' images and the first match is routinely one of them. Then open the URL and check its pixel dimensions against the source file. Skipping that check published a stranger's image as both the cover and the in-body picture of an article, and the run reported it as a success.
   - An upload is not attached until the page shows it as a hosted URL. A rendered thumbnail proves the browser read the file, nothing more. Poll until the `img`'s `src` is the platform's own CDN and no "Uploading"/"Loading" state remains, then save. Hashnode's cover panel says `Change cover` and paints the picture while the upload is still in flight; a save taken at that moment stored nothing, twice, and both times the editor looked perfect until it was reloaded.
   - Set alt text where the platform offers the field and the attachment carries an `alt`. Alt text is best-effort: two attempts, then move on. Some platforms' alt editors do not open through automation at all. Never let a stuck alt editor block a post, never delete-and-repost to add alt without the user's explicit request, and never drop it silently — record `degraded` in the ledger and name it in the report.
   - A post over the platform's cap is cut by whole units, never reworded. The author's sentences are the author's; an agent trimming to fit is choosing what the post no longer says, and paraphrasing to save nine characters quietly rewrites someone else's voice. So drop whole paragraphs — and check the result still parses: dropping the opener that a later "that" refers to leaves a broken post, which is why the unit to drop is chosen for what the remaining text still means, not for its length alone. Say in the report exactly which paragraphs went. Where the overshoot is a handful of characters, joining the final URL to the paragraph above it with a single newline is a shape change and fair game; rewriting the sentence is not. Where the cut would be deep enough to change what the post argues — `peerlist` demanded 1546 → 480 — that is not a trim, and it goes to the user as a choice between a much shorter post and skipping the platform.
   - Count the hashtags against the platform's norm before typing, and fix the count — after asking. The hashtag table in `references/platforms.md` gives each platform its number, and the gap between a post file and that number is routine: a campaign carries one tag set, so the same block is right for `mastodon`, three tags too many for `x`, one tag too few for `threads`, silently truncated by `instagram`, and refused outright by `peerlist`. Too many → drop whole tags from the end of the set, lowest-value first. Too few → add from the campaign's own set, never invented for the occasion. Forbidden → remove the tag line. Two things are never traded away: prose never shrinks to make room for a tag (where the cap forces the choice, the tag goes), and the user is asked before any of it happens — one structured question naming the platform, the current count, the target, and the exact tags being added or dropped. Batch that question across platforms rather than asking per post. A count that is already inside the norm is left exactly as the author wrote it; a norm the table marks *verify live* is checked in the composer or reported unverified, never guessed. Where tags belong in the composer's own field (`tumblr`, `devto`, `hashnode`, `medium`, `threads`), moving them there is part of the fix, not an optional extra.
   - Convert the body to the platform's format before typing a character, per `references/post-formatting.md`. The post files are markdown and most composers render none of it: a run that typed the source verbatim published ` ```text ` fences and inline backticks to LinkedIn, and the user had to point it out. Decide the platform's class (plain text · markdown-native · rich editor), strip or convert accordingly, and prove the class from an existing post on that platform rather than from a note — `wonderful-dev` renders fences and headings but not links, which no class predicts.
   - A platform that demands a title the post file does not carry takes it from a sibling post file, never from invention. A campaign folder written for many platforms always holds long-form units that open with an H1 — the `devto`, `hashnode`, `medium` and `substack` files — and those are the campaign's own words for this piece. Read the siblings, pick the title whose length and register fit the target, and record which file it came from. Patreon blocks Publish without one, and a run that reached for the post's own first sentence produced a title that repeats the opening line while a ready-made one sat in the folder. Only when no sibling carries a title does the first sentence become the fallback, and the report says so.
   - Every URL in the source must be an `a[href]` in the composer before submitting. Nothing autolinks reliably: emit the anchor inside an HTML paste, use the editor's own link control on a Range over the URL's text node, or — where the platform linkifies server-side — type the URL with real keystrokes followed by a space and say in the ledger that the anchor is unverified until read-back. A bare URL left as text is a defect on every platform, and it has now shipped on three.
   - Diff every block against the source, not just the totals. Rich editors *move characters* during fast insertion: Patreon published `very little tex` and `/eli5 Fourier trans` with the missing `t` and `forms` glued onto the closing URL as `eli5formst`, and Ko-fi's Froala did the same to four blocks at once — in both cases the total length looked right. After `insertText`, the caret is also not settled: press `Control+End` and wait before typing anything more, or the next characters land mid-word.
   - Compare what is in the field against the source, character for character, before submitting — not "is there text", not "is the length plausible". Read the field back and assert four things: the length equals the source's (allowing only the editor's own block-model newline differences, which change `\n\n` into `\n\n\n` and nothing else), the first 40 characters match, the last 40 characters match, and the paragraph breaks survived — count the blank lines and compare with the source. That last check is not pedantry: filling a rich editor with `keyboard.insertText` lands the whole body in one block, and a Tumblr post went out with its paragraphs gone and sentences running together (`each other.It doesn't.`), which nothing else in the pre-submit check would have caught. When the breaks are missing, insert paragraph by paragraph with an explicit `Enter` between them and re-check, rather than publishing and recording `degraded`. A tail mismatch is the one that matters: truncation eats the end, and the end is usually the link. Any mismatch → fix the field before submitting, never submit and hope. Confirm any cross-post or paid-promotion toggle is off unless the post file asks for it.
6. Submit, then poll the composer's own confirmation state in-page until it confirms or the composer closes. Do not navigate away while a submit is in flight — leaving mid-upload loses the post silently, and a transient network error in that window is exactly how a fully-prepared post ends up nowhere.
7. Read back on a different page from the composer. Counting the body text on the page you just typed into counts the composer's own contents: on `wonderful-dev`, whose composer does not clear after submitting, that scored a successful publish for a post that was never created. Navigate to the profile/feed/board, confirm the post is visible there, and capture its URL. The counter from step 3 must have moved by exactly one — that proves both existence and the absence of a duplicate. Then compare the published text's tail against the source, because a composer that accepted the full body is no promise the platform stored it: `peerlist` published a 495-character post cut two characters into its closing URL, and a read-back that searched for a phrase from the middle called it a success. Match the last line, and the link in particular — a truncated post is `degraded` with what was lost named in the ledger, and where the platform allows editing, offer the user the fix rather than applying it. Never infer "newest" from the highest ID or from DOM order; both lie on paginated and virtualized surfaces. Lazy-loaded grids return nothing before they render, which is not evidence of absence — wait and re-query. Visible → `posted` with URL. Submitted into a review queue (group approval, hackernoon editorial) → `pending-approval`, which is success for this skill — say so, don't wait for moderation. Not findable → `unverified`, no automatic retry.
8. Audit the permalink against the source before calling this post done. The read-back proves the post exists; it does not prove the platform kept what was sent. Open the permalink once and assert, in order: it is published and not a draft — a composer that reached a draft state and cleared looks exactly like one that published, so read the post's own page or the dashboard listing, never the editor; the image is present and is the right image, its `src` matching the URL this file's upload returned rather than merely being some picture on the page; the closing URL is an `<a href>` and not dead characters; the body carries zero literal markdown (`## `, `[text](url)`) wherever the platform renders rich text; the paragraph breaks and the last line survived; and no `�` sits anywhere in it. Every one of these has shipped broken while the run reported a clean success, and in each case the user found it afterwards instead of the run finding it here. A failed assertion is a defect to raise immediately, while the post is minutes old — not a line to file in the final report.
9. Write the ledger.

A draft is not a delivery, and the run does not end while one exists. Every post in the plan is published or explicitly `failed`; leaving a finished draft behind and reporting it as "one click away" hands the user the one job the run was invoked to do. A composer that reached a draft state is a post still in flight: re-read the page, find what the submit is actually refusing — a required field, a second modal, a control below the fold, a portal the click missed, a split button whose menu never opened — and climb the ladder in `references/browser-interaction.md` until it commits. Only a platform-side wall ends it: a verification gate, a rate limit stated in words, a publish control proven inert across the whole ladder. Then the entry is `failed`, carrying the draft URL, the evidence, and the exact remaining step — never a quiet success, and never a line in the report that reads as done. Where the publish path is irreversible in a way the post file does not settle (an email send, a paid audience), ask the one structured question and then publish the answer; the user's absence is a reason to wait for them, not a reason to leave a draft.

A save button that opens something is not a save. Hashnode's header `Update` opens a *Post settings* dialog — attribution, discovery, scheduling, visibility — whose own `Update` button is what commits; two runs clicked the header button, navigated away, and lost the change with no error anywhere. Substack's `Continue` opens a confirm panel whose `Update now` commits. After clicking any save/publish control, read what appeared before doing anything else: a dialog, a popover, a second button with the same word. Navigating away dismisses it and discards the edit silently.

"Saved" is the editor's opinion; a reload is the fact. Autosave badges, green ticks and rendered previews all describe local state. When a change matters — a cover, a body repair, an image moved to the top — reload the editor from its URL and re-read the field, then open the public permalink and read it there. Both Hashnode failures in one run were invisible until the editor was reloaded from scratch.

Spacing is per platform, because rate limits are. Two posts to the SAME platform stay at least 2 hours apart unless the schedule itself says otherwise — an overdue backlog does not get to fire ten posts into one feed. Between different platforms there is no wait at all: they are separate accounts on separate services, none of them can see the other's timing, and idling four minutes between a Mastodon post and a Bluesky post protects nobody while turning a nine-platform run into an hour of waiting. Move straight to the next platform once the read-back on the current one is done. `--now` mode follows the same rule.

A progress report is not the end of the turn. Summarising what has published so far is useful — the user can catch a defect while the run is still young — but it is a sentence inside the work, not a place to stop. One run wrote "8 of 23 done, continuing with bluesky…" and then ended its turn, and the user had to ask why nothing else had happened. If there is a next due post and no gate is open, the next thing after the report is the next composer.

Failures: one retry after ≥ 10 minutes, and ONLY after a read-back proves the first attempt did not land (the duplicate check is the point of the ledger). Re-prove absence immediately before the retry, not just at the time of failure. Second failure → `failed`, move on, report. A captcha, challenge, or platform warning at any step → stop on that platform, tell the user, and let them resolve it in their own browser; never attempt to click through it.

A platform refusing on its own terms is `failed`, not a retry — and it usually says so in words. Medium's pre-publish panel printed *"The author of this story has published or scheduled the maximum of two stories in the past 24 hours"* while the Publish button stayed enabled and the click landed; nothing but that sentence separated it from a broken control. Rate limits, review queues, quota walls and required-field refusals are all read from the screen, recorded with the message verbatim, and handed to the user with the draft URL. Retrying inside the window cannot succeed and each attempt is another submit on a live account.

Where a platform's own uploader is proven broken, the picture becomes best-effort — after evidence, not after impatience. Bastyon's composer accepted the file and then never finished processing it: no thumbnail through 36 seconds of polling, reproduced in a fresh tab, third occurrence across two runs. At that point publishing text-only and recording `degraded` with the reason beats failing the post outright — but only with that evidence in hand, and the report says plainly that the post went out without the campaign's image and what it would cost to add it (on a platform without editing, a delete-and-repost, which is the user's call). One empty probe is never enough; the rule against retrying an upload on a false-empty signal still holds.

**Where a published post cannot be edited, the retry budget 

…(truncated)
