Maintenance
| Agent | Version | Last Changed |
|---|---|---|
| Link | v2.20.0 | July 12, 2026 |
Description: Daily automated content production — generate copy and images from Notion Social Calendar, publish to Zernio API, update Notion, notify Slack
Change Log
v2.20.0 — July 12, 2026
- Declared the fb.ai dependency and its required scope. fb.ai API keys are now scoped; this skill's
deps.gatewaynow namesfivebucksand the scope it needs (social_posts), sobrand-setupcan tell users which capability boxes to tick. No behaviour change — seeagents/link.mdfor the scope/error/quota contract.
v2.19.0 — July 03, 2026
- Corrected Zernio publish mechanics to the real MCP schema (fixes the v2.18.0 migration bug). v2.18.0 wrongly repointed
late_presign_upload→media_generate_upload_link, but that Zernio tool is a browser-only upload flow — PUTting to it silently fails. Now: local files (image-path_final.png, Step 5) upload viamedia_get_media_presigned_url(filename, content_type, size); fb.ai render signed URLs (template-path §7) pass directly asmedia_urlswith no re-host (the publisher fetches/caches at post-creation time, so ~1h expiry is safe within the run);validate_media(url)before publish; localcurlbackup after render. posts_createsignature corrected. Real params:content,platform(single string),account_id,media_urls(comma-separated string — not amedia_itemsarray),publish_now/is_draft/schedule_minutes. One call per platform (noplatforms[]).account_idalways passed, resolved viaaccounts_list.platformSpecificData.contentTyperemoved — no such param on Zernioposts_create; image-path posts publish as feed content. Removed theZERNIO_CONTENT_TYPEmapping and the "always pass contentType" rule (Step 5).- Media-folder name-mismatch warning added (Step 4c-template §1). When folders exist but none match the expected template-type names, surface a non-blocking Slack warning recommending a rename; continue with the all-folder pool.
v2.18.0 — July 01, 2026
- Zernio migrated to its own MCP (gateway v1.7.4). Repointed
late_presign_upload→media_generate_upload_linkandlate_create_post→posts_createto Zernio's native MCP tool names; dropped thefiveagents_api_keyparam from those calls (Zernio is now OAuth-connected, not gateway-routed); renamed${BRAND}_LATE_*env vars to${BRAND}_ZERNIO_*and internallate_*PublishLog/output fields tozernio_*. - Gemini image model ID updated (gateway v1.7.4).
gemini_generate_imagemodel changed from the retiredgemini-3.1-flash-image-previewto the GA defaultgemini-3.1-flash-image("Nano Banana 2").
v2.12.2 — June 08, 2026
- Stale
_raw.pngreference removed. Step 4g cleanup and Quality Checklist both referenced_raw.pngas an intermediate file to delete — that filename is never created (Gemini backgrounds save tobrands/{brand}/backgrounds/). Only_with_text.pngis a real intermediate. Agents following the old instruction silently no-op'd on a non-existent file. TOOLS.mddead reference removed. Step 5 intro said "See TOOLS.md → 'Social Publishing' for account IDs" — that file does not exist; account IDs are documented inline in Step 5 itself.- Step 6
old_strnow matches"Processing". Fix 2 (Step 5) writes"Processing"beforelate_create_post. The previous Step 6old_strwas still"Planned"— after Fix 2, the Notion row reads"Processing"so the Step 6update_contentcall was silently no-op'ing and the row never reached"Published". - Template-path carousel/single-image no longer refers to Step 5. §7 said "pass
media_itemsinlate_create_post(Step 5)" — Step 5 has an "Image-path only" guard. Reference removed; note added that template-path posts don't proceed to Step 5. LATE_CONTENT_TYPEdict now includes"reel"key. Step 5 said "for Reels and Stories" but the dict had no Reel entry. Agents handling a calendar row with Format ="Reel"had no contentType to look up.- Step 4c routing table + decision logic: Reel row added. The dimension table listed Reels but Step 4c had no routing entry — Reels fell through to the "Any | Post" catch-all. Now explicit: Reel → always Step 4c-image, 9:16 canvas,
contentType: "reel". add_text_overlaycomment corrected:ratio ≥ 1.78→ratio ≥ 1.7. Comment and code disagreed on the Story/Reel detection threshold; code (>= 1.7) is authoritative.
v2.12.1 — May 30, 2026
- Media pool fallback made explicit. Restructured the media pool build from a single dense paragraph into a numbered sub-step list so Claude reliably executes both fallback levels during runs: (1) exact folder-name match first, (2) all-folders pool if no exact match, (3) empty pool if no folders. Previously the fallback clauses were buried in prose and skipped in practice, causing posts to publish with no photos despite a media library folder being present.
SKILL.md — Content Generator
Before Executing
Read agents/link.md before starting. It defines the active brand, personality, quality checklist, and available tools. Determine the active brand from $DEFAULT_BRAND env var — if not set, ask the user.
Role
You are a content production agent for the active brand. Your job is to generate copy and images for today's scheduled social media posts from the Notion Social Calendar, save all outputs, update Notion, and notify via Slack.
Runs daily Mon–Sun on cron schedule. Targets posts scheduled for today (in the brand's timezone from brands/{brand}/brand.md Locale section).
Inputs
- Social calendar: Notion Social Media Calendar database — DB ID from env var
{BRAND}_NOTION_DB(e.g.FIVEBUCKS_NOTION_DB) - Publishing: Zernio API — publish immediately (
isDraft: false,publishNow: true) - Brand context:
brands/{brand}/brand.md,brands/{brand}/audience.md,brands/{brand}/product.md - Mode:
AUTO_PUBLISH=true— publish immediately, do NOT save as draft
Step 1 — Find today's posts in Notion
Target date: today in the brand's timezone (read from brands/{brand}/brand.md Locale section, e.g. TZ=Asia/Jakarta date '+%d %b %Y')
Find the active Social Calendar page
Use Notion MCP to read the calendar. Follow these steps:
⚠️ Never run a bare workspace-wide
notion-search(e.g. searching"SocialCalendar_"with nodata_source_urlfilter). It returns matches from every brand and other unrelated databases in the workspace, and content-generator will publish the wrong brand's content. The search MUST be scoped to the active brand's database.
1a. Resolve the brand's database to a collection:// URL, then search inside it:
The available Notion MCP tools are notion-fetch and notion-search. Use this two-step pattern:
- Fetch the brand's DB to discover its collection URL:
Use mcp__claude_ai_Notion__notion-fetch:
- id: "${BRAND}_NOTION_DB" # the brand's DB ID from env var, e.g. FIVEBUCKS_NOTION_DB
Inspect the response and extract the collection:// URL — typically returned as data_sources[0].url or under a collection field. Save it as data_source_url.
- Search inside that collection:
Use mcp__claude_ai_Notion__notion-search:
- query: "SocialCalendar_"
- data_source_url: <data_source_url from step 1>
- query_type: "internal"
This restricts results to pages inside the brand's social calendar DB. From the results, pick the page whose title contains SocialCalendar_ and whose date range covers today. Title format: SocialCalendar_DDMon-DDMonYYYY (e.g. SocialCalendar_06Apr-11Apr2026).
Brand-header validation (mandatory before proceeding): open the candidate page with notion-fetch and confirm the page's parent / database title matches ${BRAND} — e.g. parent is "Fivebucks Social Media Calendar", not "NPC Office Social Media Calendar". If the brand header doesn't match, abort with a failed run log — do not publish content from another brand's calendar.
Fallback (only if step 1 returns no collection:// URL):
If notion-fetch on the DB ID does not yield a usable data_source_url, fall back to a workspace-wide notion-search AND apply the brand-header validation strictly:
Use mcp__claude_ai_Notion__notion-search:
- query: "SocialCalendar_ ${BRAND}" # include brand name to disambiguate
Filter every result by checking the page's parent database title contains the brand. Reject any result whose parent does not include the active brand. If no result passes the brand check, log failed and exit — never default to the first match.
1b. Read the table from the chosen page:
Use mcp__claude_ai_Notion__notion-fetch with the page ID to retrieve the page content. Locate the table block (or the page's child database, depending on how the calendar was structured) and extract its table_row children. If the table is nested as a child page rather than inline, fetch the child page first.
1c. Parse rows into post objects:
Each table row has cells in this order (column index):
[0] Date, [1] Platform, [2] Format, [3] Topic, [4] Persona, [5] ContentAngle, [6] CTA, [7] Hashtags, [8] ImageBrief, [9] Direction, [10] Status
Direction is set by social-calendar at planning time and tells content-generator which template variant to use:
- Story format (
meta-story): one of"A"(Spotlight Dark, brand-led),"B"(Editorial Stat, single big claim),"C"(Cream Press, case studies / testimonials). Maps to the_directionoverride. - Carousel format (
meta-carousel): one of"type-allnumbers"(default),"sticker-editorial","editorial-mixed", or whatevercoverVariant-bodyVariantcombination the brand's template supports. - Single-image formats (
linkedin-post,meta-post):"A"/"B"/"C"— maps to the un-prefixeddirectionoverride. - Formats with no matching fb.ai template: leave blank — Direction does not apply.
If Direction is blank for a Story / Carousel / single-image post, default to "A" (story / single-image) or "type-allnumbers" (carousel) and log a warning — the calendar should have assigned one. There is no separate slide column: fb.ai selects the slide(s) from the direction override at render time (see Step 4c-template §6).
Skip the header row (index 0). Filter rows where:
Datematches today's date (in brand timezone)Status=="Planned"
Save each row's block ID as _row_id — needed for Step 6 status update.
If no matching rows, log "No posts scheduled for [date]" and exit.
Step 2 — Read brand context
Read before writing any copy:
brands/{brand}/brand.md— voice, tone, approved phrasesbrands/{brand}/audience.md— persona pain points and triggersbrands/{brand}/product.md— features, pricing, differentiators
Read before generating any image — all optional, never block on missing folders:
- Brand visual identity for the Gemini-only image-path — resolve the color/font source in this 3-tier order, feeding the Gemini prompt aesthetic:
- fb.ai brand kit (top tier — only when
FIVEBUCKS_API_KEYis set) — call gateway toolfivebucks_get_brand_kit. If it returns non-null, use its color tokens (HEX) + typography as the authoritative source — resolve fields via the Brand kit field map inagents/link.md(secondary→tokens.colors.accent, text→tokens.colors.dark, fonts fromtokens.fonts.heading/body; the kit has no separatesecondaryor font weight scale). Returns null when no kit is uploaded — fall through to tier 2. - brands/{brand}/design-system/ (local folder — when the fb.ai kit is null or
FIVEBUCKS_API_KEYis unset; the free baseline) — Claude Design visual system (colors, fonts, components, spacing). When present, informs the Gemini-only image-path's prompt aesthetic. - brands/{brand}/brand.md — the Colors and Voice & Tone sections plus the Google Font names captured in brand-setup Step 4. Universal fallback when neither of the above is available.
- fb.ai brand kit (top tier — only when
- Social templates on fb.ai — the brand's Claude-designed social templates live on fb.ai (uploaded via the dashboard in brand-setup Step 4c), not on disk. Discover them with the gateway tool
fivebucks_list_templates(needs the brand's fb.ai key in the vault under servicefivebucks—FIVEBUCKS_API_KEY). Each entry has anid, atype(meta-carousel|meta-story|linkedin-post|meta-post),dimensions, and amanifest(editable fields + image slots + slide IDs). Used by Step 4c-template; cache the list for the whole run (don't call per post).
If no matching fb.ai template exists for the post's format, fall back to the Gemini-only path (Step 4c-image) — sourcing colors/fonts via the same 3-tier order (fb.ai brand kit when the key is set → local design-system/ → brand.md colors/voice). Never log a failed run for missing visual assets.
Step 3 — Write copy for each post
For each post, generate:
- Hook (first line) — must stop the scroll; persona-specific pain point
- Body (2–4 short paragraphs) — one key insight per paragraph, no fluff
- CTA — from the calendar's CTA field; match to campaign goal
- Hashtags — use calendar hashtags; add 1–2 relevant extras if needed
Copy format by platform
| Platform | Hook | Body | CTA | Total length |
|---|---|---|---|---|
| Bold stat or provocative question | 3–4 paragraphs, professional tone | Text + link | ~1200 chars | |
| Relatable pain moment | 2–3 short paragraphs, conversational | Short CTA | ~800 chars | |
| 3–5 word hook only | Bullet points or very short copy | "Link in bio" | ~300 chars |
For Stories: caption text is NOT displayed (Stories are visual-only). Still write a production script for the _copy.md file, but send minimal text to Zernio (just hashtags or empty string).
Naming convention for output files
outputs/{brand}/posts/[Platform]/[TopicSlug]_[DDMonYYYY]_copy.md
Examples:
outputs/{brand}/posts/LinkedIn/AISearchSEOFoundations_12Mar2026_copy.mdoutputs/{brand}/posts/Facebook/Replace5Tools_12Mar2026_copy.md
Always overwrite existing files — never skip because a file already exists.
Step 3b — Generate structured _copy.json for template-path posts
For posts that will render via the template-path (a matching fb.ai template exists for the post's format — see Step 4c), produce a structured copy artifact alongside _copy.md:
outputs/{brand}/posts/[Platform]/[TopicSlug]_[DDMonYYYY]_copy.json
The JSON's keys MUST match the template's fb.ai manifest field keys (fivebucks_get_template → manifest.fields[].key) — the canonical key set + per-key character budgets per type are documented in content-creation/SKILL.md ("template copy contracts"). Read those budgets before writing.
Map the post's hook/body/CTA (from Step 3) into the template's structure per type:
- meta-carousel (6 slides): Cover (
cover_eyebrow+cover_title+cover_sub) → 4 sign slides (s2_kicker/s2_title/s2_bodythroughs5_*, optionals2_pullquote,s3_stat_value/s3_stat_label,s5_before/s5_after) → CTA (cta_eyebrow+cta_title+cta_sub+cta_button). - meta-story (6 slides): Hook (
s1_*) → Problem (s2_*— eyebrow + headline + 3 pain bullets) → Solution (s3_*) → Proof (s4_*— 4 stats + quote + author) → Offer (s5_*— 4 bullets + pill) → CTA (s6_*). Set_direction(A/B/C). - linkedin-post (1 slide):
eyebrow+headline+body+stat_value/stat_label+quote/attribution+cta_text+cta_button. Setdirection(A/B/C). - meta-post (1 slide):
eyebrow+headline+body+quote/attribution+b1…b5+cta_text+cta_button. Setdirection(A/B/C).
If the post brief is too thin to fill all required keys, omit those keys — fb.ai seeds the template's defaults for any key you don't send — and log a warning to memory.
Skip Step 3b for non-template posts — any post where no matching fb.ai template exists. For those, only _copy.md is required; content-generator's image-path uses the headline + body from _copy.md directly via Pillow text overlay.
Step 4 — Generate images
Two image-production paths depending on Format and template availability — see Step 4c for the dispatcher.
- Template-path (Carousel / Story / single-image Post on IG/FB/LinkedIn when a matching fb.ai template exists): fb.ai renders the slides server-side via
fivebucks_render_post; no Gemini call, no Pillow overlay. - Image-path (any post with no matching fb.ai template, or when the fb.ai render fails / quota is exhausted): Gemini generates the background fresh + Pillow stamps text and logo. Universal fallback — always available.
Step 4a — Determine canvas dimensions
Template-path posts skip this step — fb.ai renders each template at its own registered dimensions (e.g. 1080×1350 for 4:5 types, 1080×1920 for meta-story). The table below applies only to the image-path (Step 4c-image).
| Format | target_w | target_h |
|---|---|---|
| LinkedIn Post | 1200 | 628 |
| Facebook Post | 1200 | 630 |
| Instagram Post (square) | 1080 | 1080 |
| Instagram Post (portrait) | 1080 | 1350 |
| Instagram Reel / Facebook Reel | 1080 | 1920 |
| Instagram Story / Facebook Story | 1080 | 1920 |
Per-canvas insets — Story/Reel uses Meta safe zones only; Feed text uses asymmetric pad-derived values (larger sides); Feed logo uses uniform pad // 2.
pad = int(target_w * 0.06) (6% of canvas width). On 9:16 text and logo use the same Meta safe zones. On feed they diverge on sides only — text needs extra side clearance to survive IG profile-grid 4:5 recrop (34 px side trim).
| Canvas | Element | Top | Bottom | Sides | Scrim fade |
|---|---|---|---|---|---|
| 9:16 Story/Reel (1080×1920) | text + logo | int(target_h * 0.14) (~269 px) |
int(target_h * 0.13) (~250 px) |
int(target_w * 0.13) (~140 px) |
0 |
| All feed formats (IG, FB, LinkedIn, X) | text | pad // 2 (~32–36 px) |
pad // 2 |
pad + pad // 2 (~96–108 px) |
pad // 2 |
| All feed formats (IG, FB, LinkedIn, X) | logo | pad // 2 |
pad // 2 |
pad // 2 |
n/a |
Annotations:
- Story/Reel (9:16) — Meta safe zones only. Top 14% clears the Stories profile header; bottom 13% clears the Reels UI stack; sides 13% clear the Reels right-rail action buttons.
scrim_fade = 0keeps the gradient flush to the safe-zone boundary with no transition padding (nopadanywhere in 9:16 geometry). - Feed text — asymmetric. Top, bottom, and scrim_fade all
pad // 2(32–36 px). Sides96–108 px) — Instagram's profile-grid view recrops square feed posts to 4:5 portrait, trimming ~34 px per side; the extra side inset guarantees text survives that crop with visible breathing room. Do not "simplify" feed text back to uniformpad + pad // 2(pad // 2— that regressed v2.4.8 hardening (see v2.5.2 changelog). - Feed logo — uniform
pad // 2. All four sides ~32–36 px. Logo is small relative to canvas, so the IG profile-grid crop doesn't impact it the way it impacts text; uniform pad-derived inset reads as intentional corner placement. - Gradient scrim direction depends on
text_position. Bottom-anchored text → scrim runsscrim_top → target_h(alpha 0→230, dark zone at bottom). Top-anchored text → scrim runs0 → text_bottom + scrim_fade(alpha 230→0, dark zone at top). The dark zone is always on the same end as the text.
Step 4b — Day-of-week layout rotation
Determine the day-of-week for the post date, then apply:
| Day | text_align | text_position | logo_position |
|---|---|---|---|
| Mon | left | bottom | top-right |
| Tue | center | top | bottom-left |
| Wed | right | bottom | top-right |
| Thu | left | top | bottom-left |
| Fri | center | bottom | top-right |
| Sat | right | top | bottom-left |
Text and logo are always on opposite vertical ends — never on the same row. Mon/Wed/Fri = bottom text + top-right logo; Tue/Thu/Sat = top text + bottom-left logo. text_align cycles left → center → right across days.
⚠️ The day-of-week rotation table applies only on the Gemini-only image-path (Step 4c-image). On the template-path (Step 4c-template) all text, logo, kicker numerals, and CTA chrome are produced by the React template's render — Pillow text overlay and logo overlay are both skipped, and neither text_align nor logo_position rotation has any effect on template-path posts.
Step 4c — Choose asset type: Image or Video
Check the post Format from the calendar. "A <type> template exists" means the cached fivebucks_list_templates result (Step 4c-template, step 1) includes an entry with that type.
| Platform | Format | Asset Type | Tool |
|---|---|---|---|
| FB/IG | Carousel | Static images | If a meta-carousel template exists on fb.ai → Step 4c-template. Else → Step 4c-image (Gemini background → text overlay → logo). |
| FB/IG | Story | Static image | If a meta-story template exists → Step 4c-template. Else → Step 4c-image (publish as Story). |
| FB/IG | Reel | 9:16 static image | Step 4c-image (Gemini background → text overlay → logo; aspect_ratio: "9:16"). No fb.ai template type exists for Reels, and Zernio posts_create cannot set a Reel content-type — the 9:16 image publishes as a feed post. |
| FB/IG | Post (single image) | Static image | If a meta-post template exists → Step 4c-template. Else → Step 4c-image. |
| Post | Static image | If a linkedin-post template exists → Step 4c-template. Else → Step 4c-image. |
|
| Any | Post | Static image | Step 4c-image (no matching template) |
Decision logic:
- Check the
Formatfield from the Notion calendar. - If Format =
"Reel"AND platform ∈ {Instagram, Facebook} → use Step 4c-image (9:16 canvas). No fb.ai template type exists for Reels, and Zernioposts_createcannot set a Reel content-type — the 9:16 image publishes as a feed post. - If Format =
"Carousel"AND platform ∈ {Instagram, Facebook} AND ameta-carouseltemplate exists → use Step 4c-template. - If Format =
"Story"AND platform ∈ {Instagram, Facebook} AND ameta-storytemplate exists → use Step 4c-template. - If Format =
"Post"AND platform ∈ {Instagram, Facebook} AND ameta-posttemplate exists → use Step 4c-template. - If Format =
"Post"AND platform = LinkedIn AND alinkedin-posttemplate exists → use Step 4c-template. - All other formats (or no matching template) → use Step 4c-image (Gemini-generated background + text overlay + logo).
Step 4c-template — Render via fb.ai (fivebucks_*)
Use this path when a matching fb.ai template exists for the post's format (see Step 4c). Rendering is server-side on fb.ai — no local Playwright, no Gemini-for-slots, no Zernio slot presign. Templates render their own images and tint overlay natively; the skill just supplies copy (and optionally photos) and collects rendered PNGs.
No Pillow text overlay, no Pillow logo overlay on this path. The skill's jobs: pick the template by type → build copy overrides → create a post → render → re-host the PNGs on Zernio for publishing.
Requires the brand's fb.ai key in the vault under service fivebucks (FIVEBUCKS_API_KEY, brand-setup Step 7). Each render consumes 1.0 CONTENT_GENERATION quota on fb.ai regardless of slide count.
Steps:
1. List templates (cache for the run)
Use gateway MCP tool fivebucks_list_templates:
- fiveagents_api_key: ${FIVEAGENTS_API_KEY}
Returns an array of templates, each with id, name, type (meta-carousel | meta-story | linkedin-post | meta-post), dimensions, and manifest. Build a type → {id, manifest} map and cache it for the entire daily run — don't call per post. If no template of the type this post needs exists, fall back to Step 4c-image.
Also at run start, build the media pool (cache alongside the template list — call once, not per post). Execute these sub-steps in order — do not skip the fallbacks:
Call
fivebucks_list_media_foldersonce (cache the result alongside the template list):Use gateway MCP tool fivebucks_list_media_folders: - fiveagents_api_key: ${FIVEAGENTS_API_KEY}Match each returned folder to a template type by exact name (case-insensitive):
Folder name Template type LinkedIn Postlinkedin-postMeta Storymeta-storyMeta Carouselmeta-carouselMeta Postmeta-postFor each matched folder: call
fivebucks_list_media_files→ store the file list asmedia_pool[type].Fallback — no exact match: if a template type has no folder with a matching name, combine ALL returned folders — call
fivebucks_list_media_filesfor every folder and merge into a single list. Use that combined list asmedia_pool[type]. (Do not leave the type's pool empty just because its named folder is missing.) When this fallback fires (folders exist but none match the expected namesLinkedIn Post/Meta Story/Meta Carousel/Meta Post), surface a non-blocking warning in the Slack notification recommending the user rename folders to the expected names so per-type photo injection works correctly. Do not fail — continue with the all-folder pool.Fallback — no folders at all: if
fivebucks_list_media_foldersreturns an empty list, setmedia_poolto empty. Photo injection is skipped for the entire run — this is normal, not an error.On any API error from
fivebucks_list_media_folders: skip silently, treat the pool as empty. Never fail or warn the run.
2. Read the manifest
From the cached entry (or fivebucks_get_template for the freshest copy) read:
manifest.fields[]— each{ key, label, group, type, default, bound?, options? }. Skip fields withbound === false(hardcoded — editing has no effect). Fortype: 'select'fields, send only a value fromoptions. Fortype: 'image'slots, the value is""or"media:{fileId}", with companion{slot}_image_position/{slot}_image_fitselects.manifest.slides[]— slide IDs. The render returns one PNG per slide, in slide order.manifest.theme— informational (the template bakes its own overlay:dark= 40% black,light= 50% white). Use it to pick photos that contrast.
3. Build the overrides payload (copy → manifest field keys)
Flat key→value map. Send only fields you're changing; fb.ai seeds defaults for the rest. Use the per-type key sets in content-creation/SKILL.md (or the Step 3b _copy.json). Set direction:
- meta-story:
_direction="A"/"B"/"C"(default"A"— renders 6 slides). Never"all"(renders 18 slides and burns the same 1.0 quota at once). - linkedin-post / meta-post: the un-prefixed
direction="A"/"B"/"C"(picks the single slide's layout). - meta-carousel:
coverVariant/bodyVariantfrom the post's Direction field (e.g."type-allnumbers"→coverVariant="type",bodyVariant="allnumbers") if the manifest exposes them.
4. Assign photos from the media pool
Use media_pool[type] built in Step 1. For this post's template type:
- Pool non-empty → inject photos into image slots:
meta-carousel/meta-storybody slotss2_image…s5_image: cycle through the pool (up to 4 photos; reuse from the start if the pool has fewer than 4). Use a different photo per slot when possible.linkedin-post/meta-post: assign one photo tobg_image.- For each assigned slot add companion overrides:
"<slot>_image_position": "center"and"<slot>_image_fit": "cover".
- Pool empty → leave all image slots empty — the template renders its own branded placeholder/gradient. Never fail or warn for an empty pool; this is the normal no-media fallback.
5. Create the post
Use gateway MCP tool fivebucks_create_post:
- fiveagents_api_key: ${FIVEAGENTS_API_KEY}
- template_id: "<id from step 1>"
- name: "[Brand] [Platform] [Format] — [Topic] — [Date]"
- overrides: { ...copy + direction (+ image slots from step 4)... }
→ Returns the new post id
To adjust copy or add photos after creating, call fivebucks_update_post (post_id, overrides) — overrides merge.
6. Render — fb.ai selects the slides from the Direction
Omit slide_ids for every template type. fb.ai filters direction-based templates by the post's direction override server-side and screenshots only the right slide(s). You set the direction in overrides (Step 3); fivebucks_render_post returns the correct PNGs in slide order. Don't pass slide_ids to choose a direction — that's fb.ai's job now (slide_ids remains only as an optional way to render a specific subset, which you never need here).
Template type |
Direction override (set in Step 3) | What fb.ai renders |
|---|---|---|
meta-story |
_direction = A/B/C |
that direction's 6 slides (all = 18 — never use) |
meta-carousel |
coverVariant/bodyVariant (style) |
all 6 slides |
linkedin-post |
direction = A/B/C |
the 1 slide for that direction |
meta-post |
direction = A/B/C |
the 1 slide for that direction |
Use gateway MCP tool fivebucks_render_post:
- fiveagents_api_key: ${FIVEAGENTS_API_KEY}
- post_id: "<id from step 5>"
- (omit slide_ids)
→ Returns 1-hour signed PNG URL(s), one per rendered slide, in slide order
meta-story renders the 6 slides of the _direction set (A/B/C — never all, which renders 18 slides and burns the same 1.0 quota). meta-carousel renders all 6. linkedin-post / meta-post each return the single slide matching direction. fb.ai resolves any "media:{fileId}" overrides server-side and the template renders the photo natively — nothing else to wire.
7. Publish — Story vs other formats
Pass the fivebucks_render_post signed URLs directly to posts_create as media_urls — do not re-host. The publisher fetches and caches each image at post-creation time (not at publish time), so the ~1-hour signed-URL expiry is safe as long as posts_create runs in the same run as the render. validate_media(url) each URL first, and curl a local backup of each slide before publishing.
⚠️ Do not use media_generate_upload_link here — it is a browser-only upload flow (returns a human upload-page URL, not a programmatic presign) and PUTting to it silently fails. It was mis-used by the v2.18.0 migration. Resolve account IDs via accounts_list at run start.
posts_create real signature: content, platform (single string), account_id, media_urls (comma-separated string), publish_now/is_draft/schedule_minutes. There is no platforms[] array and no platformSpecificData — publish one posts_create per platform.
meta-story (6 slides → 6 separate Story posts per platform):
Post one slide at a time with a 1-second sleep between calls. After every ~5 consecutive FB story posts, sleep 5 seconds before the next call (Facebook rate-limits rapid sequential story submissions) and retry once on rejection.
import time, subprocess
for i, signed_url in enumerate(render_urls, start=1): # slide-1 through slide-6
validate_media(url=signed_url) # confirm valid: true before publishing
# Local backup — preserves the asset even if a publish step later fails
subprocess.run(["curl", "-s", "-L", signed_url,
"-o", f"outputs/{brand}/posts/Instagram/{slug}_story-slide-{i}_{date}.png"])
for platform, account_id in [("facebook", ZERNIO_ACCOUNTS["facebook"]),
("instagram", ZERNIO_ACCOUNTS["instagram"])]:
for attempt in range(2):
result = posts_create(
content=copy_text,
platform=platform,
account_id=account_id,
media_urls=signed_url, # signed URL passed directly — no re-host
publish_now=True,
is_draft=False,
)
if result.get("success") or attempt == 1:
break
time.sleep(5) # FB rate-limit back-off before retry
time.sleep(1) # 1-second gap between slides
This produces 6 separate Story posts per platform — one per slide, in slide order.
meta-carousel and single-image types (linkedin-post, meta-post) — publish directly:
media_urls = ",".join(render_urls) # comma-separated string, in slide order (one URL for single-image types)
for signed_url in render_urls:
validate_media(url=signed_url)
posts_create(
content=copy_text,
platform=platform_key,
account_id=ZERNIO_ACCOUNTS[platform_key],
media_urls=media_urls, # carousel = comma-separated URLs; single-image = one URL
publish_now=True,
is_draft=False,
)
A carousel becomes a comma-separated media_urls string; single-image types are one URL. curl a local backup of each signed_url first. (Template-path posts do not proceed to Step 5 — that section is image-path only.)
Skip Steps 4d, 4e, 4f, 4g — no Pillow overlays, no local tmp files. Day-of-week text_align / logo_position rotations apply only to Step 4c-image.
On quota / subscription error (fivebucks_render_post returns 402/403 with a quota body): surface the upgrade message in the Slack notification, set the Notion status to "Draft Ready", and fall back to Step 4c-image for this post.
On other failure (5xx / timeout):
- Log a Slack warning for this post:
"⚠️ [{brand}] fb.ai render failed for '{topic}' — falling back to Gemini-only path. Post marked Draft Ready." - Fall back to Step 4c-image (Gemini background → Pillow text overlay → Pillow logo overlay).
- Set Notion status to
"Draft Ready"instead of"Published"so the user reviews the fallback before resending. - Do NOT retry the render — render once and fall back.
Step 4c-image — Generate image via Gemini
Story full-frame guard (defensive — belt-and-suspenders):
Before building the Gemini prompt, check whether the post is a Story and whether the image_brief already contains the full-frame composition instruction written by social-calendar v2.5.0+. If it does not (e.g. the calendar was authored by an older run), wrap it now:
STORY_FULLFRAME_TEMPLATE = (
"Photorealistic, full-bleed vertical portrait image for a 9:16 social media Story. "
"{SCENE_DESCRIPTION}. The scene fills the ENTIRE frame from top to bottom — no empty areas, "
"no plain backgrounds, no flat colour zones anywhere in the image. Rich environmental detail "
"in the upper, middle, AND lower thirds of the frame. Cinematic lighting, editorial quality. "
"Shot as if for a magazine cover in portrait orientation. "
"Do not include any text, logos, or UI elements."
)
if post.format.lower() == "story" \
and "fills the ENTIRE frame" not in post.image_brief:
image_brief = STORY_FULLFRAME_TEMPLATE.format(SCENE_DESCRIPTION=post.image_brief)
else:
image_brief = post.image_brief
Use image_brief (the potentially-wrapped version) as the Gemini prompt base from this point on — never post.image_brief directly.
Generate a fresh image for every post using Gemini:
Use gateway MCP tool `gemini_generate_image`:
- fiveagents_api_key: ${FIVEAGENTS_API_KEY}
- prompt: Build from: brand visual style (from brand.md), post topic, mood, and image_brief (the wrapped version from the guard above). Example: "Professional clean desk workspace with laptop showing analytics dashboard, soft natural lighting, warm tones, no text, no people, bokeh background"
- aspect_ratio: match target canvas from Step 4a (e.g. "1:1" for IG square, "9:16" for Story/Reel, "191:100" for LinkedIn)
- model: "gemini-3.1-flash-image"
Result is auto-saved to a temp file. Use Python to locate, decode, and save to disk:
```python
import glob, json, base64, os
result_file = max(glob.glob('/sessions/*/mnt/.claude/projects/*/tool-results/mcp-*gemini_generate_image*.txt'), key=os.path.getmtime)
with open(result_file) as f:
parsed = json.loads(json.load(f)[0]['text'])
with open('brands/{brand}/backgrounds/{descriptive_filename}.png', 'wb') as f:
f.write(base64.b64decode(parsed['image_base64']))
Prompt rules:
- Always include "no text, no people" — text and logo are added in Steps 4d/4e
- Match the brand's visual style and color palette — 3-tier read order: (1) fb.ai brand kit via
fivebucks_get_brand_kitwhenFIVEBUCKS_API_KEYis set; (2)brands/{brand}/design-system/when the kit is null or the key is unset; (3)brands/{brand}/brand.mdColors section as the universal fallback. Extract the primary/accent/background HEX tokens from whichever source applied and inject them into the prompt as "warm tones around #ec4899 / muted slate around #0f172a" style guidance — when the fb.ai kit is the source, resolve roles via the Brand kit field map inagents/link.md(secondary→tokens.colors.accent, text→tokens.colors.dark; the kit has no separatesecondarytoken). Same Visual consistency rule asagents/link.md— never hardcode from memory. - Pillow text rendering in Step 4d uses
DejaVuSans-Boldas a stable cross-platform rasterizer; the brand font names (fb.ai brand kit / design-system files) are for reference only (Canva / HTML mockups consume them, this image path does not). Colors on text are picked adaptively from the Gemini background, which is why getting the brand palette into the Gemini prompt at this step matters. - Keep it clean and uncluttered — the text overlay needs readable space at the top or bottom (per the day-of-week
text_positionfrom Step 4b)
Step 4d — Apply text overlay — USE PILLOW
Use Python Pillow to add gradient scrim + headline + subline. Do NOT use image_add_text_overlay gateway MCP tool.
from PIL import Image, ImageDraw, ImageFont, ImageStat
def add_text_overlay(input_path, output_path, headline, subline, target_w, target_h,
text_align='center', text_position='bottom'):
img = Image.open(input_path).convert('RGBA')
# Resize + center-crop to target canvas
r = img.width / img.height; tr = target_w / target_h
if r > tr: nw = int(img.width * target_h / img.height); nh = target_h
else: nw = target_w; nh = int(img.height * target_w / img.width)
img = img.resize((nw, nh), Image.LANCZOS)
img = img.crop(((nw-target_w)//2, (nh-target_h)//2, (nw-target_w)//2+target_w, (nh-target_h)//2+target_h))
pad = int(target_w * 0.06)
# Per-canvas insets — Story/Reel uses Meta safe zones only; Feed uses pad-derived only.
# is_story_reel: True only for 9:16 (ratio ≥ 1.7). IG portrait 4:5 = 1.25 → feed treatment.
# target_h > target_w is NOT sufficient: IG portrait (1080×1350) would wrongly get 9:16 safe zones.
is_story_reel = (target_h / target_w) >= 1.7
if is_story_reel:
# Meta-spec safe zones (matches "central 1080x1420 of 1080x1920" rule)
top_inset = int(target_h * 0.14) # Stories profile header clearance
bottom_inset = int(target_h * 0.13) # Reels UI stack clearance
side_inset = int(target_w * 0.13) # Reels right-rail clearance
scrim_fade = 0 # no extra pad transition past text edge
else:
# Feed — asymmetric: top/bottom/scrim_fade = pad // 2; sides = pad + pad // 2.
# Sides need extra room: IG profile-grid view recrops square feed posts to 4:5,
# trimming ~34 px per side. pad // 2 (~32 px) gets entirely consumed by that crop.
# Logo (add_logo) stays at uniform pad // 2 — text and logo diverge on sides by design.
# Do NOT "simplify" sid
…(truncated)