| name | promote-newsletter |
| description | Use when user wants to promote a beehiiv newsletter post on social media, create social posts from a newsletter, or schedule newsletter promotions to Buffer channels — "promote this newsletter", "share newsletter on social", "create posts for this article". |
| user_invocable | true |
promote-newsletter
Extract the strongest snippets from a beehiiv newsletter post and schedule each selected snippet to every eligible Buffer channel. Never rewrite content — only trim to fit each channel's char budget.
Usage
/promote-newsletter <beehiiv-post-url> or /promote-newsletter latest
🟢 Happy Path (read first; everything below is edge-case detail)
For a beehiiv newsletter promotion when nothing goes wrong. ~5-10 min wall-clock, mostly waiting on user review. Linear flow:
Phase 1 — Fetch content (~1 min).
- Refresh voice-corpus cache:
_shared/voice-corpus/voice-corpus --refresh. Pull article body via jq -r '.posts[] | select(.url | contains("<slug>")) | .body_text' _shared/voice-corpus/cache.json > /tmp/newsletter-body.txt. (Prefer this over WebFetch — beehiiv URLs trip its copyright guardrail.)
- WebFetch the article URL with an image-only prompt to enumerate every image URL (hero, inline, charts).
- HEAD-check each image URL with
curl -sI ... -w "%{http_code}"; drop any non-200 from the pool (beehiiv's CDN occasionally serves 307→403).
Phase 2 — Pick snippets + check queue (~1 min).
- Select 3-5 strongest snippets from the body — concrete insights, stats, or provocative claims that stand alone.
- Call
mcp__buffer__get_account for the org ID, then mcp__buffer__list_posts (status: ["scheduled", "needs_approval", "draft"], first: 100) to fetch the upcoming queue.
- Substring-match each snippet (distinctive 4-8 word phrase) and the article title against queued post text. Annotate each snippet
✅ new or ⚠️ queued Nx (earliest YYYY-MM-DD).
- Present the numbered snippet list + image pool to the user, hero image flagged as the default attachment.
Phase 3 — User snippet selection. Single question: which snippets to approve (default: all ✅ new) and which image to attach to each. Wait for input. Every approved snippet fans out to every eligible Buffer channel.
Phase 4 — Compose (snippet × channel matrix). Build a JSON manifest with one cell per (snippet × channel) — never a bash array, since snippet bodies are multi-line. For each cell: trim the verbatim excerpt to fit the channel's char budget (use … for truncation, never paraphrase) and append the canonical CTA Comment "newsletter" to get my latest post, "<Article Title>" separated by a blank line. Distribute the image pool with priority Instagram → LinkedIn → Facebook → Threads, never reusing a URL (Buffer dedups). Skip TikTok / YouTube channels (video-only).
Phase 5 — Adversarial review. Run _shared/adversarial-review/adversarial-review on the full batch with SOURCE_LABEL: "SOURCE ARTICLE", the full beehiiv body, and the verbatim-excerpt + CTA rules. Must return all_pass before Phase 6.
Phase 6 — User review. Render the matrix as a single table (one row per cell: snippet, channel, chars/limit, image, body preview), followed by each unique body once. Ask "Ready to schedule all N posts to Buffer?". User can approve all, edit by row #, drop a snippet, skip a channel, or cancel.
Phase 7 — Schedule. For each approved cell, build args via _shared/buffer-post-prep/buffer-post-prep (attaches format-tag = verbatim_quote) and call mcp__buffer__create_post. Report per-channel success.
Process
Phase 1 — Fetch Newsletter Content
Always try _shared/voice-corpus first for the body text. The binary fetches the user's beehiiv RSS feed and caches every post's plain-text body — no rate limits, no copyright guardrails, no LLM-mediated summarization. Confirmed 2026-05-17: WebFetch on the user's own newsletter URL refused to return the article verbatim ("substantial reproduction of copyrighted material"), which broke the verbatim-quote rule. The RSS cache has no such problem.
_shared/voice-corpus/voice-corpus --refresh
jq -r '.posts[] | select(.url | contains("<slug>")) | .body_text' \
_shared/voice-corpus/cache.json > /tmp/newsletter-body.txt
WebFetch is still needed for image URLs, since voice-corpus strips HTML. Make a SECOND WebFetch call to the article URL with a tightly-scoped prompt:
List EVERY image URL that appears in the article — hero, inline images,
graphs/charts, screenshots. For each one tell me: (a) full URL, (b) what
it depicts (one short phrase), (c) approximately where it appears. Do not
summarize the article. Just enumerate the image URLs.
Pre-validate every image URL with a HEAD request before adding it to the image pool. beehiiv's media.beehiiv.com/cdn-cgi/image/... URLs occasionally return a 307 redirect to a 403, and Buffer's image-dimensions fetcher rejects them at create_post time with "Failed to fetch image dimensions: Not Found". Confirmed 2026-05-17 on the Magic-section image of "The Fix-Forward Solutions to AI Coding Problems."
for url in "${IMAGE_URLS[@]}"; do
code=$(curl -sI "$url" -o /dev/null -w "%{http_code}")
if [[ "$code" == "200" ]]; then echo "OK $url"
else echo "DROP ($code) $url"
fi
done
Drop any non-200 URL from the image pool. Note the drop count in Phase 2 so the user sees how many images were filtered.
Instagram aspect-ratio guard (added 2026-07-19). Instagram only accepts feed images with width/height between 4:5 (0.8) and 1.91:1. beehiiv header/hero images are typically ~3:1 (e.g. 2172×724) and get rejected at publish time — on the 2026-06-19 "I turned 35" run, all 5 IG posts errored with "Instagram doesn't support the aspect ratio of this media" and sat in status:error for a month, so IG (the #1 beehiiv-conversion channel) got zero promote posts for that launch. Before assigning an image to an Instagram cell, check its dimensions (sips -g pixelWidth -g pixelHeight on a downloaded copy, or the dimensions Buffer echoes back in the asset metadata) and:
- ratio within [0.8, 1.91] → OK to assign to IG.
- ratio outside the range → do NOT assign it to the IG cell. Either (a) pad it to 1.91:1 on a solid background (
ffmpeg/sips) and host the padded copy, or (b) give the IG cell a different in-range image from the pool, or (c) skip IG for that snippet per the existing out-of-images rule. Non-IG cells (LinkedIn/FB/Threads) can still use the wide image freely.
- After scheduling, run a
list_posts status:[error] readback — an errored IG post is silent (Buffer doesn't surface it) and starves the top conversion channel.
If "latest" or no URL:
Refresh the voice-corpus cache and list the recent articles with titles and dates. Ask the user which one to promote, then proceed with that article's URL.
If the article isn't on beehiiv (rare — this skill is beehiiv-first): fall back to the full WebFetch flow. Be aware its content-policy guardrails may refuse full-body extraction.
Phase 2 — Identify Candidate Snippets & Media
Select 3-5 strongest snippets from the article. Criteria:
- Contains a concrete insight, statistic, or provocative claim
- Stands alone without needing surrounding context
- Quotable and compelling for social media
Present each snippet as a numbered list:
1. [key stat] "73% of developers now use AI daily..." (142 chars)
2. [bold claim] "The real shift isn't automation..." (203 chars)
3. [insight] "What most people miss about..." (178 chars)
Also present extracted images with URLs. Recommend the hero/header image as the default media attachment.
Before presenting the list, check the Buffer queue for existing posts from this article.
Users often kick off /promote-newsletter when earlier promotion runs (crosspost-newsletter, carousel-newsletter, a previous invocation of this skill) have already seeded the queue with related posts. Flag overlapping snippets so the user doesn't unknowingly re-promote the same lines.
- Call
mcp__buffer__get_account for the organization ID. If Buffer MCP is unreachable, skip this check and warn the user at the top of the snippet list: "Buffer queue check skipped — unable to reach Buffer MCP. Existing queue may overlap." Then proceed without annotations.
- Call
mcp__buffer__list_posts with:
organizationId: from step 1
status: ["scheduled", "needs_approval", "draft"]
first: 100
sort: [{field: "dueAt", direction: "asc"}]
- If the response exceeds the tool-result size limit (saves to a file automatically), use
jq to extract {dueAt, channelService, text} per post. You're scanning post text, not rendering the full payload.
- For each candidate snippet (and the article title), match against queued post text (case-insensitive substring) using:
- A distinctive 4-8 word phrase from the snippet (e.g.,
"personal SaaS-apocalypse", "blender of what has existed") — not a common word.
- The article title or a distinctive title phrase.
- Be specific — don't match on common words like "AI" or "shipped" alone.
- Annotate each snippet in the Phase 2 presentation with a status tag:
✅ new — zero matching queued posts
⚠️ queued Nx (earliest YYYY-MM-DD) — N matching posts; show the soonest dueAt
Mention the dup count in the Phase 3 question as well so the user's snippet selection is informed. Default recommendation: prefer ✅ new snippets unless the user wants deliberate repetition.
Phase 3 — User Snippet Selection
Ask the user:
- Which snippet(s) to approve for posting (default: all
✅ new snippets)
- Which image to attach to each snippet (default: hero image for the first, then walk through the article's other images in order; remaining snippets go text-only)
Default fan-out behavior: every approved snippet posts to every eligible Buffer channel — one post per (snippet × channel) pair. Do not ask the user to assign different snippets to different platforms; the user picks which snippets are good, and every approved snippet ships to every channel.
Wait for user input before proceeding.
Phase 4 — Compose Posts (full fan-out: every approved snippet × every eligible channel)
For each approved snippet, compose one post per eligible Buffer channel — same snippet text, trimmed (or expanded) to fit that channel's char budget. The matrix is snippets × channels, not "one snippet per platform."
Prep manifest format. Snippet bodies are multi-line; bash arrays + read -r IFS='|' truncate at the first newline. Use a JSON manifest instead — one object per cell, then iterate in Python:
[
{"id": "01", "channel_id": "692438d9...", "service": "linkedin",
"text": "We as an industry...\n\n…\n\nWhat can you add to tokens that someone else can't? Your perspective.\n\nComment \"newsletter\" to get my latest post, \"<Article Title>\"",
"image_url": null, "image_alt": null},
{"id": "02", "channel_id": "6934230229...", "service": "linkedin",
"text": "We as an industry...", "image_url": "https://media.beehiiv.com/...",
"image_alt": "<Article Title>"}
]
A Python loop calling buffer-post-prep once per cell handles multi-line bodies safely. Confirmed 2026-05-17: a bash array-based prep silently truncated all 6 long-form bodies to their first line.
CRITICAL RULE — No Rewriting:
Never rewrite, paraphrase, or rephrase the author's words. You may only:
- Remove words or sentences to shorten (use ellipsis
... where text was removed)
- Add line breaks for readability
- Truncate from the end, preserving complete sentences
The post body (excluding CTA) must be a direct excerpt from the article.
Every post must end with this CTA (separated by a blank line), using the article title:
Comment "newsletter" to get my latest post, "<Article Title>"
Character limits (CTA is ~70 chars depending on title length, leave ~5 char safety margin):
| Platform | Limit | Snippet budget |
|---|
| Twitter/X | 280 | 280 - CTA length - 7 margin |
| Bluesky | 300 | 300 - CTA length - 7 margin |
| Pinterest | 300 | 300 - CTA length - 7 margin |
| Threads | 500 | 500 - CTA length - 7 margin |
| Facebook | 500 | 500 - CTA length - 7 margin |
| Mastodon | 500 | 500 - CTA length - 7 margin |
| Instagram | 2,200 | 2,200 - CTA length - 7 margin |
| TikTok | 2,200 | 2,200 - CTA length - 7 margin |
| LinkedIn | 3,000 | 3,000 - CTA length - 7 margin |
Calculate the actual CTA length using the article title to determine snippet budgets. The CTA format is Comment "newsletter" to get my latest post, "<title>" — count the full string including the title.
For short-form platforms (Twitter, Bluesky, Pinterest): when a snippet exceeds the budget, trim from the end with … while preserving complete sentences. If even the first sentence won't fit, drop that snippet for that channel and surface the skip — never paraphrase to fit.
For long-form platforms (LinkedIn, Instagram): the full snippet can be used, potentially with multiple paragraphs.
Media attachment (snippet × channel fan-out has a tight image budget):
- IMPORTANT — Buffer duplicate detection: Buffer will reject posts that share the same image URL, treating them as duplicate content. Each (snippet × channel) cell needs either a unique image URL or to be text-only across the whole batch.
- Default distribution: the article's inline images are a shared pool. Distribute them across the snippet × channel matrix with this priority order, never reusing a URL: (1) Instagram cells first — Instagram requires an image, so every snippet's Instagram cell must get one before other channels; (2) LinkedIn cells next — visuals lift engagement most here; (3) Facebook cells; (4) Threads cells — Threads is conversational, text-only is fine. Cells that don't receive an image go text-only.
- Out of images: if the pool runs dry, remaining cells go text-only. If a snippet has no image left for its Instagram cell, skip Instagram for that snippet rather than sending text-only (Buffer will reject) or reusing a URL (Buffer dedups).
- Attach images via
assets.images with metadata.altText set to the article title.
Skip TikTok and YouTube channels — they require video assets, not images.
Adversarial review (REQUIRED before user review)
Apply the Adversarial Review pattern with these per-skill specifics:
- SOURCE_LABEL: "SOURCE ARTICLE"
- SOURCE_CONTENT: the full beehiiv article body, verbatim
- SKILL_NAME:
promote-newsletter
- ARTIFACT_NAME: "post"
- RULES_LIST:
- Each post body (excluding CTA) must be a DIRECT EXCERPT from the source. No rewriting, no paraphrasing.
- Allowed edits: remove words/sentences (use
…), add line breaks, truncate from the end.
- Every post MUST end with the canonical CTA from
_shared/cta.sh "<Article Title>".
- No emoji unless explicitly requested.
- No unverifiable third-party claims ("every leader I respect…", "everyone in [industry] knows…").
- ISSUE_GUIDANCE: "If a snippet IS verbatim, confirm by saying 'Verbatim match: lines N-M of source.' Do not give a PASS without citing the verbatim location."
This is what makes "no fabrication" a property of the system rather than a hope.
Phase 5 — Review Before Publishing
Render the snippet × channel matrix as a single table — at 4 snippets × 6 channels = 24 posts, the per-channel narrative format used to drown in scroll. Show one row per cell with the cell's character count, image assignment, and a body preview:
Snippet × Channel matrix (24 cells)
# | snippet | channel | chars/limit | image | body preview
01 | A | LinkedIn (Mike)| 613/3000 | text-only | We as an industry need…
02 | A | LinkedIn (EVC) | 613/3000 | Open Secret (#2) | We as an industry need…
03 | A | Threads (mike) | 307/500 | text-only | The key question for…
...
Follow the table with the full body of each unique snippet variant (long-form vs short-form per snippet — typically 2 variants × N snippets = 6–10 unique bodies, not 24), so the user can scan once per snippet rather than once per cell.
Ask: "Ready to schedule all N posts to Buffer?"
The user can:
- Approve all
- Edit individual cells (by row #)
- Drop a snippet entirely (removes all of its row entries)
- Skip a specific channel column
- Cancel entirely
Phase 6 — Schedule to Buffer
🔒 HARD GATE — adversarial review must have returned summary == "all_pass" before this phase.
If the prior _shared/adversarial-review/adversarial-review run returned some_fail for any draft in this batch, you MUST NOT call mcp__buffer__create_post for that draft. Iterate fixes per the round cap, then re-run. After 5 rounds, surface remaining FAILs as deadlocks to the user; only proceed once the user explicitly accepts each deadlocked claim. Spot-checking claims manually does NOT substitute — the 4-way independent review catches issues a careful manual review misses (today's run caught "30+ PRs" inflated metric vs actual 30, voice drift, "on every public method" overstatement — all caught by reviewers and missed by the composer's manual check).
Apply the Buffer create_post pattern for the transport layer (channel filter, min_followers, max_posts cap, format tag attachment, platform metadata). The skill provides:
format-tag = verbatim_quote (always — per Per-skill format tag table)
- For each approved post, build args via
_shared/buffer-post-prep/buffer-post-prep, then call mcp__buffer__create_post with the resulting JSON.
Schedule byte-identical to what passed review (verbatim-critical). Curly vs straight apostrophes/quotes are part of the verbatim rule — the source of "I turned 35…" uses straight apostrophes in the body but curly in quoted phrases ("Become Undeniable") and the CTA ("Here's"). On 2026-06-19 a hand-typed curly it's (source has straight) FAILed agy + codex while claude passed (FAIL-OR → blocked). Two safeguards:
- Don't re-type snippet bodies. Extract each body directly from the assembled adversarial-review prompt (the exact text that passed) or index-slice it from the source
body_text (body[si:ei]). Generate the per-cell create_post args once with a Python script — not a bash loop: mapfile is a bash builtin that silently yields an empty array under zsh (Claude Code's Bash tool runs zsh), which drops every --image-url and makes Instagram cells fail with "instagram posts require --image-url". Python is shell-agnostic.
- Readback gate after scheduling.
mcp__buffer__list_posts filtered by the verbatim_quote tagId + status: scheduled + createdAt.start, then byte-diff each stored text against the prepped args (and assert image-URL uniqueness — Buffer dedups, so each URL is used once across the whole queue). Confirms storage is byte-faithful before you call the run done.
The skill's cognition for this phase is image assignment (which snippet × channel cell gets which image, since Buffer dedups image URLs) and scheduling order (typically snippet-by-snippet across all channels, so each snippet's fan-out goes out together). Fan-out level is no longer a per-run question — the default is "every approved snippet → every eligible channel." The user controls saturation by approving more or fewer snippets in Phase 3. The transport layer enforces:
- Skips channels with
isDisconnected: true, isLocked: true, or service: "startPage"
- Skips channels below
min_followers_to_promote = 50 (verified via mcp__buffer__get_channel)
- Validates the post text against the platform char limit
- Attaches
tagIds: [<format:verbatim-quote Tag ID>] from _shared/buffer-post-prep/tag-ids.local.json (Buffer's CreatePostInput schema requires Tag IDs, not tag-name strings — tags: [...] is silently dropped). One-time setup in _shared/buffer-post-prep/README.md. If the local config is missing, the post still ships (untagged) and the binary warns on stderr.
- Sets platform-specific metadata correctly
Rate limiting: Buffer's API enforces rate limits (HTTP 429). When scheduling many posts (e.g. multiple articles in one session), you will hit this after ~40-50 rapid create_post calls. When rate limited:
- Stop immediately — do not retry in a loop.
- Save all remaining posts (snippet text, CTA, channel IDs, image URLs, Twitter-trimmed variants) to
remaining-posts.md in the project directory so they can be scheduled in a later session.
- Report to the user which posts succeeded and which are saved for later.
Report results per channel: success or error message.