| name | carousel-newsletter |
| description | Use when user wants to promote a beehiiv newsletter as a 10-slide illustrated carousel to Buffer for Instagram, LinkedIn, Facebook, Threads — "carousel the newsletter", "swipe post", "newsletter carousel", "10-slide post". Generates on-brand EVC slides with AI-generated illustrations (Gemini 2.5 Flash Image / Nano Banana) and schedules them with a "comment 'newsletter'" CTA. |
| user_invocable | true |
carousel-newsletter
Promote a beehiiv newsletter post as a 10-slide illustrated carousel on every Buffer-connected channel (Instagram, LinkedIn, Facebook, Threads). The carousel summarizes the newsletter using direct quotes and drives a comment "newsletter" CTA that routes into the Comment-to-DM funnel.
The visual system: two-zone slides — a cream text zone + a dedicated illustration zone filled by a Gemini-generated image. The Enterprise Vibe Code (EVC) banner is attached as a style reference on every generation, locking palette / character design / illustration language.
When to run
User says things like:
- "carousel my latest newsletter"
- "make a swipe post for the EVC article"
- "10-slide post of this newsletter"
If they want a single-image snippet, use promote-newsletter. If they want to republish the full article, use crosspost-newsletter.
Recommended sequencing for major article launches: run /crosspost-newsletter FIRST (publishes the LinkedIn pulse article), THEN /carousel-newsletter so the carousel re-engages an audience that already has the long-form context. Confirmed 2026-04-27: the LinkedIn pulse accompanying post for "Tokens From Our Past" was the #1-impressions LinkedIn post within hours of publish; following it with a carousel hits a primed audience rather than a cold one. See also feedback_full_stack_newsletter_launch.md for the broader 4-skill pipeline.
🟢 Happy Path (read first; everything below is edge-case detail)
For a beehiiv newsletter carousel when nothing goes wrong. ~10-15 min wall-clock (Gemini rate limits dominate). Two user gates: copy approval before illustration spend, image approval before Buffer.
Phase 1 — Fetch (1 min). WebFetch the beehiiv RSS feed (or the URL the user provides). Extract title, subtitle, H2 headings, body paragraphs, blockquotes, stat-shaped phrases. Save to /tmp/carousel-<slug>/source.json. Run _shared/voice-corpus/voice-corpus and hold the JSON output for voice-grounding the original-copy slides.
Phase 2 — Draft 10-slide script (2-3 min). USER REVIEW GATE (COPY). Plain-text outline only — no rendering. Fixed structure: 1 hook, 2/6/9 sections, 3/5/7 verbatim quotes (≤260 chars each), 4/8 stats, 10 fixed CTA with accent word newsletter. Voice-ground slides 1/2/4/6/8/9 against the corpus (quotes are verbatim — do NOT rewrite). Surface the outline and stop for user approval before spending on illustrations.
Phase 3 — Generate illustrations (3-5 min). For each slide write a scene prompt that fills the illustration zone edge-to-edge and reinforces the slide's meaning. Aspect ratios: hook 4:3, section/stat/CTA 9:16, quote 16:9. Call python3 templates/gen_illustration.py "<scene>" /tmp/carousel-<slug>/illustrations/slide-NN.png --aspect <ratio>. Sleep ~15s between calls for Nano Banana rate limit. ~$0.40 total.
Phase 4 — Render HTML → PNG (1 min). For each slide, copy the template, substitute {{ILLUS_IMG}} and copy placeholders, then templates/render.sh <filled.html> <output.png>. Always produces exactly 1080×1350.
Phase 5 — Adversarial review (REQUIRED). Run _shared/adversarial-review/adversarial-review over all 10 slides at once (copy + scene prompts). SOURCE_CONTENT must have Publication: Enterprise Vibe Code prepended above the article title so the attribution line on quote slides isn't flagged as fabrication. Must return all_pass. Copy fails → fix + re-render (cheap). Illustration fails → regen single image (~$0.04) + re-render.
Phase 6 — User image review gate. Open /tmp/carousel-<slug>/preview.html as a 10-slide grid. Single-slide regen is cheap — encourage iteration.
Phase 7 — Host publicly (30 sec). mkdir -p generated/<YYYY-MM-DD>-<slug>/, copy PNGs in, commit + push. Raw URLs: https://raw.githubusercontent.com/<owner>/<repo>/main/generated/<YYYY-MM-DD>-<slug>/slide-NN.png.
Phase 8 — Post to Buffer (1 min). 🔒 HARD GATE: adversarial review must be all_pass before this phase. list_channels filtered to isDisconnected=false AND isLocked=false. Per-channel mcp__buffer__create_post with all 10 raw PNG URLs in assets.images, caption = "<strongest quote>" + _shared/cta.sh "<Article Title>" output (no trailing punctuation), mode: "addToQueue", schedulingType: "automatic", tagIds: [<carousel Tag ID from _shared/buffer-post-prep/tag-ids.local.json>]. Instagram metadata: { instagram: { type: "post", shouldShareToFeed: true } } — Instagram auto-renders ≥2 images as a carousel. Facebook + Threads use their respective type: "post" metadata. LinkedIn needs no metadata.
Phase 9 — Summary. Table: Platform | Channel | Status | Buffer queue URL. Confirm slide 10 accent word is literally newsletter.
Prerequisites
Auth (one-time): Google Cloud Application Default Credentials.
brew install --cask gcloud-cli
gcloud auth application-default login
gcloud config set project gen-lang-client-0527845499
Python deps (one-time): pip3 install --user --break-system-packages google-genai
Runtime requirements:
- Google Chrome at
/Applications/Google Chrome.app (headless render).
sips on PATH (macOS built-in).
python3 ≥ 3.10.
- EVC banner reference image at
~/Pictures/evc_banner2.png (used as style reference for every image-gen call).
- Public hosting for the rendered PNGs — default flow commits them to this repo under
generated/<YYYY-MM-DD>-<slug>/ and serves via GitHub raw URLs. Buffer needs publicly reachable URLs.
Key files
templates/shared.css — brand tokens + two-zone layout classes (.split-hook, .split-section, .split-quote, .split-stat, .split-cta). Each .split-* class sets the absolute positioning of the .zone-text and .zone-illus boxes for that slide type.
templates/01-hook.html — cover slide (text 40% top, illus 60% bottom, 4:3 scene).
templates/02-section.html — kicker + headline + body (text left 65%, illus right 35%, 9:16 narrow scene).
templates/03-quote.html — verbatim quote (text top 80%, illus bottom 20% wide strip, 16:9 scene).
templates/04-stat.html — big number + label (text left 60%, illus right 40%, 9:16 scene).
templates/05-cta.html — "Comment 'newsletter'" CTA (text left 60%, illus right 40%, 9:16 scene).
templates/gen_illustration.py — image-gen helper. Calls Gemini 2.5 Flash Image via Vertex AI with the EVC banner as style reference + master brand prompt + per-slide scene prompt. Usage: gen_illustration.py "<scene>" <output.png> --aspect <ratio>.
templates/gen_illustration_openai.py — drop-in alternative engine (added 2026-06-27). Same CLI + the SAME imported BRAND_PROMPT, but renders via OpenAI gpt-image-1 through images.edit (the only gpt-image-1 path that accepts a reference image — images.generate has none) with the EVC banner as the reference. Use for an engine bake-off or a deliberate library restyle. Keep ONE engine per deck — mixing Gemini + gpt-image-1 drifts slide-to-slide. gpt-image-1 reads bolder/heavier-ink vs Gemini's lighter painterly style; 5× the cost ($0.20/slide at high) and ~6× slower. Needs OPENAI_API_KEY (a real platform key with image access — a codex/ChatGPT login token does NOT authorize the Images API) and a verified OpenAI org (the script reports that specific failure). gpt-image-1 only emits 1024×1024 / 1536×1024 / 1024×1536, so the script maps each --aspect to the nearest by orientation; the two-zone HTML render crops to the zone, so the size mismatch is lossless.
templates/render.sh — HTML → 1080×1350 PNG. Headless Chrome with viewport-chrome compensation + sips crop. Usage: render.sh <filled.html> <output.png>.
templates/illustrations.svg — legacy SVG sprite from the v1 hand-drawn approach. Retained for backward compat (render.sh still inlines it if <!--SVG_SPRITE--> marker present) but no longer used by the current templates.
examples/sample-deck/ — reference 10-slide rendered deck (pre-image-gen era; visually outdated but shows the structural layout).
Related skills to read first
../promote-newsletter/SKILL.md — beehiiv RSS fetch, Buffer channel filter (connected + unlocked), CTA copy, rate-limit/remaining-posts pattern.
../crosspost-newsletter/SKILL.md — richer beehiiv DOM extraction if you need quote candidates beyond what RSS exposes.
Workflow
Phase 1 — Fetch the newsletter + Voice Corpus
Same pattern as promote-newsletter. WebFetch the beehiiv RSS feed (or the URL the user provides). Extract: title, subtitle, H2 section headings, body paragraphs, blockquotes, stat-shaped phrases, hero image URL (for reference, not used in the deck). Save to /tmp/carousel-<slug>/source.json.
Also fetch the voice corpus (recent newsletters, used as voice reference in Phase 2 for the original-copy slides):
_shared/voice-corpus/voice-corpus
Output is JSON with posts: [{title, url, published_at, source_type, body_text}] (source_type = newsletter or youtube_live). Hold onto this output for Phase 2. See PATTERNS.md#pattern-voice-grounding-for-original-copy-generation for the rationale.
Use body_text as-is in Phase 2 prompts — do NOT add a second truncation (no body_text[:N] inline). The binary already caps each post per its config. See _shared/voice-corpus/README.md § Consumers.
Phase 2 — Draft the 10-slide script ← USER REVIEW GATE (COPY)
VOICE GROUNDING applies to slides 1, 2, 4, 6, 8, 9 ONLY.
The 10-slide deck mixes verbatim quotes and original copy. The voice grounding rule applies only to the original-copy slides:
- Slides 1 (hook), 2 / 6 / 9 (sections), 4 / 8 (stats) — original copy, MUST sound like the author. Prepend the Phase 1 voice-corpus output as inline excerpts and match: sentence rhythm, vocabulary preferences ("vibe coding", "agentic", etc.), first-person stance, slight irreverence + grounded practicality. Mismatched voice on these slides is a fail signal — same weight as a fabrication. See PATTERNS.md#pattern-voice-grounding-for-original-copy-generation.
- Slides 3 / 5 / 7 (quotes) — VERBATIM from the source article. Voice grounding does NOT apply; do not rewrite the quote in the author's voice (the quote IS the author's voice already).
- Slide 10 (CTA) — fixed template. Not subject to voice rewriting.
Plain text outline, no rendering yet. Fixed structure:
| # | Template | Purpose |
|---|
| 1 | 01-hook.html | Title + 1-line tease. LINE_1 + LINE_2 ≤ ~26 chars combined for clean 2-line wrap. TEASE ≤ 120 chars. |
| 2 | 02-section.html | Stage-setter. Body ≤ 220 chars. |
| 3 | 03-quote.html | Strongest verbatim quote (≤260 chars). Attribution = <Article Title> — Enterprise Vibe Code. |
| 4 | 04-stat.html | Stat or big number from the article. |
| 5 | 03-quote.html | Second verbatim quote. |
| 6 | 02-section.html | Key insight / "the shift". |
| 7 | 03-quote.html | Third verbatim quote. |
| 8 | 04-stat.html OR 02-section.html | Supporting point. |
| 9 | 02-section.html | Payoff / what to do. |
| 10 | 05-cta.html | Fixed CTA, accent word = newsletter (verbatim; must match Comment-to-DM trigger). |
Surface this outline and stop for user approval before generating illustrations (which cost ~$0.04 each and hit rate limits).
Phase 3 — Generate per-slide illustrations
For each slide, compose a scene prompt that describes what fills the illustration zone. Key rules:
- Describe a scene that FILLS the entire frame edge-to-edge. Don't say "leave empty" — Nano Banana ignores region constraints. Composition is guaranteed by the two-zone HTML layout, not by the prompt.
- Tie the scene to the slide's meaning. The robot, bricks, and tracks should do something relevant to the slide's text — a robot meditating on a stat slide about patience, a robot running on tracks for a "velocity" slide, a robot carefully stacking bricks for a "build slowly" slide. Generic scenes feel boring after a few decks.
- Match the aspect to the zone:
- Hook →
--aspect 4:3
- Section →
--aspect 9:16
- Quote →
--aspect 16:9
- Stat →
--aspect 9:16
- CTA →
--aspect 9:16
Example scene prompts (contextual):
| Slide concept | Scene |
|---|
| Hook: "3 Lessons from Black Belt" | Horizontal scene: robot on rail tracks with wrench, minecart of bricks, 3-brick tower, large gear, small gear accent. |
| Section: "Train so you can train tomorrow" | Narrow portrait: robot patiently placing one brick on a 2-brick base — showing careful incremental work. |
| Quote about sustainability | Wide strip: robot seated on a rail tie next to a water-jug-shaped brick, relaxed posture. |
| Stat: "14 yrs" | Narrow portrait: tall 4-tier brick tower with tiny gear at top, robot looking up at it. |
| Section: "Democratized knowing and doing" | Narrow portrait: robot handing a wrench to a second smaller robot — teaching/enabling. |
| CTA | Narrow portrait: cheerful robot waving, celebratory brick tower, confetti-like gears floating. |
Call pattern:
python3 templates/gen_illustration.py "<scene>" /tmp/carousel-<slug>/illustrations/slide-NN.png --aspect <ratio>
Rate limiting: Nano Banana defaults to ~5 req/min on a new project. Sleep ~15s between calls, OR wrap each call in a retry loop with 30s backoff on 429. A full 10-slide deck takes ~3–5 min wall-clock including backoff.
Cost: ~$0.04/image = ~$0.40 per 10-slide deck in steady state. Billed to the gen-lang-client-0527845499 project.
Auth model: gen_illustration.py uses GOOGLE_GENAI_USE_VERTEXAI=true + ADC. If you see google.auth.exceptions.DefaultCredentialsError, the user needs to re-run gcloud auth application-default login.
Phase 4 — Render HTML → PNG
For each slide:
- Copy the template, substitute placeholders:
html = html.replace('{{ILLUS_IMG}}', f'file://{illus_path}')
for k, v in copy.items():
html = html.replace('{{' + k + '}}', v)
templates/render.sh <filled.html> <output.png> — always produces exactly 1080×1350.
The render script handles Chrome's 87px window-chrome offset and crops via sips. Don't reinvent it.
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. Prepend a
Publication: Enterprise Vibe Code line above the title so reviewers know the publication name is in scope when validating attribution lines (otherwise stricter reviewers like agy flag <Title> — Enterprise Vibe Code as a fabrication because "Enterprise Vibe Code" doesn't appear in the article body). Caught 2026-05-03 (then with the gemini reviewer; agy now fills that strict-reviewer slot).
- SKILL_NAME:
carousel-newsletter
- ARTIFACT_NAME: "slide" (run reviewer over all 10 slides — copy + illustration scene prompt — at once)
- RULES_LIST:
- Quote slides (template 03-quote): the QUOTE field MUST be verbatim from the source. No paraphrasing.
- Section slides (template 02-section): KICKER + HEADLINE + BODY must be grounded in the source. BODY can paraphrase but must not invent claims.
- Stat slides (template 04-stat): the NUMBER + LABEL must come from the source verbatim or be derivable from a fact in the source.
- CTA slide (template 05-cta): ACCENT_WORD MUST be literally
newsletter (case-insensitive). Comment-to-DM trigger depends on this exact string — see CTA pattern.
- Illustrations: each scene prompt should reinforce the slide's meaning (the illustration content should be relatable to the slide copy).
- BANNED: invented stats, fabricated quotes, claims the source doesn't support.
- ISSUE_GUIDANCE: "For quote slides cite the source line; for invented claims cite both the slide and what the source says."
Apply verdicts (carousel-specific):
- All PASS → proceed to Phase 5 image preview.
- Any FAIL on copy → fix the copy + re-render the slide PNG (cheap, no Gemini cost) → re-run reviewer.
- Any FAIL on illustration → regenerate that single illustration (~$0.04) with a revised scene prompt → re-render → re-run reviewer.
Verify reviewer FAILs against the source before editing — quote slides especially (learned 2026-06-27). Two persistent panel-noise patterns on this skill:
- Apostrophe hallucination on quote slides.
codex/agy/grok-build intermittently claim a curly-vs-straight apostrophe mismatch on a verbatim quote — in both directions (claimed straight U+0027 when the source was curly U+2019, and vice versa), and even contradicting their own prior-round PASS on the same slide. The "I turned 35" source genuinely mixes curly and straight apostrophes by region. When a quote slide is flagged for a character mismatch, dump codepoints (ord()) of the quote vs the source substring — if the quote is a byte-identical substring of body_text, it IS verbatim and the claim is false. Do NOT "fix" a correct quote to appease the reviewer.
grok-build "not in source" false claims. It flagged source-grounded phrases as absent when grep proved otherwise (e.g. "running my own race" is a literal section title; "build" appears in the body). Always grep a "not in source" claim before editing. Per the round cap, surface verified-false claims as deadlocks with the proof rather than degrading copy to chase an all_pass — 4-of-5 reviewers passing + two disprovable objections is a deadlock, not a fail.
Phase 5 — User review gate (IMAGES)
Open a preview grid:
python3 -c "..." > /tmp/carousel-<slug>/preview.html && open /tmp/carousel-<slug>/preview.html
Let the user approve or request regenerations. Single-slide regen is cheap (~$0.04), so encourage iteration on slides that missed.
Common failure modes to watch for:
- Illustration went off-palette (teal/orange snuck in) → regenerate with stronger palette emphasis in the scene prompt.
- Text from the article accidentally appeared in the illustration → regenerate (model sometimes draws words).
- Zone cropping cut off a key element → shift the scene prompt to position the subject where the zone crops it well (center, left, right).
Phase 6 — Host publicly
Buffer requires public URLs. Default flow:
mkdir -p generated/<YYYY-MM-DD>-<slug>/
cp /tmp/carousel-<slug>/slide-*.png generated/<YYYY-MM-DD>-<slug>/
git add generated/<YYYY-MM-DD>-<slug>/ && git commit -m "Add carousel assets: <title>" && git push
Raw URL: https://raw.githubusercontent.com/<owner>/<repo>/main/generated/<YYYY-MM-DD>-<slug>/slide-NN.png
Phase 7 — Post to Buffer
🔒 HARD GATE — adversarial review must have returned summary == "all_pass" for ALL slides before this phase.
If the prior _shared/adversarial-review/adversarial-review run returned some_fail for any slide draft, you MUST NOT call mcp__buffer__create_post. Iterate fixes per the round cap (5 rounds max), then surface deadlocks to the user. Both copy AND illustration scene prompts go through review; both must PASS before the carousel ships.
Reuse patterns from ../promote-newsletter/SKILL.md. Filter list_channels to isDisconnected=false AND isLocked=false. Per-channel:
All captions use the canonical CTA — generate via _shared/cta.sh "<Article Title>" and concatenate. Do NOT add a trailing period or other punctuation; PATTERNS.md warns that ad-hoc edits to the CTA risk breaking the Manychat trigger silently.
| Platform | metadata | assets.images | Caption |
|---|
| Instagram | { instagram: { type: "post", shouldShareToFeed: true } } | All 10 PNG URLs (multi-image post auto-creates carousel when ≥2 images attached) | "<strongest quote>"\n\n + canonical CTA from _shared/cta.sh |
| LinkedIn | (no metadata required) | All 10 PNG URLs | Same as IG. |
| Facebook | { facebook: { type: "post" } } | All 10 PNG URLs | Same as IG. |
| Threads | { threads: { type: "post" } } | All 10 PNG URLs (max 20) | Same as IG. |
mode: "addToQueue", schedulingType: "automatic", tagIds: [<format:carousel Tag ID>] (required for closed-loop measurement — buffer-stats uses this tag to compute per-format engagement). Buffer's CreatePostInput schema has tagIds: [TagId!] (24-char hex MongoDB ObjectIds) — NOT a tags field. Tag name strings are silently dropped if you send them as tags. Look up the carousel Tag ID from _shared/buffer-post-prep/tag-ids.local.json (one-time setup — see _shared/buffer-post-prep/README.md) and pass it as tagIds: [<id>]. If the file is missing or the key isn't there, ship the post untagged and warn the user — audit-buffer-queue will catch it next week.
Important — instagram.type does NOT accept carousel as of 2026-04-26. The Buffer MCP API rejects it with "Invalid option: expected one of 'story'|'reel'|'post'". Use instagram.type: "post" and attach all 10 image URLs in assets.images — Instagram automatically renders multi-image posts as a carousel. Earlier skill claim about PostType.carousel was inaccurate (that's a top-level PostType enum but not valid for InstagramPostMetadataInput.type).
If a channel rejects the carousel payload, log and continue — do not abort the whole run. Channels not targeted: X/Twitter (not connected), TikTok/YouTube (need video).
Phase 8 — Summary
Print a table: Platform | Channel | Status | Buffer queue URL. Report path to local PNGs and the GitHub raw URL prefix. Confirm the CTA accent word is literally newsletter.
The master brand prompt
Locked in gen_illustration.py as the BRAND_PROMPT constant. It covers: illustration style, strict palette (navy / green / yellow / blue / gray / brown / white / cream), robot character design, lego brick rules, gears, rails, minecart, composition rules (no text in images, small navy triangle watermark bottom-right). Every image-gen call prepends this prompt before the per-slide scene description. Do not tweak it per-post — consistency across decks is the point.
Gotchas
- ADC expiry: the creds file at
~/.config/gcloud/application_default_credentials.json is long-lived but tied to the user's Google account. Revoking Google access or gcloud auth application-default revoke breaks the skill.
- Banner size: the full
evc_banner2.png is ~4.9 MB; Vertex silently drops attachments that large. gen_illustration.py caches a downscaled 1024px version at /tmp/evc-banner-1024.png.
- Slide 10 accent word: MUST be
newsletter verbatim — the Comment-to-DM automation listens for this exact trigger.
- Inter font fallback: templates assume Inter with
Helvetica Neue fallback. If Inter isn't installed, the display weight reads a little lighter but still on-brand.
- Chrome version drift: the 87-px window-chrome reservation in
render.sh is based on Chrome 147. If elements clip at the bottom, measure the actual viewport and bump the --window-size value.
Verification checklist before shipping