| name | crosspost-newsletter |
| description | Use when user wants to cross-post a beehiiv newsletter article on LinkedIn, Substack, Medium, Hacker News, or Reddit using browser automation — "crosspost this article", "cross-post newsletter", "publish to Medium", "post to hacker news", "submit to reddit", "share on HN", "crosspost to substack", "syndicate newsletter". |
| user_invocable | true |
crosspost-newsletter
Cross-post a beehiiv newsletter article across five platforms in two modes:
- Full-article syndication to LinkedIn (native article), Substack, and Medium — preserves rich formatting, headings, images, and sets canonical URL back to the beehiiv original. Uses gstack browse for LinkedIn/Substack, Claude in Chrome for Medium (which blocks headless browsers).
- Link submission to Hacker News and Reddit — submits the beehiiv URL with the article title. Uses Claude in Chrome for both (neither platform has a workable public posting API). For Reddit, submits to one or more subreddits chosen by the user.
If a platform offers the option to send the article as an email to subscribers, always enable it.
Usage
/crosspost-newsletter <beehiiv-post-url> or /crosspost-newsletter latest
🟢 Happy Path (read first; everything below is edge-case detail)
For a beehiiv newsletter cross-post when nothing goes wrong. ~45-60 min wall-clock. Each step links to a labeled edge case (Edge: <name>) you only need to read if that step fails.
Phase 0 — Memory (30 sec). Read project_substack_locked_out.md, feedback_medium_topics.md, project_channels.md, reference_beehiiv_feed.md from project memory. Skip platforms the memories rule out (e.g., Substack if locked out). Pull the Medium topic list and the beehiiv feed URL.
Phase 1 — Content prep (3-5 min).
- Refresh voice-corpus cache:
_shared/voice-corpus/voice-corpus --refresh. Pull article body via jq -r '.posts[] | select(.url | contains("<slug>")) | .body_text' cache.json > /tmp/newsletter-body.txt. Prefer this over WebFetch — see Edge: webfetch-copyright-refusal.
- WebFetch with image-only prompt → enumerate image URLs.
- HEAD-check each image URL via curl; drop any non-200 from the pool (see
Edge: beehiiv-cdn-307-403).
- Download images to
/tmp/bb-images/bb-imgN.{ext} and /tmp/bb-cover.png.
- Build
/tmp/article-body.html for LinkedIn (empty-<p> placeholders for each image) AND /tmp/article-body-medium.html for Medium (inline <img src="<cdn-url>"> tags).
Phase 2 — Platform selection. Single AskUserQuestion: which of (LinkedIn / Substack / Medium / HN / Reddit) and which subreddits. Default: all four non-Substack platforms + Reddit subs r/vibecoding, r/ClaudeAI, r/singularity, r/artificial (note: NOT r/ArtificialIntelligence — that sub doesn't exist).
Phase 3 — Browser setup (one-time, 60 sec).
$B connect for headed gstack Chromium (visible window). Do NOT use launched mode — see Edge: launched-mode-invisible.
$B cookie-import-browser chrome linkedin.com reddit.com — picker opens once; user selects both domains, closes picker.
$B useragent "Mozilla/5.0 ... Chrome/131.0.0.0 ..." for Reddit.
mcp__claude-in-chrome__tabs_context_mcp to confirm Claude in Chrome extension is connected (for Medium + HN).
Phase 4 — Publish per platform. Process in this order (highest leverage first):
-
LinkedIn pulse (8-12 min). $B goto linkedin.com/article/new/. Click Title textbox → type article title. Click body editor → paste via ClipboardEvent('paste', {clipboardData: dt}) with DataTransfer setting text/html + text/plain. Set cover: click "Upload from computer" → $B upload /tmp/bb-cover.png → click modal Next. For each body image: JS-click image toolbar (button index 9) → $B upload /tmp/bb-images/bb-imgN.png → re-snapshot → click modal Next. Move all figures to empty-<p> placeholder anchors via parentNode.insertBefore. Click Next (advance to publish page). Type accompanying-post draft (2-4 short paragraphs, voice-grounded). Run adversarial review on accompanying post (must all_pass — see Edge: linkedin-accompanying-fabrication). User adds paragraph breaks via real Enter in headed Chrome (unavoidable — see Edge: linkedin-quill-strips-newlines). Click Publish. Verify URL contains /pulse/<slug>/?published=t AND "Congrats on publishing" dialog appears (see Edge: publish-verify-by-canonical-signal).
-
Medium story (5-8 min). mcp__claude-in-chrome__tabs_create_mcp → navigate to medium.com/new-story. Build /tmp/article-body-medium.html with inline <img> tags. HEX=$(xxd -p /tmp/article-body-medium.html | tr -d '\n'); osascript -e "set the clipboard to «data HTML${HEX}»". REAL-click into the body editor (a computer left_click on the "Tell your story…" line), NOT just JS body.focus() — confirmed 2026-05-31: JS focus() alone does NOT give the editor the keyboard-focus target Claude-in-Chrome's cmd+v writes to, so the first paste silently lands 1 paragraph / 0 figures; a real click fixes it (then cmd+v lands all 47 p / 8 figures, "Saved" in ~1s). mcp__claude-in-chrome__computer key="cmd+v". Verify editor.querySelectorAll('p').length >= source paragraphs - 2 AND save indicator = "Saved". (Also confirmed 2026-05-31: when the gstack browser is in $B connect headed mode = the user's real Chrome, Reddit is ALREADY authed — skip the cookie-import picker entirely; just set the UA spoof and go.) Click .graf--title element by coordinates (NOT the outer textbox — see Edge: medium-two-titles) → type article title. Click "..." menu → "More settings" → "Advanced Settings" (link in left sidebar) → click "Advanced Settings ⌄" header to EXPAND the panel (see Edge: medium-advanced-collapsed) → check "This story was originally published elsewhere" → click "Edit canonical link" → triple_click field + Delete + type beehiiv URL → "Save canonical link" (toast: "Canonical link successfully updated"). Navigate back to /p/<id>/edit → click Publish (top right). On publish dialog: subtitle auto-pulls from body first paragraph (acceptable verbatim). Type each of 5 Medium topics from memory using left_click combobox → type "<topic>" → wait 2s → key "Down Return". Click final Publish. Verify URL contains ?postPublishedType=initial. Cover image (Medium has NO separate header/cover-image slot, unlike LinkedIn): insert the beehiiv cover at the TOP of the body, above the first paragraph. Reliable path: put the cover's PNG bytes on the clipboard (osascript -e 'set the clipboard to (read (POSIX file "/tmp/bb-cover.png") as «class PNGf»)'), real-click the very start of the first body line, then cmd+v — pasting bytes makes Medium upload the image directly, avoiding the beehiiv-S3 og:image 307→403 server-fetch risk (see Edge: beehiiv-cdn-307-403). If the story is ALREADY published, this edit only goes live after clicking "Save and publish" (top-right) — the "Saved" autosave indicator alone just persists a pending draft of changes, it does NOT update the public article.
-
Hacker News (2-3 min). New Claude in Chrome tab → news.ycombinator.com/submit. Draft 2-3 sentence "Author here…" note. Adversarial review on title + note (must all_pass). Single javascript_tool call with React-native value setter pattern to fill input[name="title"] + input[name="url"] + textarea[name="text"] (see Edge: hn-batched-type-freeze). Click submit. Verify URL is news.ycombinator.com/newest (canonical post-submit redirect).
-
Reddit (3-5 min per sub, 90s between subs). Per sub:
$B goto reddit.com/r/<sub>/submit/ (note: skip ?type=LINK query — let UI default, then click Link tab; see Edge: reddit-type-mode-drift).
- Re-snapshot, capture refs for Title / Link URL / Body / Add-flair / Post.
- Fill title + URL + body via
$B click @<ref> + $B type.
- If flair button has
* (required): click Add-flair → snapshot flair list → if target not visible, click "View all flairs" → select target via JS full-pointer-events on faceplate-radio-input[name="flairId"] → click $B click '#post-flair-modal-apply-button' (NOT a ref — see Edge: reddit-lit-rejects-synthetic-clicks).
- Re-fill body after flair commit (modal clears it; title + URL persist).
- Adversarial review on body text (must
all_pass or hit deadlock-accept).
- Click Post (re-snapshot ref first — refs renumber). Verify URL contains
?created=t3_<id>.
sleep 90 before next sub.
Phase 5 — Summary table with one row per platform/sub: Status + URL.
Edge labels (jump to these only when you hit the matching failure signal)
| Label | Symptom |
|---|
Edge: webfetch-copyright-refusal | WebFetch refuses to return full article body verbatim |
Edge: beehiiv-cdn-307-403 | Image URL returns 307→403 on Buffer/Medium's IP range |
Edge: launched-mode-invisible | $B browser window not visible to user; $B handoff opens about:blank |
Edge: linkedin-quill-strips-newlines | Accompanying-post paragraph breaks don't survive programmatic input |
Edge: linkedin-accompanying-fabrication | Adversarial review FAIL on accompanying post |
Edge: publish-verify-by-canonical-signal | URL changed after click but no canonical published-state token |
Edge: medium-two-titles | Article title saved as the body's first paragraph instead of the typed title |
Edge: medium-advanced-collapsed | "Customize Canonical Link" section not visible on Story Settings |
Edge: medium-paste-sentinel-trip | "Something is wrong and we cannot save your story" appears |
Edge: hn-batched-type-freeze | HN submit form goes blank after batched left_click/type sequence |
Edge: reddit-type-mode-drift | Reddit submit URL drifts from ?type=LINK to ?type=TEXT mid-fill |
Edge: reddit-lit-rejects-synthetic-clicks | Reddit flair Add button doesn't commit despite full pointer event sequence |
Edge: reddit-field-contamination-cascade | $B type appends to wrong field after a navigation invalidated refs |
Edge: javascript_tool-blocks-url-responses | [BLOCKED: Cookie/query string data] when JS returns URL-shaped values |
Edge: substack-locked-out | Substack login fails; skip per project_substack_locked_out.md memory |
Edge: cookie-handoff-drops-auth | Cookies reset after $B handoff — re-import via picker |
Each label corresponds to a section in Known Issues & Workarounds below. Search for the label text to find the full fix.
Required up-front auth handoff (for "review at end" / autonomous mode)
If the user asks for a fully autonomous run ("kick off and only review at end"), surface this prerequisite up-front and pause for the picker selection — don't try to silently skip platforms whose auth couldn't be established. Caught 2026-05-03.
| Platform | Auth path | Cost |
|---|
| LinkedIn | gstack cookie-import-browser chrome linkedin.com | ~10 sec, picker UI |
| Reddit | gstack cookie-import-browser chrome reddit.com + UA spoof | ~10 sec, picker UI |
| Medium | already-logged-in real Chrome (Claude in Chrome) | usually free |
| Hacker News | already-logged-in real Chrome (Claude in Chrome) | usually free |
| Substack | gstack $B handoff (cookies don't work — see Known Issues) | 2 min in-window login required mid-run |
Optimal flow: open the gstack cookie picker ONCE before any platform work and tell the user to select linkedin.com + reddit.com at the same time, then close it. Substack is the only platform that requires a mid-run handoff (its session cookies are HttpOnly).
If running fully autonomous and Substack login is missing: SKIP Substack and document in the Phase 7 summary as SKIPPED: requires manual login + cookies don't work. Do NOT silently fail.
Substack: known-broken in autonomous mode (2026-04-26 + 2026-05-03 confirmed)
Substack has hit the same auth wall in two consecutive crosspost runs. Confirmed paths that don't work:
- gstack
cookie-import-browser chrome substack.com — picker accepts substack.com but the imported cookies don't authenticate the gstack browser (HttpOnly session cookies)
- Claude in Chrome navigation to
substack.com/sign-in — JavaScript access blocked with "Cannot access a chrome-extension:// URL of different extension" extension conflict
- Mid-run
$B handoff for manual login — works but requires user attention; cannot be done in "review at end" autonomous mode
Three viable resolutions, in increasing cost order:
- Pre-session login prerequisite (cheapest, recommended): When the user invokes
/crosspost-newsletter and selects Substack, the skill should immediately verify Substack auth in Phase 3a (before any platform work). If not authenticated, handoff up-front with: "Substack needs manual login before this run can proceed autonomously. I'll wait." Converts the recurring mid-run interruption into a single up-front prereq.
- OAuth API path (medium cost): Substack has a private API used by their mobile app (
substack.com/api/v1/...). Reverse-engineering the auth flow is non-trivial. Defer until ≥3 sessions have been blocked by the manual-login requirement.
- Mark Substack as manual-only (cheapest "give up"): Remove Substack from the autonomous platform list. Print at end of
/crosspost-newsletter: "Substack: post via web editor manually. Article body at /tmp/<slug>/article-body.html." Closes recurring breakage at the cost of one platform's auto-syndication.
Default behavior (2026-05-03+): option 1 (pre-session prereq up-front), with option 3's print as fallback. Escalate to option 2 (OAuth) if a 4th session hits this wall.
Before You Begin — Run in a Dedicated Claude Instance
This skill executes hundreds of tool calls (content extraction, image uploads, multi-step browser automation, per-subreddit submissions). Approving each permission prompt interactively is painfully slow and breaks flow.
Recommended: open a dedicated Claude Code instance in a fresh terminal with permissions pre-approved:
claude --dangerously-skip-permissions
Then invoke /crosspost-newsletter inside that instance. The flag grants blanket permission for tool calls in that session — combined with this skill's built-in user review gates (Phase 5 per-platform approval before each publish), you retain control over what gets posted without approving each individual Bash/JS call.
When NOT to use --dangerously-skip-permissions:
- If you haven't read and understood what the skill does end-to-end
- If you're running the skill against an account you don't fully control
- If your environment has secrets the skill could accidentally expose
For a quick one-platform test run, the normal permission flow is fine. For a full 5-platform cross-post, the dedicated-instance approach is strongly recommended.
What a successful run looks like (expected flow)
A full 5-platform cross-post takes roughly 45-60 minutes of wall-clock time. Rough breakdown:
- Phases 0-2 (2-3 min): check memory for preferences, fetch article content via RSS + browser extraction, confirm platform selection.
- LinkedIn (8-12 min): paste body, set cover image, batch-upload 3-5 body images (each needs a user handoff to dismiss LinkedIn's modal overlay), move figures to correct positions via DOM
insertBefore, set accompanying post, publish. Handoffs: ~5.
- Substack (6-8 min): paste body, enable email header/footer, upload 3-5 images inline (each via toolbar Image button), clean empty paragraphs, publish + send email + dismiss subscribe-buttons dialog. Handoffs: ~0.
- Medium (5-8 min): Claude in Chrome required. Open dual tabs (Medium + beehiiv), handoff for manual cmd+c/cmd+v (programmatic doesn't work), manually re-wrap flattened blockquote in Medium toolbar, clean empty H3s before figures via real clicks, set canonical URL via settings, fix auto-populated subtitle, add topics (from memory if saved), publish. Handoffs: ~2.
- Hacker News (2-3 min): Claude in Chrome. Draft HN-appropriate title, fill title + URL + short author note, submit. Handoffs: 0.
- Reddit (15-20 min): gstack browse with cookie import. Per subreddit (~3-5 min each): navigate to
/r/<sub>/submit, click Link tab, fill title + URL + body, set post flair + user tag via shadow-DOM modal walk, re-type fields after flair modal (Reddit clears them), screenshot for review, submit. 90s wait between subs. Handoffs: ~1-2 per sub for CAPTCHA or persistent-user-flair blocks.
Typical wins: 4-5 of 5 platforms publish successfully. Occasional losses: a subreddit's automod flags the body as spam (rewrite and retry), or a sub requires a persistent user flair that wasn't pre-set (skip or handoff).
Process
Phase 0 — Check memory for saved preferences
Before asking the user anything, check the memory directory for preferences this skill has already learned from past runs:
- Beehiiv RSS feed URL — look for
reference_beehiiv_feed.md or any memory describing a feed URL. Use it directly instead of asking for the feed again.
- Medium topic tags — look for a feedback memory about Medium topic selection (e.g.
feedback_medium_topics.md). Use the saved preference as the default; offer it to the user with "go with these 5?" instead of proposing from scratch.
- Target channels / platforms — a
project_channels.md memory may restrict the platform list (e.g. "X is off the channel list"). Respect it silently; don't propose excluded platforms.
- Default subreddit picks — may not exist yet, but if a run establishes clear subreddit preferences, save them as feedback for next time.
These memories exist so the user doesn't have to repeat themselves. Check first; ask only for what isn't there.
Phase 1 — Fetch Full Newsletter Content
If URL provided:
Use WebFetch with the beehiiv post URL to get metadata (title, subtitle, date).
IMPORTANT: beehiiv renders article content dynamically. WebFetch and the RSS feed may return empty blockquotes and miss dynamically-loaded content. To get the complete article:
-
First, fetch the beehiiv RSS feed via WebFetch to get the article HTML body. The user's feed URL will be provided or stored in their settings; it has the form https://rss.beehiiv.com/feeds/<feed-id>.xml. This gives you most text, headings, and links, but blockquotes may be empty and images may use beehiiv CDN URLs.
-
Then, use the browser to extract blockquotes, cover image, and body images + captions from the rendered page. beehiiv renders content client-side in these DOM structures (current as of 2026):
.dream-post-content-doc — article body root
.dream-post-content-paragraph — paragraph text (inside a div.j6zgbu0 wrapper)
.dream-post-content-imageBlock — image with optional caption in a figcaption
- Section headings in divs whose class starts with
hynlcx
- Cover/hero image is NOT inside the body — it's in
<meta property="og:image">
$B goto <beehiiv-post-url>
$B js "
const quotes = [...document.querySelectorAll('blockquote, [class*=blockquote]')]
.map(q => q.textContent.trim().replace(/^❝/, '').trim())
.filter(q => q.length > 0);
// Cover image is separate — from og:image meta tag
const coverImageUrl = document.querySelector('meta[property=\"og:image\"]')?.content;
// Body images with captions, in article order
const imageBlocks = [...document.querySelectorAll('.dream-post-content-imageBlock')];
const bodyImages = imageBlocks.map(block => {
const img = block.querySelector('img');
const cap = block.querySelector('figcaption, [class*=caption]');
return { src: img?.src, caption: cap?.textContent?.trim() || '' };
});
JSON.stringify({ quotes, coverImageUrl, bodyImages }, null, 2);
"
-
Merge the blockquote text into the RSS HTML body, replacing empty <blockquote> tags.
-
Download cover + body images locally (cover and body are separate):
curl -sL "<coverImageUrl>" -o /tmp/bb-cover.png
curl -sL "<bodyImages[0].src>" -o /tmp/bb-img1.jpg
curl -sL "<bodyImages[1].src>" -o /tmp/bb-img2.jpg
Images must be uploaded separately per platform — they cannot be pasted via HTML.
Preserve each body image's caption for Step 4d of each platform's inline-image flow.
If "latest" or no URL:
Fetch the RSS feed, list recent articles, ask the user which one, then follow the above process.
Content preparation:
- Strip beehiiv boilerplate — remove tracking pixels, analytics images, newsletter signup forms, footer, "View in browser" links, beehiiv-specific CSS classes/inline styles, UTM parameters from links
- Keep semantic HTML only — h1-h6, p, strong, em, a, ul/ol/li, blockquote, pre/code (do NOT include img tags — images are uploaded separately)
- PRESERVE EXACT ELEMENT ORDER from source — walk the beehiiv DOM (
.dream-post-content-doc children) in document order and emit elements in the same order. Do NOT reorder, group, or guess positions. Blockquotes, footnotes, and mid-article callouts are easy to misplace when hand-constructing the HTML — watch especially for quotes that appear AFTER footnotes in the source but feel like they belong with the main body. Use this extraction JS to get everything in correct order:
$B js "
const body = document.querySelector('.dream-post-content-doc');
const parts = [];
for (const wrap of body.children) {
const child = wrap.firstElementChild;
if (!child) continue;
const cls = child.getAttribute('class') || '';
const wrapCls = wrap.className || '';
if (cls.includes('paragraph')) parts.push('<p>' + child.innerHTML + '</p>');
else if (cls.startsWith('hynlcx')) parts.push('<h2>' + child.textContent.trim() + '</h2>');
else if (wrapCls.includes('imageBlock')) {
const img = wrap.querySelector('img');
if (img) parts.push('IMG::' + img.src);
} else if (wrapCls.includes('blockquote') || cls.includes('blockquote')) {
parts.push('<blockquote>' + wrap.textContent.trim().replace(/^❝/, '').trim() + '</blockquote>');
}
}
parts.join('\\n');
"
Then hand-clean the output (strip beehiiv-specific spans, fix blockquote attribution, replace IMG:: placeholders with empty lines that document where each image goes). Do not introduce any new elements or reorder what the extractor produced.
- Verify element order matches source — before saving
/tmp/article-body.html, run a sanity check:
- Count
<blockquote> tags — should match beehiiv's count
- Count
<h2> tags — should match the number of section headings in the source
- Count
<p> tags — should match (±1-2 for empty/spacer paragraphs)
- Check first and last paragraph text against the source
- For each blockquote: confirm the paragraph immediately before it (in your HTML) matches the paragraph immediately before it in beehiiv. This catches misplaced blockquotes before they get pasted into every platform.
- Format blockquote attributions — the quote author/attribution must always be on a separate line below the quote text. In the HTML, place a
<br> before the em dash and author name inside each <blockquote>:
<blockquote>"Quote text here."<br>— Author Name</blockquote>
This formatting must be done in the initial paste HTML for all platforms. Post-paste editing of blockquotes causes save errors on Medium, and LinkedIn's editor is similarly restrictive. Getting it right in the initial paste is the only reliable approach.
- Use HTML-comment placeholders for image anchors — at each position where a body image should go, emit an empty paragraph with a comment marking the image index and subject:
<p></p>
On paste, most editors strip the HTML comment but keep the empty <p> tag. That empty <p> then serves as a positional anchor you can query for during image placement:
const emptyPs = [...editor.querySelectorAll('p')].filter(p => !p.textContent.trim());
On LinkedIn, after all body images have been batch-uploaded (they all land clumped at the end regardless of cursor position), move each figure to its corresponding empty <p> using target.parentNode.insertBefore(fig, target) followed by target.remove(). On Substack, the same empty-<p> pattern works as a cursor-positioning target before triggering the Image toolbar button. This approach worked cleanly on the 2026-04-19 run for both platforms — the images landed in exactly the right spots without any per-image heading-text matching.
- Save clean HTML to a temp file —
/tmp/article-body.html
- Escape for JS embedding — when loading from file into
$B js, escape backticks and ${ sequences:
ARTICLE_HTML=$(cat /tmp/article-body.html | sed "s/\`/\\\\\`/g" | sed 's/\$/\\$/g')
Present to the user:
Article: "<Title>"
Subtitle: "<subtitle>"
Published: <date>
Length: ~<word count> words, <image count> images, <quote count> blockquotes
URL: <beehiiv URL>
Phase 2 — Platform Selection
Ask the user which platforms to cross-post to (multi-select — any combination is valid):
Full-article syndication (paste entire body with formatting + images):
- A) LinkedIn — native long-form article (gstack browse)
- B) Substack — full post (gstack browse)
- C) Medium — full story (Claude in Chrome; bypasses Cloudflare)
Link submission (title + URL + short author note):
- D) Hacker News — submit to
news.ycombinator.com (Claude in Chrome). Text field is optional-but-allowed alongside URL; a short "Author here…" note adds value.
- E) Reddit — submit as a Link post to one or more subreddits (gstack browse — the Claude in Chrome extension is blocked from reddit.com). Body text is required even though labeled "Optional" — the sub's automod will remove bare-link posts.
Wait for user input before proceeding.
Platforms are processed one at a time, sequentially. Full-article platforms run first (they need the article body prepared in Phase 1), then link submissions.
Mid-session auth notes:
- LinkedIn + Reddit run through gstack browse and authenticate via
cookie-import-browser chrome <domain>. LinkedIn import works on first try; Reddit needs a UA spoof set BEFORE the first navigation (otherwise 403).
- Substack runs through gstack browse but cookie-import does NOT work (HttpOnly session cookies). Use a manual
$B handoff for in-window login instead — saves time vs. retrying the picker.
- Medium + HN run through Claude in Chrome (the real Chrome browser), bypassing both Cloudflare (Medium) and headless detection. No cookie import needed; the user's real Chrome session is used as-is.
Phase 3 — Browser Setup & Authentication
3a. Initialize the browse binary AND connect headed
B=~/.claude/skills/gstack/browse/dist/browse
if [ -x "$B" ]; then echo "READY"; else echo "NEEDS_SETUP"; fi
Then immediately bring up a visible browser window via $B connect (headed mode), unless the run is fully autonomous and no user-visible edits are expected. See the /open-gstack-browser skill for Step 0 pre-flight cleanup + Step 1 connect.
$B connect
$B status
$B focus
Why headed by default: the LinkedIn accompanying-post paragraph-break handoff is unavoidable (the field strips all programmatic line breaks). With launched mode the Chromium window is invisible to the user, so $B handoff opens an about:blank popup as a workaround — and the popup drops the LinkedIn session cookies on the way (confirmed 2026-05-17). Headed mode lets the user edit the textbox directly without any handoff/recovery cycle. Same logic applies to Substack's mid-run manual login and Reddit's CAPTCHA prompts.
Mode is sticky. Do NOT call $B connect after the skill has already been running in launched mode — it forks a new browser process and orphans the prior tabs (you lose the LinkedIn draft state). And $B disconnect of a headed instance force-cleans the launched server too. Pick the mode at 3a and stay in it.
3b. Verify authentication for each selected platform
Navigate to a page that reveals login state and take a snapshot:
LinkedIn: $B goto https://www.linkedin.com/feed/ — logged in = feed with search bar. Not logged in = login form with "Email or phone" field.
Substack: $B goto https://substack.com/sign-in — if it stays on /sign-in and shows an email input, NOT logged in. If it auto-redirects away from /sign-in, logged in. Note: https://substack.com/account/settings and https://substack.com/home both serve a public marketing/feed page when not logged in (no obvious "you're signed out" signal), so they're unreliable for auth checks. Use /sign-in redirect behavior as the canonical test. Beware of HTTP 429s from rate-hitting /sign-in — wait 5 seconds between checks.
Substack auth caveat (2026-04-26 confirmed): the cookie-import-browser flow does NOT reliably work for Substack, even when the user selects substack.com in the picker. Substack's session cookies appear to be HttpOnly or otherwise inaccessible to the picker. If the first cookie import doesn't take, don't retry the picker — go straight to a $B handoff for in-window manual login. This burns ~2 min vs. 10+ min of failed cookie-picker rounds.
Medium: $B goto https://medium.com/me/stories — logged in = stories dashboard. Not logged in = login page. WARNING: Medium aggressively blocks headless browsers with Cloudflare (403). Cookie import typically does not help. Expect to skip Medium or handoff for manual login.
3c. Handle authentication failures
For any platform where the user is NOT logged in:
-
Attempt cookie import (note: the syntax is browser domain, not --domain):
$B cookie-import-browser chrome linkedin.com
This opens a cookie picker UI. Tell the user to select the domain and close the picker.
-
Re-navigate to the check page and snapshot again.
-
If still not logged in, handoff:
$B handoff "Please log in to <platform> — I'll continue once you're done."
-
After user confirms, $B resume and verify. If still not logged in, skip this platform.
Per-platform reliability of cookie import (confirmed 2026-04-26):
- LinkedIn — cookie import works reliably on the first try.
- Reddit — cookie import works reliably, but ALSO requires a UA spoof (see below) for any subsequent navigation. Set the UA proactively before any reddit.com goto.
- Substack — cookie import does NOT work (HttpOnly session cookies). Skip the picker for Substack and go straight to in-window handoff login. Saves time.
Reddit UA spoof — set this proactively, not as a fallback:
$B useragent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
Without this, $B goto https://www.reddit.com/ returns HTTP 403 immediately (Reddit's bot detection blocks the default headless UA). With the spoof, navigation succeeds and the imported cookies authenticate the session normally. Set the UA before the first Reddit navigation in the run, not as a recovery step after the 403.
$B handoff opens a separate browser window at about:blank. Confirmed 2026-04-26. The handoff message tells the user what to do, but the URL the user sees in the popped tab is about:blank — they need to switch back to the platform tab (LinkedIn / Substack / Reddit etc.) to actually do the action. Make this explicit in handoff messages: "Switch to the [platform] tab and click X. The about:blank tab can be ignored."
Optimization: batch the cookie picker request. When multiple gstack platforms will be used (e.g. LinkedIn + Substack + Reddit), open the picker once and tell the user to select all relevant domains at the same time ("please select linkedin.com + substack.com + reddit.com, then close the picker"). Saves a round-trip per platform and keeps the handoffs to one.
3d. Verify Claude in Chrome extension before Medium/HN
Medium and HN require the Claude in Chrome browser extension. Before attempting either, make a lightweight call to confirm the extension is connected:
mcp__claude-in-chrome__tabs_context_mcp (createIfEmpty: true)
If the response is "No Chrome extension connected.", handoff with clear setup instructions:
"The Claude in Chrome extension isn't connected to this session. To connect: open Chrome, run the /chrome slash command in this Claude session (or follow your usual extension-connect flow). Reply ready when connected and I'll retry."
Then retry tabs_context_mcp. Don't try to do Medium/HN work before the extension is confirmed connected — every tool call will fail with no useful error, and the user will be stuck staring at blank screenshots.
Phase 4 — Cross-Post to Each Platform
Complete one platform fully (through publish) before starting the next.
Required platform order (highest-leverage first; confirmed 2026-04-27):
- LinkedIn (Native Article) — runs FIRST. Data from 2026-04-27: the LinkedIn pulse accompanying post for "Tokens From Our Past" became the #1-impressions LinkedIn post within hours. The pulse + accompanying post combo is the highest-leverage single move in the cross-post pipeline. Its engagement also primes the algorithm for any subsequent LinkedIn carousel/snippet posts in the same article's promotion cycle.
- Substack + Medium — runs after LinkedIn. Substack uses gstack browse (manual login if needed). Medium uses Claude in Chrome with osascript clipboard path. Both can be done in parallel from the user's perspective if the user is OK juggling two browser windows; otherwise serialize.
- Hacker News — runs after the long-form syndication is live. HN comments often link to the LinkedIn or Medium versions for credibility, so having those URLs ready improves the submission body's "Author here..." note.
- Reddit — runs last. Reddit per-sub takes the longest (CAPTCHA, flair, automod) and benefits least from priming since each subreddit audience is distinct. ~3-5 min per sub with 90s pauses between.
If the user explicitly asks to skip a leg, do so — but keep the relative order of the remaining legs (e.g. if skipping LinkedIn, still run Substack/Medium before HN/Reddit).
Platform: LinkedIn (Native Article)
Step 1 — Navigate to article editor:
$B goto https://www.linkedin.com/article/new/
$B snapshot -i
Look for [textbox] "Title" and [textbox] "Article editor content" in the snapshot. Also note [button] "Upload from computer" for the cover image.
Step 1b — Verify byline + publish destination (the radios are coupled):
The "publish-as" dropdown at the top of the editor has TWO radio groups that constrain each other:
- Author — "Mike Lady" (personal) or "" (LinkedIn page)
- Destination — "Individual article" or " newsletter"
The valid combinations are:
- Author = Mike Lady + Destination =
<Company> newsletter ✅ canonical pattern for personal byline on a company newsletter — this is the LinkedIn default if the user admins a newsletter publication
- Author =
<Company> + Destination = Individual article ✅ standalone post under the company page
- Author =
<Company> + Destination = <Company> newsletter ✅ company-byline newsletter article
- Author = Mike Lady + Destination = Individual article ❌ NOT POSSIBLE — selecting "Individual article" auto-flips author to the company
If the user wants a personal byline (Mike Lady) on a standalone (non-newsletter) article, they cannot do it via this UI — the only "personal byline" option requires publishing under the company's newsletter. Confirm with the user before making the dropdown selection. The default state (Mike Lady author + Company newsletter destination) is usually what they want.
If you need to change byline or destination:
$B click @e<dropdown>
$B click @e<author-Mike-Lady-radio>
$B snapshot -i
Step 2 — Fill the title:
$B click @eN
$B type "<article title>"
Note: the command is type, not type_text.
Step 3 — Insert the article body (text only, no images):
Click the "Article editor content" textbox, then inject via clipboard paste:
$B click @eN
$B js "
const html = `$ARTICLE_HTML`;
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));
const editors = [...document.querySelectorAll('[contenteditable=\"true\"]')];
const editor = editors.find(el => el.getAttribute('aria-label')?.includes('Article'));
if (editor) {
editor.focus();
const evt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
editor.dispatchEvent(evt);
'PASTE_OK';
} else { 'EDITOR_NOT_FOUND'; }
"
Verify with $B screenshot. Text, headings, links, and blockquotes should appear. Images will NOT be in the paste — they must be uploaded separately.
IMPORTANT — heading conversion: LinkedIn's editor converts pasted <h2> tags to <h3>. When subsequent steps query headings (to anchor figure positioning), use h3 (or the wildcard h1,h2,h3,h4,h5,h6) — do not assume the tag matches your source HTML.
Step 4 — Set cover image + upload and position body images:
The reliable pattern is: (a) set the cover image first, (b) batch-upload all body images to wherever LinkedIn drops them, (c) move each <figure> to its target heading via DOM, (d) set captions.
The image toolbar ignores cursor position. Setting range.setStartAfter(heading) before clicking the toolbar has no effect — images land at a cached/default position regardless. Don't bother setting the cursor; fix placement by moving figures after upload.
Step 4a — Set the cover image (separate from body images):
The cover image is the article's hero image from beehiiv's og:image meta (downloaded as /tmp/bb-cover.png in Phase 1). The LinkedIn cover slot is the .article-editor-cover-media element at the top of the editor.
Scroll to top and check cover state:
$B js "
window.scrollTo(0, 0);
const cover = document.querySelector('.article-editor-cover-media');
({
hasImg: !!cover?.querySelector('img'),
placeholderVisible: !!cover?.querySelector('.article-editor-cover-media__placeholder')
});
"
- If placeholder is visible: click the "Upload from computer" button inside the placeholder (from the initial snapshot, typically
[button] "Upload from computer").
- If the cover already has the wrong image (e.g. you want to replace it): click the "Delete" button inside the cover (aria-label contains "remove the cover image"). Then the placeholder returns — click "Upload from computer".
Upload:
$B upload "#media-editor-file-selector__file-input" /tmp/bb-cover.png
The cover modal overlay appears with buttons: [button] "Dismiss", [button] "Edit", [button] "Alternative text", [button] "Hyperlink", [button] "Select bb-cover.png", [button] "Delete", [button] "Next". The "Next" button commits the cover image AND closes the modal cleanly — that's the programmatic dismiss path. Do NOT click "Dismiss" (that triggers a discard-confirmation dialog) or "Delete" (deletes the upload). For the cover specifically, the modal sometimes lacks the Next button and only a body-click works — if $B click @<Next-ref> fails, fall back to a single user handoff for body-click dismiss.
Step 4b — Identify the body image toolbar button (once):
$B js "
const buttons = [...document.querySelectorAll('.scaffold-formatted-text-editor-icon-button')];
buttons.map((b, i) => {
const svg = b.querySelector('svg use');
return { index: i, href: svg?.getAttribute('href') };
}).filter(x => x.href && x.href.includes('image'));
"
The image button has href: '#image-medium' (typically index 9).
Step 4c — Upload each body image in article order, dismiss via the modal's "Next" button:
For each body image:
$B js "
const buttons = [...document.querySelectorAll('.scaffold-formatted-text-editor-icon-button')];
buttons[9].click();
'clicked';
"
sleep 2
$B upload "#media-editor-file-selector__file-input" /tmp/bb-imgN.jpg
sleep 4
$B snapshot -i
$B click @e<N>
sleep 2
$B js "({ figureCount: document.querySelector('[aria-label=\"Article editor content\"]').querySelectorAll('figure').length })"
The modal's "Next" button is the reliable programmatic dismiss path for body images. Confirmed on the 2026-04-26 run after Escape, programmatic body click, and pointer-event sequences all failed:
$B press Escape only works if the editor is currently focused (rare after a JS-triggered toolbar click — focus stays on the body or moves to the modal).
- Programmatic clicks on the editor (even with the full pointer+mouse event sequence) do NOT dismiss the modal.
$B click @<Dismiss-ref> triggers a discard-confirmation dialog (Dismiss / Cancel / Discard); clicking Cancel returns you to the upload modal, clicking Discard wipes the upload.
$B click @<Delete-ref> deletes the upload.
$B click @<Next-ref> commits the image into the editor body AND closes the modal.
After upload, the modal exposes both the editor toolbar's Next button and the modal's own Next button — re-snapshot to get the fresh ref of the modal Next, since @e<N> refs shift between snapshots.
After all body images are uploaded this way, they will all be clumped together at the end of the article (cursor position is ignored). The next step moves them to the right positions.
Step 4d — Move each figure to its empty-<p> placeholder anchor:
The clean HTML from Phase 1 contained empty-paragraph anchors (<p><!-- IMG1: ... --></p>) at each image position. The HTML comment is stripped on paste but the empty <p> remains as a positional marker. After body image upload, all figures are clumped at the end of the editor — move each to its corresponding empty <p> anchor in source order, then remove the placeholder:
$B js "
const editor = document.querySelector('[aria-label=\"Article editor content\"]');
const allChildren = [...editor.children];
// Empty <p>s that aren't figure containers — these are our anchors
const emptyPs = allChildren.filter(c => c.tagName === 'P' && !c.textContent.trim() && !c.querySelector('figure'));
const anchors = emptyPs.slice(0, 4); // first N match the N image placeholders
const figures = [...editor.querySelectorAll('figure')];
let moved = 0;
for (let i = 0; i < figures.length; i++) {
const target = anchors[i];
const fig = figures[i];
if (target && fig) {
target.parentNode.insertBefore(fig, target);
target.remove();
moved++;
}
}
editor.dispatchEvent(new Event('input', { bubbles: true }));
'MOVED ' + moved + ' figures';
"
This anchor-based approach is more reliable than heading-text matching because:
- Many articles place images mid-section, not directly after a heading
- Heading text can be transformed by LinkedIn's paste sanitizer (NBSPs, h2→h3 downgrade) and require normalization
- Empty
<p>s have a predictable index per Phase 1 source order — no string matching needed
This DOM manipulation is safe on LinkedIn — <figure> moves do not break the editor's save state. (This is different from Medium, where post-paste DOM edits break save.)
Verify with a follow-up query that walks the editor and reports heading+image order.
Step 4e — Set image captions (from Phase 1 extraction):
Each LinkedIn figure has a <textarea class="article-editor-figure-caption"> for captions. React ignores direct textarea.value = "..." assignment — use the native setter pattern:
$B js "
const figures = [...document.querySelectorAll('[aria-label=\"Article editor content\"] figure')];
// captions in upload order; empty string = no caption
// Populate from Phase 1 bodyImages[].caption extraction
const captions = [
'<caption for image 1, or empty string>',
'<caption for image 2, or empty string>',
// ...one entry per body image...
];
let filled = 0;
figures.forEach((fig, i) => {
if (!captions[i]) return;
const ta = fig.querySelector('textarea.article-editor-figure-caption');
if (!ta) return;
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
setter.call(ta, captions[i]);
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.dispatchEvent(new Event('change', { bubbles: true }));
filled++;
});
'FILLED ' + filled + ' captions';
"
Verify captions persist with:
$B js "[...document.querySelectorAll('[aria-label=\"Article editor content\"] figure')].map(f => f.querySelector('textarea.article-editor-figure-caption')?.value)"
Step 5 — Canonical URL:
LinkedIn does NOT have a canonical URL field in the article editor UI. Skip this step.
Step 6 — Publish:
Click "Next" button to go to publish confirmation. The publish page shows:
- A text field for an accompanying post (
[textbox] "Text editor for creating content")
- A "Publish" button
Write the accompanying post and fill it before clicking Publish:
$B click @eN
$B type "<accompanying post text>"
Accompanying-post guidance:
- If the cover image is a person, an object, or anything not self-explanatory, lead with who/what they are and why they matter to the piece. LinkedIn's feed card surfaces the cover image prominently — if a reader sees a muscular bodybuilder and your accompanying post opens with "Most people are picking from the same corner of the AI low-hanging fruit forest…", the cover creates cognitive friction instead of a hook. Confirmed feedback from the user on the 2026-04-19 run: the draft post framed the article's thesis but never explained who Kai Greene was or why his photo headed the piece; the revised post opened with "Kai Greene was never Mr. Olympia, but early-2010s YouTube fitness fans knew him as 'The People's Champ'…" and tied his "thoughts become things" slogan back to the essay.
- 2-4 short paragraphs. Value/impact framing, not self-congratulation.
- No emojis or hashtags unless the user asks.
- Dollar signs need escaping in the Bash
type command: \$16.64.
Revise as a draft and show it to the user (Phase 5) BEFORE clicking Publish. The accompanying post is the highest-leverage piece of copy on LinkedIn — it's what appears in the feed, not the article itself. Get user signoff.
Then click Publish and capture the URL:
$B click @eN
$B url
The published URL will be in format: https://www.linkedin.com/pulse/<slug>/
Platform: Substack
Step 1 — Determine Substack subdomain:
$B goto https://substack.com/account/settings
$B click @eN
$B url
Step 2 — Navigate to post editor:
$B goto https://<subdomain>.substack.com/publish/post
$B snapshot -i
You'll see: [textbox] "title", [textbox] "Add a subtitle…", toolbar buttons including [button] "Image", [button] "Email header / footer", and [button] "Continue".
Step 3 — Fill the title and subtitle:
IMPORTANT: Substack has duplicate title/subtitle fields — a sidebar pair and a main editor pair. The visible editor fields are <textarea> elements. Use JS to fill the correct ones:
$B js "
const textareas = [...document.querySelectorAll('textarea')];
// Find by placeholder text
const title = textareas.find(t => t.placeholder === 'Title');
const subtitle = textareas.find(t => t.placeholder.includes('subtitle'));
if (title) { title.focus(); title.value = '<article title>'; title.dispatchEvent(new Event('input', { bubbles: true })); }
if (subtitle) { subtitle.focus(); subtitle.value = '<article subtitle>'; subtitle.dispatchEvent(new Event('input', { bubbles: true })); }
'FILLED';
"
Step 4 — Enable email header/footer:
Click the "Email header / footer" button to toggle it on:
$B click @eN
Step 5 — Insert the article body (text only, no images):
Substack uses ProseMirror. Clipboard paste preserves text, headings, links, and blockquotes but strips <img> tags:
$B js "
const html = `$ARTICLE_HTML`;
const dt = new DataTransfer();
dt.setData('text/html', html);
dt.setData('text/plain', html.replace(/<[^>]*>/g, ''));
const editor = document.querySelector('.ProseMirror[contenteditable=\"true\"]');
if (editor) {
editor.focus();
const evt = new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true });
editor.dispatchEvent(evt);
'PASTE_OK';
} else { 'EDITOR_NOT_FOUND'; }
"
Step 6 — Upload images inline:
For each image, position cursor and use the toolbar Image button:
-
Position cursor at the correct location (same JS approach as LinkedIn).
-
Press Enter to create a new empty line: $B press Enter
-
Click the toolbar "Image" button (@eN). A dropdown menu appears with menuitems: "Image", "Gallery", "Stock photos", "Generate image".
-
Click the "Image" menuitem via JS (direct ref click may fail with "matched multiple elements"):
$B js "
const items = [...document.querySelectorAll('[role=\"menuitem\"]')];
const imageItem = items.find(i => i.textContent.trim() === 'Image');
if (imageItem) { imageItem.click(); 'CLICKED'; }
"
-
This creates new file inputs. Find and upload to the one with accept="image/*,.heic,.heif":
$B js "
const inputs = [...document.querySelectorAll('input[type=\"file\"]')];
const newInput = inputs.find(i => i.accept && i.accept.includes('.heic'));
if (newInput) { newInput.setAttribute('data-img-upload', 'true'); 'MARKED'; }
"
$B upload "[data-img-upload='true']" /tmp/image.jpg
-
Wait 3 seconds for upload, then verify the image is in the editor DOM:
$B js "
const editor = document.querySelector('.ProseMirror[contenteditable=\"true\"]');
const imgs = editor ? [...editor.querySelectorAll('img')] : [];
imgs.length;
"
-
For subsequent images, the data-img-upload attribute may already exist. Use a unique attribute each time or find the latest .heic input.
-
Remove empty paragraphs around figures. Pressing Enter after each heading (to position cursor for image upload) leaves an empty <p> either before or after each figure in the final DOM. Clean them up with:
$B js "
const editor = document.querySelector('.ProseMirror[contenteditable=\"true\"]');
const kids = [...editor.children];
const emptyPs = [];
kids.forEach((c, i) => {
const isFigureContainer = c.tagName === 'DIV' && c.querySelector('figure');
if (isFigureContainer) {
const next = kids[i + 1];
if (next && next.tagName === 'P' && !next.textContent.trim()) emptyPs.push(next);
const prev = kids[i - 1];
if (prev && prev.tagName === 'P' && !prev.textContent.trim()) emptyPs.push(prev);
}
});
emptyPs.forEach(el => el.remove());
editor.dispatchEvent(new Event('input', { bubbles: true }));
'REMOVED ' + emptyPs.length + ' empty paragraphs';
"
ProseMirror tolerates this DOM removal cleanly — Substack's autosave continues working. Confirm via snapshot that the save indicator reads "Saved" (not "Saving..." stuck).
Step 7 — Add image captions (from Phase 1 extraction):
Substack exposes a three-dot menu on each image that contains an "Edit caption" action. The menu only surfaces when the image is properly selected via real mouse events — a simple .click() is not enough. Dispatch the full pointer+mouse event sequence with exact coordinates, then click the "Edit caption" action, then fill the <figcaption class="image-caption"> via document.execCommand('insertText').
For each image (captions from Phase 1 bodyImages[].caption):
$B js "
const imgs = [...document.querySelectorAll('.ProseMirror img')];
const img = imgs[N]; // 0-indexed image
img.scrollIntoView({ block: 'center' });
const r = img.getBoundingClientRect();
const opts = { bubbles: true, cancelable: true, view: window,
clientX: r.x + r.width/2, clientY: r.y + r.height/2,
button: 0, buttons: 1, pointerType: 'mouse',
pointerId: 1, isPrimary: true };
// Full event sequence — click() alone does NOT reveal the menu
img.dispatchEvent(new PointerEvent('pointerdown', opts));
img.dispatchEvent(new MouseEvent('mousedown', opts));
img.dispatchEvent(new PointerEvent('pointerup', opts));
img.dispatchEvent(new MouseEvent('mouseup', opts));
img.dispatchEvent(new MouseEvent('click', opts));
'SELECTED';
"
Then click the "Edit caption" action:
$B js "
const actions = [...document.querySelectorAll('.image-action')];
const editCap = actions.find(a => a.textContent.trim() === 'Edit caption');
if (editCap) editCap.click();
'CLICKED';
"
Then fill the caption via Range + execCommand:
$B js "
const imgs = [...document.querySelectorAll('.ProseMirror img')];
const figure = imgs[N].closest('figure');
const cap = figure?.querySelector('figcaption.image-caption');
if (!cap) { 'NO_CAP'; } else {
const range = document.createRange();
range.selectNodeContents(cap);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
document.execCommand('delete', false);
document.execCommand('insertText', false, '<caption text>');
'SET: ' + cap.textContent;
}
"
Repeat for each image. Direct figcaption.textContent = "..." does NOT work — ProseMirror ignores it. Only execCommand('insertText') routes through ProseMirror's dispatch and persists.
Step 8 — Canonical URL:
Substack does NOT have a canonical URL field in the post settings UI. Skip this step.
Step 9 — Publish:
- Click "Continue" button. A publish dialog appears with:
- Audience: "Everyone" (checked)
- Comments: "Everyone" (checked)
[checkbox] "Send via email and the Substack app" — KEEP CHECKED
- "Send to everyone now" button
- Click "Send to everyone now".
- Second dialog: "Add subscribe buttons to your post" — this always appears after the send. Options are "Publish without buttons" and "Add subscribe buttons". Default recommendation: "Publish without buttons" — the article already has the email-header/footer CTAs enabled from Step 4, and inline subscribe buttons often feel spammy in crosspost contexts where most readers came from a link they trust. Ask the user if you want confirmation.
- After publishing, the URL redirects to a
/publish/posts/detail/<id>/share-center. Find the public article URL by querying the page:
$B js "
const links = [...document.querySelectorAll('a')];
const postLink = links.find(a => a.href && a.href.includes('<subdomain>.substack.com/p/'));
postLink ? postLink.href : 'NOT_FOUND';
"
Format: https://<subdomain>.substack.com/p/<slug>.
Platform: Medium
Medium blocks headless browsers (gstack browse) via Cloudflare. Use the Claude in Chrome extension (mcp__claude-in-chrome__* tools) instead, which operates through the user's real Chrome browser.
Step 1 — Set up Claude in Chrome tab:
mcp__claude-in-chrome__tabs_context_mcp (createIfEmpty: true)
mcp__claude-in-chrome__tabs_create_mcp # if needed
Step 2 — Navigate to story editor:
mcp__claude-in-chrome__navigate (url: "https://medium.com/new-story", tabId: <tabId>)
If not logged in, the user must log in manually in Chrome — Medium sessions are in the real browser.
Step 3 — Fill the title (do this AFTER the body paste in Step 4, not before):
⚠️ Critical ordering — confirmed 2026-05-17. The Medium editor has TWO different "Title" surfaces:
- An outer Title textbox surfaced by
mcp__claude-in-chrome__find / read_page (often ref_14 on a fresh /new-story page).
- An empty
<h3 class="graf--title"> element inside the body's main [contenteditable="true"] editor.
Only the inner .graf--title matters. Typing into the outer textbox AND pressing Enter before pasting can land your text in the outer textbox while the inner .graf--title stays empty — and the inner one is what Medium saves as the story title. On the Fix-Forward run I confirmed the inner .graf--title was graf--empty after the outer-textbox flow, so Medium auto-pulled the body's first paragraph as the title and pushed it into the SEO description preview. (The publish dialog showed "47/100" for the title field because the title was correct, but only after we explicitly clicked the inner .graf--title and re-typed.)
Reliable flow:
- Run the body paste first (Step 4 — osascript+cmd+v lands all 92+ paragraphs).
- Then find the inner title element:
const editor = [...document.querySelectorAll('[contenteditable="true"]')][0];
const titleEl = editor.querySelector('.graf--title');
titleEl.scrollIntoView({block: 'center'});
const r = titleEl.getBoundingClientRect();
({x: Math.round(r.x + 20), y: Math.round(r.y + r.height/2)})
- Real click at those coords:
mcp__claude-in-chrome__computer (action: "left_click", coordinate: [x, y])
mcp__claude-in-chrome__computer (action: "type", text: "<article title>")
- Verify by querying
document.querySelector('.graf--title').textContent. The graf--empty modifier class should be gone and .textContent should match the article title.
If the title field is left empty when Publish is clicked, Medium will auto-pull the body's first paragraph as both the story title AND the URL slug — usually wrong (e.g. "Two weeks ago I started talking about…" instead of the actual article title). The publish-confirmation page shows the title field char count near the preview — verify there too before clicking Publish.
Step 4 — Insert the article body via osascript NSPasteboard + Claude in Chrome cmd+v (DEFAULT, no user action):
Build clean HTML with inline beehiiv CDN <img> tags, push it onto the macOS pasteboard via osascript «data HTML${HEX}», click into the Medium body, then dispatch a real cmd+v via Claude in Chrome. Medium fetches the images server-side from the CDN URLs in the paste, so no upload dance is needed. Confirmed working on the 2026-05-17 Fix-Forward run: single paste produced 92 paragraphs / 11 figures / 11 imgs / 0 empty-H3-before-figure artifacts / "Saved" within 1 second.
-
Build the Medium-targeted HTML from the article-extract JSON. Walk extracted parts in document order; emit <p>/<h2> verbatim; for image parts, emit an inline <img src="<beehiiv-cdn-url>" alt="..."> tag (do NOT use the empty-<p> placeholder pattern from LinkedIn — Medium fetches the images directly from the CDN URLs in the paste).
for part in extracted_parts:
if part["type"] == "p": out.append(f"<p>{part['html']}</p>")
elif part["type"] == "h2": out.append(f"<h2>{part['text']}</h2>")
elif part["type"] == "img": out.append(f'<img src="{part["src"]}" alt="...">')
-
Open the Medium editor tab in Claude in Chrome. No second tab is needed — the HTML never crosses tabs; it goes through the OS pasteboard.
mcp__claude-in-chrome__tabs_create_mcp
mcp__claude-in-chrome__navigate (tabId: <mediumTab>, url: "https://medium.com/new-story")
-
Push the HTML onto macOS NSPasteboard via osascript with the «data HTML${HEX}» literal. AppleScript can't coerce a string to «class HTML» — only the hex-encoded «data HTML...» form works:
HEX=$(cat /tmp/article-body-medium.html | xxd -p | tr -d '\n')
osascript -e "set the clipboard to «data HTML${HEX}»"
-
Focus the Medium body editor and dispatch cmd+v. The body editor is the contenteditable with data-default-value containing "story" or "body" (or the last contenteditable if no match — Medium fluctuates):
const eds = [...document.querySelectorAll('[contenteditable="true"]')];
const body = eds.find(e => (e.getAttribute('data-default-value') || '').toLowerCase().includes('story') ||
(e.getAttribute('data-default-value') || '').toLowerCase().includes('body')) || eds[eds.length - 1];
body.focus();
⚠️ JS body.focus() is NOT sufficient as the cmd+v target (confirmed 2026-05-31). Claude-in-Chrome's cmd+v writes to the OS/CDP keyboard-focus element, which focus() does not reliably set inside Medium's editor — the first paste silently lands 1 paragraph / 0 figures. After the focus(), do a real computer left_click on the "Tell your story…" body line (screenshot to get its coords, ~mid-left of the editor) BEFORE the cmd+v. The real click makes the paste land all paragraphs + figures, "Saved" within ~1s.
Then dispatch real cmd+v through Claude in Chrome (NOT a synthetic ClipboardEvent — Medium's automation-detection sentinel blocks synthetic paste):
mcp__claude-in-chrome__computer (action: "key", text: "cmd+v", tabId: <mediumTab>)
-
Verify the paste landed. Count paragraphs, h3s (Medium downgrades h2→h3), figures, imgs. Save indicator should read "Saved" within 1-2 seconds.
const editor = [...document.querySelectorAll('[contenteditable="true"]')].find(el => el.querySelectorAll('p').length > 0);
const saveIndicator = [...document.querySelectorAll('*')].find(e => ['Saved', 'Saving...', 'Draft', 'Saved just now'].includes(e.textContent))?.textContent;
({paragraphs: editor?.querySelectorAll('p').length, h3s: editor?.querySelectorAll('h3').length, figures: editor?.querySelectorAll('figure').length, imgs: editor?.querySelectorAll('img').length, save: saveIndicator})
Expect: paragraph/heading/image counts match source within ±1-2, save: "Saved". If save stays "Saving..." or shows "Something is wrong and we cannot save your story", the paste tripped Medium's automation sentinel — discard the draft and retry on a fresh /new-story (do NOT try to recover an automation-locked draft).
Why this is the default (vs the older manual cmd+c-from-beehiiv handoff):
- No user action required. The user only needs to be logged into Medium in their real Chrome.
- Bypasses both blockers of the synthetic-clipboard path: cross-tab clipboard never happens (HTML goes through OS pasteboard before the cmd+v), and the cmd+v dispatch is a real keystroke through Claude in Chrome's CDP, which doesn't trip Medium's "Something is wrong" sentinel (only synthetic ClipboardEvent dispatch does).
- Cleaner output than the manual paste. The 2026-05-17 run got 92 paragraphs + 11 figures + 0 empty-H3-before-figure artifacts on first try; the manual paste path historically left an empty
<h3> before each figure that required per-figure cleanup.
Fallback to manual user paste only if the osascript path fails (e.g., a Medium UI revision that strips inline <img> tags from pasted HTML, breaking the image-fetch behavior). The manual handoff steps:
"Please switch to the beehiiv tab, select the article body (Cmd+A inside .dream-post-content-doc), Cmd+C, switch to Medium, click into the body, Cmd+V. Reply done when pasted."
After the manual paste, the same verify step applies. Manual paste typically leaves empty <h3> siblings before each figure — see Step 5 conditional cleanup.
Do NOT attempt synthetic ClipboardEvent dispatch with DataTransfer as the primary path. Medium's automation-detection sentinel detects the event provenance and locks the draft into the "Something is wrong" state — recoverable only by discarding and starting fresh on /new-story. The osascript-NSPasteboard + real-cmd+v path bypasses this because the JS doesn't dispatch the paste event; the browser's native paste handler does, reading from NSPasteboard.
Step 5 — Conditionally clean up empty H3s that appear before each image (skip if none):
The osascript+cmd+v paste path usually does not produce empty <h3> siblings before figures (confirmed 2026-05-17: 11 figures, 0 empty H3 siblings). Older paste paths (notably the legacy manual cmd+c-from-beehiiv path) did leave them. First query for them, then only clean up if any are found.
Query:
const editor = [...document.querySelectorAll('[contenteditable="true"]')].find(el => el.querySelectorAll('p').length > 0);
const figs = [...editor.querySelectorAll('figure')];
const dirty = figs.filter(f => {
const idx = [...f.parentElement.children].indexOf(f);
const prev = f.parentElement.children[idx - 1];
return prev?.tagName === 'H3' && !prev.textContent.trim();
}).map(f => figs.indexOf(f));
JSON.stringify({figCount: figs.length, dirtyFigIndices: dirty});
If dirty is empty: skip the rest of this step entirely.
If dirty is non-empty, for EACH figure in the dirty list, do real-click + Backspace (NOT direct DOM manipulation — .remove() triggers Medium's "Something is wrong and we cannot save your story" automation-detection error):
- Find the preceding empty
<h3> and its screen coordinates:
const figs = [...document.querySelector('[contenteditable="true"]').querySelectorAll('figure')];
const f = figs[N];
const prev = f.parentElement.children[[...f.parentElement.children].indexOf(f) - 1];
prev.scrollIntoView({ block: 'center' });
const r = prev.getBoundingClientRect();
({ x: Math.round(r.x + 20), y: Math.round(r.y + r.height/2), empty: !prev.textContent.trim() });
- Real click at the coordinates, then press Backspace:
mcp__claude-in-chrome__computer (action: "left_click", coordinate: [x, y])
mcp__claude-in-chrome__computer (action: "key", text: "Backspace")
- Repeat for each figure in
dirty. The clicks + Backspace go through Medium's keyboard handlers and don't trigger the automation-detection error.
Verify after cleanup:
const editor = [...document.querySelectorAll('[contenteditable="true"]')].find(el => el.querySelectorAll('p').length > 0);
const figs = [...editor.querySelectorAll('figure')];
const emptyH3Count = figs.filter(f => {
const prev = f.parentElement.children[[...f.parentElement.children].indexOf(f) - 1];
return prev && prev.tagName === 'H3' && !prev.textContent.trim();
}).length;
const saveIndicator = [...document.querySelectorAll('*')].find(e => e.textContent === 'Saved' || e.textContent === 'Saving...' || e.textContent === 'Draft')?.textContent;
({ emptyH3Count, figCount: figs.length, saveIndicator });
Expect emptyH3Count: 0 and saveIndicator: "Saved".
Step 5b — Caption paragraphs from beehiiv (optional cleanup):
beehiiv's image captions come through as regular <p> elements immediately after each figure. They display as body text rather than proper Medium captions. Options:
- Leave them — they read fine as italic-styled body text below each image
- Move them to native Medium figcaptions — complex: Medium's
<figcaption> has a <span class="defaultValue">Type caption for image (optional)</span> placeholder that resists both execCommand('insertText') and direct textContent assignment. Would need real mouse clicks + keyboard typing. Not worth the automation risk.
- Skip — if the user pastes cleanly once, don't touch anything else. Every post-paste DOM edit risks triggering save errors.
Recommended approach: Leave captions as inline paragraphs. If the user wants them in the figure, they can do it manually (~30 seconds).
IMPORTANT — Avoid post-paste DOM manipulation:
Medium's editor is very sensitive to programmatic DOM changes. Specifically:
element.remove() on headings/paragraphs adjacent to figures → triggers "Something is wrong and we cannot save your story"
- Setting
figcaption.textContent = "..." → ignored (Draft.js overwrites)
execCommand('insertText') on figcaption → doesn't clear the defaultValue placeholder
- Shift+Enter / Backspace inside blockquotes → often breaks save state
The safe operations are:
- Real mouse clicks at coordinates
- Keyboard input (type, Backspace, Enter) after a real click
- Reading state via JS (safe)
- The initial paste (safe)
Quote formatting note: Medium's editor does NOT support <br> inside blockquotes for post-paste editing. Include <br> before the author attribution in the source HTML — this works during the initial paste. Or, if captions come from beehiiv paste, they'll be correctly formatted already.
Step 5c — Heading and space normalization on paste:
Medium's paste sanitizer does two things:
- Converts
<h2> to <h3> (same as LinkedIn — document this when matching headings)
- Replaces some spaces with non-breaking spaces (U+00A0 = char code 160) in heading text
When searching for headings programmatically, normalize text before comparison:
const norm = s => s.replace(/\u00a0/g, ' ').trim();
const bb = headings.find(h => norm(h.textContent) === 'The Black Belt');
Step 6 — Set canonical URL:
Navigate to story settings via the "..." menu → "More settings" → "Advanced Settings":
mcp__claude-in-chrome__computer (action: "left_click") on the "..." button (top-right of editor)
- Click "More settings" in the dropdown
- Click "Advanced Settings" in the left nav
- Check the "This story was originally published elsewhere" checkbox via JS:
const checkboxes = [...document.querySelectorAll('input[type="checkbox"]')];
const canonical = checkboxes.find(c => c.labels?.[0]?.textContent?.includes('originally published'));
if (canonical) { canonical.click(); 'CHECKED: ' + canonical.checked; }
- Click "Edit canonical link", clear the field, type the beehiiv URL
- Click "Save canonical link"
- Navigate back to the editor
Step 7 — Publish:
-
Click "Publish" button (top-right)
-
Medium shows a publish confirmation page with:
- Story preview (title, description, preview image)
- Topics (up to 5 — see topic preference memory if one exists)
- Publication (optional — "Submit your story to connect with community")
[checkbox] "Notify your N subscribers" — KEEP CHECKED
- "Publish" button and "Schedule for later" link
-
Fix the auto-populated subtitle. Medium auto-pulls the subtitle from the first line of body text — which for beehiiv articles is usually the opening blockquote (e.g. "Thoughts Become Things"). Replace it with the real beehiiv subtitle:
// Find the subtitle textbox
mcp__claude-in-chrome__find (query: "Story preview subtitle textbox")
mcp__claude-in-chrome__computer (action: "triple_click", ref: <subtitle ref>)
mcp__claude-in-chrome__computer (action: "key", text: "Delete")
mcp__claude-in-chrome__computer (action: "type", text: "<real beehiiv subtitle from Phase 1>")
-
Add topics. Check for a saved topic preference memory (look for feedback_medium_topics.md or similar in memory). If present, use those topics. Otherwise, propose 5 topics based on content and ask the user to confirm. Let the user override — topic choice drives Medium's discovery feeds, and the user's Medium audience may have a very different profile than the article theme suggests.
-
Click the final "Publish" button.
-
Capture the published URL (format: https://medium.com/@<handle>/<postId> or the bare https://medium.com/p/<postId>).
Platform: Hacker News (Link Submission)
HN has no workable public posting API (the official Firebase API at github.com/HackerNews/API is entirely read-only). Use Claude in Chrome browser automation. The user must be logged in to news.ycombinator.com in their real Chrome browser.
Content pattern: title + URL only. HN link submissions do NOT support a text body — if URL is filled, the text field must be empty.
Step 1 — Navigate to submit page:
mcp__claude-in-chrome__navigate (url: "https://news.ycombinator.com/submit", tabId: <tabId>)
Step 2 — Verify login:
mcp__claude-in-chrome__read_page (tabId: <tabId>, filter: "interactive")
Logged in = page shows textbox elements named title, url, text and a submit button. Not logged in = "please log in" message with a login form. If not logged in, handoff to user:
mcp__claude-in-chrome__computer (action: "screenshot", tabId: <tabId>)
Ask the user to log in, then wait for confirmation and re-check.
Step 3 — Prepare HN-appropriate title:
Before submitting, adapt the title per HN culture:
- Trim to ≤80 characters (HN enforces this)
- Strip clickbait prefixes ("How to", "You won't believe", "N things that...")
- Remove editorial framing — HN prefers descriptive titles
- Do NOT add "Show HN:" prefix unless the article is a genuine demo of original work (misusing this gets the submission killed)
Present the adapted title to the user for approval before proceeding.
Step 4 — Fill title, URL, and (optionally) text:
mcp__claude-in-chrome__find (tabId: <tabId>, query: "title textbox")
mcp__claude-in-chrome__computer (action: "left_click", ref: <title ref>)
mcp__claude-in-chrome__computer (action: "type", text: "<HN-adapted title>")
mcp__claude-in-chrome__find (tabId: <tabId>, query: "url textbox")
mcp__claude-in-chrome__computer (action: "left_click", ref: <url ref>)
mcp__claude-in-chrome__computer (action: "type", text: "<beehiiv URL>")
The text field is genuinely optional and works alongside the URL. The HN submit page itself states: "If there is a url, text is optional." Older skill versions warned to leave it empty — that was wrong. A 2-3 sentence "Author here…" note often helps the submission by giving commenters a thread starter. Propose one for user approval; if approved, fill:
mcp__claude-in-chrome__find (tabId: <tabId>, query: "text textbox")
mcp__claude-in-chrome__computer (action: "left_click", ref: <text ref>)
mcp__claude-in-chrome__computer (action: "type", text: "Author here. <1-2 sentence original note — no duplicate of the URL content>")
Keep the text field short (under 500 chars) and substantive. Do not paste the article body into it — that's what the URL is for.
Step 5 — Screenshot for review:
mcp__claude-in-chrome__computer (action: "screenshot", tabId: <tabId>)
Show screenshot to user (Phase 5).
Step 6 — Submit:
mcp__claude-in-chrome__find (tabId: <tabId>, query: "submit button")
mcp__claude-in-chrome__computer (action: "left_click", ref: <submit ref>)
Step 7 — Capture result:
After submit, the URL changes to news.ycombinator.com/item?id=<N>. If HN redirects to a rate-limit warning ("You're posting too fast") or a validation error, capture that instead and report to the user. Also watch for the "[dead]" state — HN silently kills flagged submissions, which appear successful but never show on /newest.
Platform: Reddit (Link Submission)
Reddit has an OAuth2 write API, but it requires creating a Reddit app and managing tokens. Browser automation via Claude in Chrome is simpler and consistent with the rest of the skill. The user must be logged in to reddit.com in their real Chrome browser.
Step 1 — Select subreddits:
Before presenting the list, consider the article's content type and recommend a tailored subset:
- Technical tutorial / how-to → Programming + AI categories both fit
- Personal essay / reflection → AI category (r/ClaudeAI, r/vibecoding, r/singularity) fits well; programming subs are a weaker fit
- Research / benchmarks → r/MachineLearning, r/LocalLLaMA, r/LocalLLM
- Business / launch → r/SaaS, r/Entrepreneur, r/indiehackers, r/startups
- Ops / infra → r/devops, r/sre
Present the full categorized subreddit list and let the user multi-select (with an option to add custom subs):
AI / LLM / Agents (primary fit for most AI-related content):
r/ClaudeAI — Anthropic/Claude-specific; Claude Code workflows. Requires a post flair; accepts link posts.
r/AI_Agents (212K) — explicitly about LLMs with tool-use / agentic systems. Does NOT allow link posts (the Link and Images tabs are disabled). Only Text or Poll. Either skip, or do a text post with the URL embedded in the body.
r/vibecoding (89K) — hands-on AI coding. Post tags optional, link posts work.
r/VibeCodeDevs (15K) — sister community.
r/ChatGPTCoding — Claude Code + AI coding workflows. Requires a persistent subreddit-level user flair on your profile (set via the "SET USER FLAIR" sidebar widget on any r/ChatGPTCoding page). Selecting a user flair inside the post flair modal is NOT enough — the Post button stays disabled with "Please select a user flair before posting". Also has an aggressive spam filter (see "Reddit spam filter triggers" below).
r/ArtificialIntelligence — general AI discussion
r/artificial — general AI discussion (different sub, smaller)
r/singularity — future of AI, philosophical posts welcome
r/OpenAI (730K+) — OpenAI/ChatGPT-focused
r/GPT (590K+) — GPT-focused
r/PromptEngineering — practical prompt tactics
r/LocalLLaMA — local LLM tinkering, quantization, Ollama/llama.cpp
r/LocalLLM — related to LocalLLaMA
r/LanguageTechnology — NLP, transformers, embeddings
r/MachineLearning (2M+) — research-heavy; may reject personal essays
r/aineurips — AI research + news, less technical than r/MachineLearning
Programming / Software Engineering (best for technical how-to; weak fit for personal essays):
r/programming — NOTE: bans LLM-primarily-generated posts (2026 rule); human-written OK but moderated
r/coding — opinion pieces / tutorials, no news
r/webdev — web development focus
r/ExperiencedDevs — 3+ years dev, career + engineering
r/softwareengineering — system design, enterprise challenges
r/cscareerquestions — CS/SWE/SRE careers
r/compsci — general computer science
r/learnprogramming — beginner help (weak fit for cross-posts)
Business / Indie / SaaS:
r/SaaS — SaaS launches and discussion
r/Entrepreneur — general entrepreneurship
r/indiehackers — indie maker content
r/startups — startup launches
Specialty / Ops:
r/devops — devops practices
r/sre — site reliability engineering
If the user selects more than 5 subs, warn them about Reddit's 9:1 self-promotion rule and confirm.
Step 2 — For each selected subreddit, repeat Steps 3–8:
Step 3 — Navigate to submit page:
mcp__claude-in-chrome__navigate (url: "https://www.reddit.com/r/<subreddit>/submit", tabId: <tabId>)
Step 4 — Verify login:
mcp__claude-in-chrome__read_page (tabId: <tabId>, filter: "interactive")
Logged in = page shows "Post", "Images & Video", "Link", "Poll" tabs and a title textbox. Not logged in = login prompt. Handoff if not logged in.
Step 5 — Select "Link" post type: