awesome-content-publisher · git:20260908.306ddd8 · 2026-09-08 · sha256 e58ba9f6d860f644

awesome-content-publisher git:20260908.306ddd8A

Immutable. This exact content never changes and is served at /api/v1/blob/e58ba9f6d860f644.

---
name: awesome-content-publisher
description: "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)."
license: MIT
metadata:
  author: Khasky
  tags: ["marketing", "publishing", "social-media", "browser-automation", "playwright", "scheduling", "safety"]
  documentation: "https://github.com/khasky/awesome-agent-skills/tree/main/skills/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.

**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.
   - **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 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 is ZERO.** peerlist, x, bluesky, threads and most feeds offer no edit — the only correction is delete-and-repost, and a delete throws away the upvotes, comments and reach the post has already collected. On those platforms a submit whose outcome you cannot confirm is recorded `unverified` and handed to the user, full stop. Never re-submit "because the listing does not show it": a duplicate there is permanent and expensive, an unverified entry costs a message. This rule outranks the paragraph above; the 10-minute retry is for platforms where a mistake can be edited away.

**A surface that has already contradicted itself is disqualified as evidence for the rest of the run.** peerlist's profile listing returned a different subset of posts on nearly every load — one post, then four, then two, with the newest sometimes absent — and a run that treated two agreeing loads as proof of absence submitted the same post twice more. When a listing wobbles, stop reading it and escalate to a source that cannot lie:

- **A second surface the platform renders for the same content**, read visually like any other page: the platform's own search results for a phrase from the post, its drafts or dashboard listing, the post's permalink if a URL was captured, a scrolled-to-the-bottom profile rather than a first screenful.
- **What the page's own network log already recorded.** `browser_network_requests` shows the calls the page made while you watched it — a `200` on the create request with a returned id is the platform telling you the post exists. Read the log; never replay the call.
- The user, who can look at their own feed in a second.

**"Not in the listing" is never proof of absence.** Absence has to be proven positively, from a surface that has not already contradicted itself, before any republish anywhere — and where no such surface can be found, the answer is `unverified` and a message to the user, not a second submit. Never reach for the platform's API to settle it.

**Unintended outward-facing side effect → halt the run and disclose immediately.** If an action put something on the user's account that the plan did not promise — a stray upload, a wrong surface, an accidental edit — stop before the next post. Establish what actually happened with evidence rather than assumption (a count delta, the artifact's own page, its timestamp), report it plainly with the URL and the cause, write it to `incidents`, and ask the user how to resolve it. Do not delete or undo it on your own initiative: removal is itself an irreversible act on their account and needs the same explicit per-item request as any other deletion. Do not bury it in the final report either — the user needs it while they can still act, and burying it also robs them of the chance to stop the run before the same bug repeats on the next platform.

**A defect found after publication is a republish decision, and the old copy goes last.** When the audit or the user finds a post that went out wrong — no image, a dead link, literal markdown, a body still in draft — correct the source of truth first, publish the fixed version, verify it, and only then remove the old one, on the user's explicit request and never before the replacement is confirmed live. Deleting first turns a defective post into no post at all the moment the republish fails. **Prove the old copy's identity before deleting it**: match its permalink or its own text, never its position in a feed. One run left a `peerlist` duplicate standing because that platform's post cards nest and the action menu could not be tied to a card's text with confidence — leaving it was the correct call. Record both sides: the new entry names what it replaces, the old becomes `superseded` with its URL kept, because a deleted URL is still the only evidence of what was there.

## Phase 8 — Waiting between posts (hours or days)

Between due times, idle using whatever long-wait mechanism the agent runtime has (a scheduled wakeup, an interval loop). Long gaps are normal — a 2-week campaign means the session mostly waits. If the runtime cannot wait that long: write the ledger, print the resume command and the next due time, and end the turn — the ledger makes re-invocation resume exactly where the run stopped, with zero duplicates. Never busy-poll the clock in a tight loop.

## Phase 9 — Report

```text
Source:      <folder>   (<N> posts, <M> platforms, <date range>)
Posted:      <n> — each with platform, time, and the read-back URL
Pending:     <n> approval queues (facebook-group, hackernoon, …)
Skipped:     <n> (<platforms and why — not logged in, user choice>)
Failed:      <n> (<file: last error>)
Unverified:  <n> (submitted, not found on read-back — resolve before any retry)
Degraded:    <n> (posted, but missing something the file declared — e.g. alt text)
Incidents:   <n> (side effects outside the plan, with URLs and how each was resolved)
Remaining:   <n> pending, next due <time> (<pub TZ> / <local>)
Ledger:      <absolute path>
```

