Daily automated content production — generate copy and images from Notion Social Calendar, publish to Zernio API, update Notion, notify Slack. Runs daily on cron schedule.
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).
{BRAND}_NOTION_DB (e.g. FIVEBUCKS_NOTION_DB)isDraft: false, publishNow: true)brands/{brand}/brand.md, brands/{brand}/audience.md, brands/{brand}/product.mdAUTO_PUBLISH=trueTarget date: today in the brand's timezone (read from brands/{brand}/brand.md Locale section, e.g. TZ=Asia/Jakarta date '+%d %b %Y')
Use Notion MCP to read the calendar. Follow these steps:
1a. Query the database to find the latest SocialCalendar_ page:
Use mcp__notion__API-query-data-source with:
data_source_id: the brand's Notion DB ID (from env var ${BRAND}_NOTION_DB)sorts: [{"property": "Name", "direction": "descending"}]page_size: 10From the results, find the page whose title contains SocialCalendar_ and whose date range covers today. Title format: SocialCalendar_DDMon-DDMonYYYY (e.g. SocialCalendar_06Apr-11Apr2026).
1b. Read the table from that page:
Use mcp__notion__API-get-block-children with the page ID. Find the first block with type: "table", then call mcp__notion__API-get-block-children again with the table's block ID to get all table_row blocks.
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] Status
Skip the header row (index 0). Filter rows where:
Date matches 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.
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, differentiatorsFor each post, generate:
| 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 Reels: write TWO outputs:
_copy.md): 15-30 second script with [Hook — 3s] / [Value — 12s] / [CTA — 5s] timing markerscontent): clean, readable copy — hook + 1-2 short paragraphs + CTA + hashtags. No script formatting, no timing markers. ~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).
outputs/{brand}/posts/[Platform]/[TopicSlug]_[DDMonYYYY]_copy.md
Examples:
outputs/{brand}/posts/LinkedIn/AISearchSEOFoundations_12Mar2026_copy.mdoutputs/{brand}/posts/Facebook/Replace5Tools_12Mar2026_copy.mdAlways overwrite existing files — never skip because a file already exists.
Prefer pre-stored backgrounds from brands/{brand}/backgrounds/. If none are available or no good match exists, generate on-the-fly via Gemini.
Pick the best-matching background based on the post's Topic and ImageBrief. Filenames are descriptive (e.g., finance_dashboard_laptop.png, cafe_laptop_notepad.png). Don't reuse the same background for consecutive posts on the same platform.
| 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 |
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 | bottom | top-left |
| Wed | right | bottom | top-right |
| Thu | left | bottom | top-left |
| Fri | center | bottom | top-right |
| Sat | right | bottom | top-left |
text_position is always "bottom" — never "top".
Check the post Format from the calendar:
| Platform | Format | Asset Type | Tool |
|---|---|---|---|
| Any | Post | Static image | Background (pre-stored or Gemini fallback) + text overlay + logo |
| Any | Carousel | Static images | Background (pre-stored or Gemini fallback) + text overlay + logo |
| FB/IG | Story | Static image | Background (pre-stored or Gemini fallback) + text overlay + logo (publish as Story) |
| FB/IG | Reel (Argil) | AI avatar video | Argil API (1 per brand per week, tagged by social-calendar) |
| FB/IG | Reel | Static image as Story | Background (pre-stored or Gemini fallback) + text overlay + logo (publish as Story) |
| Reel/Story | Static image | Background (pre-stored or Gemini fallback) + text overlay + logo (publish as post) |
Decision logic:
Format field from the Notion calendar"Reel (Argil)" → use Step 4c-argil (AI avatar talking-head)"Reel" (without Argil tag) → use Step 4c-image (static image, publish as Story)Only for Reels tagged (Argil) in the social calendar. Generate a talking-head video:
Write a 15–30 second script from the post's Topic, ContentAngle, and CTA:
Set aspect ratio — always "9:16" for Reels (Argil is only used for Reels, never Stories).
Create the video:
Use gateway MCP tool `argil_create_video`:
- fiveagents_api_key: ${FIVEAGENTS_API_KEY}
- name: "[Brand] [Platform] Reel - [Topic] - [Date]"
- aspect_ratio: "9:16"
- moments: [{ avatarId: "AVATAR_ID", voiceId: "VOICE_ID", transcript: "Your 15-30 second script here..." }]
→ Returns video ID
Render: argil_render_video with fiveagents_api_key + video_id
Poll: argil_get_video with fiveagents_api_key + video_id until status=DONE, extract videoUrl
Download and save: Download the video from videoUrl and save to outputs/{brand}/posts/[Platform]/[Slug]_[Date]_final.mp4
Fallback: If Argil fails (API error, timeout > 10 min, no credits), fall back to static image (Step 4c-image) and publish as Story instead of Reel.
Avatar selection — rotate for variety, prefer Asian characters for SEA markets:
Pick avatar based on the post's Persona and platform. Don't repeat the same avatar on consecutive posts for the same platform. Use argil_list_avatars gateway tool to get current IDs.
Read avatar-to-persona mappings from brands/{brand}/avatars.md. This file defines which avatars to use for each persona slug, the founder avatar + voice clone, and market preferences. Example mapping below:
| Persona Slug | Suggested Avatars | Why |
|---|---|---|
| sme-founder, solopreneur | Founder avatar, Arjun, Hassan | Founder/business owner feel |
| ops-manager, content-mgr | Ananya, Kabir, Koki | Professional/operational |
| sales-leader, sales-rep | Rahul, Hassan, Budi | Sales/outreach energy |
| cs-manager | Amira, Anjali, Ananya | Customer-facing |
| agency-owner, growth-mktr | Kabir, Arjun, Founder avatar | Strategy/leadership |
| general | Rotate any Asian avatar | Variety |
Use the founder avatar + voice clone only for authority/founder content. For all other avatars, pick a matching English voice from argil_list_voices gateway tool.
Option 1 (preferred): Use pre-stored background
List available backgrounds in brands/{brand}/backgrounds/. Pick the closest match by keyword to the post's Topic and ImageBrief. Examples:
finance_dashboard_laptop.png or stacked_invoices_desk.pngseo_performance_graph.png or analytics_dashboard_desk.pngwhatsapp_chat_night.png or automated_chat_responses.pngDon't reuse the same background for consecutive posts on the same platform.
Read the chosen background and pass its file path to the Pillow add_text_overlay function in Step 4d — Pillow handles resize + center-crop to the target canvas.
Option 2 (fallback): Generate via Gemini
If brands/{brand}/backgrounds/ is empty or no good match exists for the post topic, generate a background on-the-fly:
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 ImageBrief from the Notion calendar entry. 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-preview"
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']))
If user has selected a folder, save directly to brands/{brand}/backgrounds/ — not a temp path.
Important prompt rules for generated backgrounds:
brand.mdSave the generated image to brands/{brand}/backgrounds/ for reuse by future posts.
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
def add_text_overlay(input_path, output_path, headline, subline, target_w, target_h, text_align='center'):
img = Image.open(input_path).convert('RGBA')
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))
scrim = Image.new('RGBA', (target_w, target_h), (0,0,0,0))
ds = ImageDraw.Draw(scrim)
ss = int(target_h * 0.55)
for y in range(ss, target_h):
ds.line([(0,y),(target_w,y)], fill=(0,0,0,int(185*(y-ss)/(target_h-ss))))
img = Image.alpha_composite(img, scrim)
draw = ImageDraw.Draw(img)
hs = max(36, int(target_w * 0.048)); ss2 = max(22, int(target_w * 0.026))
try:
fh = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', hs)
fs = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', ss2)
except: fh = fs = ImageFont.load_default()
# Draw wrapped text (white headline, pink #ec4899 subline)
pad = int(target_w * 0.05)
# ... (word-wrap and draw logic)
img.convert('RGB').save(output_path, 'PNG', optimize=True)
headline: max 6–8 words, title case or all caps — use the post hook (NOT the topic name verbatim)subline: always provide a subline — never pass "". Use a short supporting line: brand tagline, key benefit, or CTA teaser (read from brands/{brand}/brand.md)target_w, target_h: canvas dimensions from Step 4atext_align: from day-of-week rotation (Step 4b)_with_text.png.Use Python Pillow to composite the logo. Do NOT use image_add_logo gateway MCP tool.
from PIL import Image
def add_logo(image_path, output_path, logo_path, position='top-right', scale=0.18):
img = Image.open(image_path).convert('RGBA')
logo = Image.open(logo_path).convert('RGBA')
w, h = img.size
logo_w = int(w * scale)
logo_h = int(logo.height * logo_w / logo.width)
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
margin = int(w * 0.03)
positions = {
'top-right': (w - logo_w - margin, margin),
'top-left': (margin, margin),
'bottom-right': (w - logo_w - margin, h - logo_h - margin),
'bottom-left': (margin, h - logo_h - margin),
}
x, y = positions[position]
img.paste(logo, (x, y), logo)
img.convert('RGB').save(output_path, 'PNG', optimize=True)
brands/{brand}/logo.png. Scale: 0.18. Position: from day-of-week rotation._final.png.outputs/{brand}/posts/[Platform]/[TopicSlug]_[DDMonYYYY]_final.png
Always overwrite — never skip existing files.
Only _final.png (or _final.mp4) should remain in the output folder. Delete any intermediate files (_raw.png, _with_text.png) for every post before moving to Step 5.
Upload the image and copy directly to Zernio API and publish immediately. See TOOLS.md → "Social Publishing" for account IDs and helper functions.
IMPORTANT: Always pass platformSpecificData.contentType for Reels and Stories. Without this, Zernio defaults everything to a feed Post regardless of image dimensions.
Reel video publishing: When the asset is a video (from Argil), upload as "type": "video" and use "contentType": "video/mp4" in the presign call. The platformSpecificData.contentType mapping for Reels stays the same.
Reel fallback rule: If Argil video generation failed and you have a static image instead, Zernio API will return a 400 aspect ratio error for Reels. In this case, fall back to "story" for both Instagram and Facebook — same 1080×1920 image dimensions, no changes needed. Log the fallback in the Slack notification and memory.
For each post, use gateway MCP tools:
1. Use `late_presign_upload`:
- fiveagents_api_key: ${FIVEAGENTS_API_KEY}
- filename: "<filename>.png" (or .mp4 for video)
- content_type: "image/png" (or "video/mp4")
→ Returns uploadUrl + publicUrl
2. Use Python requests to upload the file directly to S3 (do NOT use `late_upload_media` MCP — it requires passing large base64 through context):
```python
import requests
with open('path/to/final_image.png', 'rb') as f:
requests.put(uploadUrl, data=f, headers={'Content-Type': 'image/png'})
late_create_post:
Follow the platformSpecificData.contentType mapping and Reel fallback logic below.
**Account IDs** — read from env vars using brand prefix (e.g. `FIVEBUCKS_LATE_FB`):
```python
B = BRAND.upper()
LATE_ACCOUNTS = {
"facebook": os.environ[f"{B}_LATE_FB"],
"instagram": os.environ[f"{B}_LATE_IG"],
"linkedin": os.environ[f"{B}_LATE_LI"],
}
platformSpecificData.contentType mapping — Instagram uses "reels" (plural); Facebook uses "reel" (singular):
LATE_CONTENT_TYPE = {
"reel": {"instagram": "reels", "facebook": "reel"},
"story": {"instagram": "story", "facebook": "story"},
"carousel": {}, # Zernio handles carousels via multiple mediaItems — no contentType needed
"post": {}, # default feed post — no contentType needed
}
LATE_CONTENT_TYPE_FALLBACK = {
"reel": {"instagram": "story", "facebook": "story"},
}
FALLBACK: Reels require video. If publishing a static image as a Reel and Zernio returns 400, retry with contentType "story" for both Instagram and Facebook (same 1080x1920 dimensions).
For each post, determine the platform object:
platform_key = post platform lowercase ("facebook" | "instagram" | "linkedin")post_format = post format lowercase ("post" | "reel" | "story" | "carousel")account_id = from env var {BRAND}_LATE_{PLATFORM} (e.g. FIVEBUCKS_LATE_FB)platformSpecificData.contentType using the mapping above (required for Reels/Stories)Then call late_create_post with the assembled platform object, media URL from step 2, and copy text.
Do NOT store copy in Notion — Zernio is the single source of truth.
Set Status (cell index 9) based on what was actually done in Step 5:
| Step 5 action | Notion status |
|---|---|
publishNow: true (live post) | "Published" |
isDraft: true (saved as draft) | "Draft Ready" |
Use Notion MCP to update the row's Status cell.
For each published post, use mcp__notion__API-update-a-block with the row's _row_id (saved from Step 1). Rebuild the full cells array with the Status cell (index 9) set to the new value:
block_id: <_row_id>