Every number comes from the ledger, not from memory. Anything unverified is named as unverified — a submitted post without a read-back URL is never reported as published. State plainly which posts went out degraded and what is now permanent about that (several platforms will not accept an image description after posting). Repeat any prerequisite the user chose to override — a stale bio link keeps mis-attributing every later post on that platform, not only the one just published. **Re-open every permalink of the run once more before writing the report.** Some defects only surface minutes later — a post held by moderation, an image that failed processing server-side, a draft that never went live — and the report is the last cheap moment for the user to act on them. Finally, move screenshots and the Playwright server's working files out of the user's project tree; a clean working copy is part of finishing.

## Phase 10 — Performance harvest (optional, read-only)

Runs only when the user asks (`--harvest`, or the offer made after a campaign's last post lands). It reads what the already-published posts did, so the next campaign is not written blind. Nothing is posted, edited, liked, or commented on in this phase — it is navigation and reading only.

Take the ledger's `posted` entries whose permalink was captured and whose posted-at is at least a few days old (a post read hours after publication measures nothing). Open each permalink read-only, paced like the rest of this skill, and record whatever **the platform itself shows**: reactions, comments, reposts, views where the platform displays them to the author. A platform that shows the author no counts gets `not available` — never an estimate, never a number inferred from something else.

Write `publish-state/performance.md`: one row per post (file, platform, posted-at, the raw counts, the permalink, harvested-on date), then a per-platform summary. The one derived number is an engagement weight — reactions plus three times comments — and it is labeled as this skill's own weighting, not a platform metric. Relative reads (which posts outperformed) are computed against that account's own median on that platform, never against a benchmark from someone else's account.

**Sample-size honesty:** under roughly ten harvested posts on a platform, report the raw rows and say the sample is too thin to rank. A recommendation drawn from four posts is noise with a decimal point.

What reads this file: `awesome-content-campaign` (which angles and times to favour next) and `awesome-content-voice` (`--update`, as evidence of which register lands). Both consume it as one input among several — it says what got engagement, not what was true or well written.

This phase never touches the engagement itself. No liking, no commenting, no following, no reading other people's accounts for comparison: that is a different activity from measuring one's own results, and this skill does not do it.

## Anti-patterns

- Posting in parallel, or firing an overdue backlog into one feed without the same-platform gap — the fastest way to make a legitimate account look like a bot. The mirror-image mistake: idling between two different platforms, where nothing is being protected and the run just takes longer.
- Retrying a failure without first proving via read-back that it failed — that is how duplicates happen.
- Trusting session memory over the ledger, or keeping the ledger only in memory.
- Typing credentials, storing tokens, automating login or 2FA, or clicking through captchas and warning interstitials.
- Starting on whichever bridge answered first, or carrying on in the wrong browser instead of asking for the intended profile's extension token and restarting.
- A fixed UTC offset instead of per-date IANA conversion — half the campaign lands an hour off after a DST switch.
- Treating a review-queue submission as a published post, or as a failure.
- Editing or deleting published content as cleanup without an explicit per-item user request.
- Guessing selectors when the UI has changed instead of re-deriving from a fresh snapshot.
- Declaring a control absent after filtering the DOM through a guessed label. Enumerate every candidate once — with tag, `aria-label`, `title`, text and rect — before reporting anything as missing; two "this platform has no media control" findings in one run were both false, and both posts shipped without their picture.
- Clicking a rect that lies outside the viewport. `getBoundingClientRect()` returns coordinates for off-screen elements too, and the click lands on whatever is painted there instead — scroll it into view, re-read the rect, hit-test, then click.
- Reporting a post as degraded without proving the defect the same way a success is proven. A false "no image" is as damaging as a false "published": it hides a real duplicate and sends the user to fix something that was never broken.
- Taking an uploaded file's URL from the page's HTML instead of the uploader's own output, and publishing it without checking the image is the one that was sent.
- Reading a platform's own collapse control — "Read More", "See more", a truncated feed card — as evidence that the post was cut. Expand it before comparing the tail; and where a cap exists, check the source length against it during the source scan rather than discovering it at the composer.
- Publishing a bare URL and calling the link done. Several editors never auto-link on publish, so the last line ships as dead characters unless the link control was used on the selection.
- Letting a post go out without the campaign's image on a platform that accepts one, and recording it as a normal success. Report it as a defect and fix it.
- Deciding a platform is text-only, or a control absent, from a probe that was never written to find it.
- Repeating a submit click without reading the validation message the composer is showing, or without a screenshot when the DOM text explains nothing.
- Deleting a post whose identity is not established beyond doubt. Where two posts share a body and the page's cards nest ambiguously, leave the duplicate and say so — a duplicate is recoverable, the wrong deletion is not.
- Checking the body's length against the platform's cap and not the title's. A `maxlength` on a title field truncates in silence and the cut lands on the first line every reader sees — ko-fi stopped a 78-character title at 70, mid-word.
- Treating a rendered thumbnail as a completed upload. The picture on screen may be a local read; only the platform's own hosted URL, with the uploading state gone, means the file reached the server.
- Clicking a save or publish control and then navigating, without reading what the click opened. The commit is often a second button inside the dialog that first click raised.
- Inserting the campaign image wherever the caret happens to be. It belongs at the top of the post, and the check is the published page's geometry, not the fact that an image is somewhere on it.
- Taking the first `input[type=file]` on the page. The album, avatar and cover uploaders sit on the same page as the composer, and picking the wrong one publishes to the wrong surface.
- Navigating away while a submit is uploading, then reading the empty profile as proof of failure.
- Treating a highest ID, a first DOM node, or an unrendered lazy grid as the answer to "did it post?" — only a count taken before and after, plus the opened permalink, settles it.
- Repeating an identical failing click a third time instead of climbing the ladder in `references/browser-interaction.md`.
- Typing the markdown source into a composer that does not render markdown, or assuming a platform's class from a note instead of from one of its existing posts.
- Submitting with a bare URL in the body. Assert the anchor in the composer; a link that publishes as dead characters is the one defect that wastes the whole post.
- Judging a route by the name in its URL. `daily.dev/squads/create` is the post composer — its page title reads `Create post` — and a run that read the path as "create a squad" skipped the platform and told the user it could no longer post.
- Typing into a composer without first asserting it is empty. Rich composers restore drafts, and daily.dev's restored another platform's body around which the new paste landed.
- Pinning a selector to a `placeholder` you are about to fill. Tumblr's tag field clears its own placeholder on the first keystroke, so `textarea[placeholder="#add tags"]` stops matching and the run concludes the field is broken when the text went in fine.
- Counting `[contenteditable="true"]` nodes as body blocks. Tumblr renders each tag chip as one, so the count runs high and a "junk in the body" theory follows from nothing.
- **Re-submitting on a platform whose posts cannot be edited.** The budget there is one attempt; an unconfirmed outcome is `unverified` and a message to the user, never another click on Post. A duplicate on such a platform is permanent, it splits the upvotes and comments the first copy is already collecting, and the only way out — deleting one — throws that engagement away.
- Reading "the post is not in the listing" as "the post was not created", when the listing has already returned different results across loads. Cross-check on a second surface the platform renders — search, drafts, the permalink — or leave it `unverified`; never settle it by calling the platform's API.
- Deleting a post before its replacement is verified live.
- Matching UI text against English literals when the user's browser is in another language.
- Publishing what the user asked for while quietly ignoring that some platforms they named have no content in the folder.
- Letting a stuck alt-text editor block a post, or dropping the declared alt text without recording and reporting it.
- Finishing a run with screenshots and `.playwright-mcp/` left untracked in the user's repository.
- Ending the turn on a progress report while posts are still due. The report is a line in the middle of the work, not a handoff.
- Retyping a post's body into a script from memory instead of generating it from the source file. Two bodies lost their last line that way in one run; the pre-submit diff is what caught them, and it only works because it compares against the file.
- Reading a composer that keeps its text as "the submit did nothing", when the click may have opened a confirmation. Screenshot before climbing the ladder — Mastodon's "Add alt text?" modal swallows every later click and times out an element-handle click, which reads exactly like a dead button.
- Clicking a control whose rect was read in the same `page.evaluate` as the `scrollIntoView` that moved it. Scroll, wait, then read and hit-test in a separate call.
- Treating a hit-test failure as a reason to climb the click ladder. `elementFromPoint` already named the blocker — an expand overlay, a toast covering the button — and clearing it is the fix.
- Judging a composer by `location.href`. Bluesky's intent route mounts the dialog while the URL stays on the app root, and a run that watched the URL concluded the composer never opened.
- Trusting a profile page that renders no posts. Bluesky's showed zero for a seven-post account and Truth Social's showed zero for the whole account, for the better part of an hour after a post that had in fact published. An empty profile is a broken renderer, not an answer: wait, re-check, read a second surface, and leave the entry `unverified` until one of them speaks. Acting on that emptiness produced a duplicate the user had to have deleted.
- Leaving a timed-out tool call unexamined. A helper stuck in a loop kept running after the call "timed out" and grew to tens of gigabytes; check for the process and stop it, and stop the harness task too.
- Letting a `page.goto` re-enter a form that was bound to a target. Lemmy's community binding survives every field edit and dies on the next navigation, and re-entering from the form's own query string restores the body so the next insert doubles it.
- **Touching a platform's API for any reason** — navigating the tab to an endpoint, fetching one from injected code, replaying a request the page made. It publishes nothing, it verifies nothing the page cannot show, and it is how an account gets flagged: a run that opened a minds.com entity endpoint was served a Cloudflare block page.
- Recording an engagement number the platform did not display, ranking posts off a handful of samples, or turning a harvest pass into interaction with the feed.