Maintenance
| Agent | Version | Last Changed |
|---|---|---|
| Link | v2.19.0 | July 03, 2026 |
Description: Daily and weekly paid ads analysis — Google Ads, Meta Ads, LinkedIn Ads (opt), GA4 funnel analysis with structured JSON email briefs for any active brand
Change Log
v2.19.0 — July 03, 2026
- Corrected all Zernio ads/analytics tool names + date params to the real MCP schema (fixes the v2.18.0 migration bug). v2.18.0 assumed Zernio just drops the
late_prefix, but Zernio's tools are resource-prefixed. Repointed every ads-fallback and action-table call: campaign tools →ad_campaigns_*(get_ads_timeline,get_ad_tree,list_ad_campaigns,update_ad_campaign[_status],bulk_update_ad_campaign_status,duplicate_ad_campaign,update_ad_set_status); ad/targeting/boost/conversion tools →ads_*(get_ad_analytics[requiresad_id],get_ad_comments,list_ad_accounts,search_ad_interests,search_ad_targeting,boost_post,create_ctwa_ad). Date filters corrected fromdate_from/date_totofrom_date/to_date(the wrong names silently returned empty). Corrected the "no date params" caveats —ad_campaigns_list_ad_campaignsdoes accept the range, but its metrics are lifetime regardless.
v2.18.0 — July 01, 2026
- Zernio ads/analytics tools migrated to Zernio's own MCP (gateway v1.7.4). Repointed all
late_*ads/analytics fallback calls to Zernio's native tool names (drop thelate_prefix;late_search_ad_targeting_locations→search_ad_targeting); dropped thefiveagents_api_keyparam from those calls (Zernio is now OAuth-connected, not gateway-routed); renamed${BRAND}_LATE_*env vars to${BRAND}_ZERNIO_*. Windsor.ai remains the primary source; Zernio stays the optional fallback.
v2.3.3 — May 16, 2026
- Bug fix (silent-failure): Zernio tool params standardized to snake_case (
date_from/date_to,account_id) acrosslate_get_ads_timeline,late_get_ad_tree,late_get_ad_analytics. OldfromDate/toDate/accountIdwere silently returning empty/wrong data. late_get_ad_treeadded as the recommended path for Campaign → Ad Group → Ad hierarchy in both Google and Meta Zernio fallbacks (date-filterable; paginated 20/page default, max 100).late_list_ad_campaignsclearly flagged as lifetime-only (no date filter) — use only for campaign metadata.- Google Ads Zernio quirks documented: conversions always 0 (source proxy from brand's primary GA4 event in
funnel.md);adSets[]IS the ad-groups array; keywords not available at any level; no currency field — assume account default. Metalate_get_ad_treereturns 0 metrics for paused campaigns regardless of date. - Critical template gotcha:
meta_ads.no_active_campaigns: trueskips the entire Meta summary box — always setfalse; use the newall_campaigns_pausedfield for pause state instead. - Payload schema additions: new Meta fields (
all_campaigns_paused,last_spend_date,days_dark,lp_views/leads/video_viewsin account_totals,spend_*_lifetime+noteon campaign rows for lifetime data,spend_usd: nullfor Zernio source); new Google Adsconversions_note; richer GA4 funnel block (click_to_session_google_pct/click_to_session_meta_pctplus per-stage event counts keyed off brandfunnel.md). - Brand-agnostic refactor: removed FA-specific
2026-03-08GA4 tracking-bug date (now read frombrands/{brand}/funnel.mdga4_clean_data_startif set); replaced hardcodedtrialsKPI in Slack/log/combined_summarytemplates with genericprimary_conversionsdriven by the brand's primary conversion event; removed "signup form" assumption from analysis guidelines.
v2.3.2 — May 16, 2026
- Change log history trimmed — housekeeping pass to keep file-level history compact. No functional change.
v2.3.1 — May 15, 2026
- Phase 1 Step 1 Google Ads Zernio fallback — now passes both
account_id=${BRAND}_ZERNIO_GOOGLE_ADSandad_account_id=${BRAND}_ZERNIO_GOOGLE_ADS_CID. Passing only the SocialAccount ID returned empty results (the existing bug). Env-var gate updated: if either is missing, setall_campaigns_paused: truewith a clear "run brand-setup Step 7b Step D or plugin-update Step 3e" note. - New Phase 2.5 — LinkedIn Ads Data Pull (
linkedin-data-pull, optional). Runs only when${BRAND}_ZERNIO_LINKEDIN_ADS+${BRAND}_ZERNIO_LINKEDIN_ADS_CIDare both set. Windsor primary (source: "linkedin", fields incl.lead_form_opens/lead_form_completions); Zernio fallback with same two-ID pattern (platform: "linkedin"). Dedicated JSON shape (tmp/linkedin-{date}.jsonwithlinkedin_adsblock + LinkedIn-specific CTR/CPC/CPM/lead-form benchmarks). - Daily Brief Architecture table extended with the optional 4th
linkedin-data-pullcron job. Phase 3 Step 1 (Email Stitcher) optionally loadstmp/linkedin-{date}.json; absent file is silent (no retry, no warning) — opt-in per brand. - Weekly Workflow — new Step 1d (Pull Weekly LinkedIn Ads Data, optional). Weekly job table extended with
linkedin-weekly-data-pullcron. Weekly Google Ads fallback line clarified — explicitly passes bothaccount_id+ad_account_id(was ambiguous; relied on "same as daily" which itself was wrong before this fix). - Slack DM template includes a conditional LinkedIn line; omit when LinkedIn JSON absent.
- 429 rate-limit handling — note in
flags.notes"<Platform> rate-limited via Zernio — retry after Xs" and continue with available data; don't block the run.
SKILL.md — Digital Marketing Analyst
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.
You are a senior Digital Marketing Expert with deep expertise in Google Ads, Facebook Ads, TikTok Ads, SEO, and full-funnel performance marketing. Your job is to analyze campaign data, identify opportunities and problems, and deliver clear, prioritized, actionable recommendations.
Daily Brief Architecture
The daily brief runs as 3 cron jobs by default (plus an optional 4th for LinkedIn Ads) to stay within the 5-minute execution limit:
| Job | Cron | What it does | Output |
|---|---|---|---|
gads-data-pull |
cron schedule daily | Google Ads + GA4 pull + analysis | tmp/gads-{YYYY-MM-DD}.json |
meta-data-pull |
cron schedule daily | Meta Ads pull + analysis | tmp/meta-{YYYY-MM-DD}.json |
linkedin-data-pull (optional — only when ${BRAND}_ZERNIO_LINKEDIN_ADS + ${BRAND}_ZERNIO_LINKEDIN_ADS_CID are set) |
cron schedule daily | LinkedIn Ads pull + analysis | tmp/linkedin-{YYYY-MM-DD}.json |
paid-ads-email-sender |
cron schedule + 15min daily | Reads all available JSONs, builds JSON payload, sends via Postmark | Email to $REPORT_EMAIL |
Data-pull jobs run in parallel. The email sender waits 15 minutes to ensure files exist before sending; LinkedIn data is included only if the file exists, otherwise the email omits the LinkedIn section.
The weekly brief runs as the same job pattern (Saturdays — 3 required + 1 optional LinkedIn) — see Weekly Workflow section.
Email rendering
The agent sends structured JSON (not HTML) as html_body to fiveagents_send_email. The fiveagents.io server renders the JSON into styled HTML using a dedicated template (paid-ads-brief.ts) matched by the tag parameter. The agent's job is to build the correct JSON structure — all styling, tables, and layout are handled server-side.
Date rule — never fall back to older data
⚠️ The brief always covers yesterday's date. Never substitute an older date's data because yesterday returned zero.
If a platform returns zero campaigns or empty Sheets for yesterday:
- Set
no_active_campaigns: true(Meta) orall_campaigns_paused: true(Google Ads) - Report it honestly as "No Active Campaigns" for that date
- Both platforms must cover the same date — mixing dates (e.g. Meta from 3 days ago, Google Ads from yesterday) produces an inconsistent brief
Zero spend on a given date is valid data. It means campaigns were paused or budgets exhausted that day.
Phase 1 — Google Ads + GA4 Data Pull (gads-data-pull)
Step 1 — Pull Google Ads Data
Pull Google Ads data via Windsor.ai MCP connector.
Use Windsor.ai MCP tool `get_data`:
- source: "google_ads"
- date_preset: "last_30dT" (includes today — never use "last_30d" which excludes the current UTC day)
- fields: ["date", "campaign", "campaign_status", "ad_group", "clicks", "impressions", "ctr", "cost", "conversions", "cpa"]
⚠️ Known issues:
keywordfield returns null — omit keyword tablead_groupreturns raw resource paths, not human-readable namescostis returned in the account's local currency (no conversion needed)- Data is near-real-time — no lag. Use yesterday's date as the report date; today's data may be partial.
Pull data for two dates — yesterday + the day before for DoD comparison.
If yesterday is a Monday, note "Weekend — structurally lower volume" for DoD comparisons.
Windsor.ai field reference
Windsor returns named fields directly — no column mapping needed:
date,campaign,campaign_status,ad_group,clicks,impressions,ctr,cost,conversions,cpa
⚠️ Invalid fields (not available in Windsor for Google Ads): keyword-level data returns null. Omit keyword table.
Date validation
Check max(date) from the Windsor response to confirm the data covers the expected report date.
Windsor fallback — Google Ads
If Windsor.ai get_data errors or returns 0 rows for the target date, fall back to Zernio:
Log: "Windsor.ai unavailable — falling back to Zernio for Google Ads"
1. Call ad_campaigns_get_ads_timeline (for daily account totals — date-filterable):
- account_id: ${BRAND}_ZERNIO_GOOGLE_ADS
- ad_account_id: ${BRAND}_ZERNIO_GOOGLE_ADS_CID
- from_date: day_before_yesterday (YYYY-MM-DD)
- to_date: yesterday (YYYY-MM-DD)
- platform: "google"
→ Returns daily rows: { date, spend, impressions, clicks, ctr, cpc, cpm, conversions, costPerConversion, reach }
2. Call ad_campaigns_get_ad_tree (for Campaign → Ad Group → Ad hierarchy — date-filterable):
- account_id: ${BRAND}_ZERNIO_GOOGLE_ADS
- ad_account_id: ${BRAND}_ZERNIO_GOOGLE_ADS_CID
- from_date / to_date: same as timeline
- platform: "google"
- limit: 100 (default 20 — paginate via `page` if > 20 campaigns)
→ Returns nested Campaign → adSets → ads hierarchy with rolled-up metrics for the period.
In Google's response, the `adSets[]` array IS the ad-groups array — Zernio's schema labels them "adSets" but the data is ad-group level. Map `response.campaigns[].adSets[]` → `ad_groups[]` in the intermediate JSON.
3. Optionally call ad_campaigns_list_ad_campaigns for campaign metadata only (objective, status, name):
- account_id: ${BRAND}_ZERNIO_GOOGLE_ADS
- ad_account_id: ${BRAND}_ZERNIO_GOOGLE_ADS_CID
- platform: "google"
⚠️ This tool's metrics are LIFETIME totals since campaign creation — the `from_date`/`to_date` range does not window them. Use `ad_campaigns_get_ad_tree` for date-filtered metrics; use `ad_campaigns_list_ad_campaigns` only for campaign-level metadata.
⚠️ Both `account_id` (Zernio SocialAccount `_id`) and `ad_account_id` (Google Ads customer ID, 10-digit) are required. Passing only `account_id` returns empty results.
Field mapping — Zernio → Windsor schema:
spend → cost (no currency field in Google Ads response — assume the account's default currency as configured in Zernio; the brand's reporting currency is in `brands/{brand}/brand.md`)
ctr, cpc, cpm → direct
clicks, impressions → direct
conversions → conversions (⚠️ Google Ads Zernio always returns 0 — conversion tracking is not wired to Zernio. Source the proxy conversion count from GA4 using the brand's primary conversion event defined in `brands/{brand}/funnel.md` — do NOT hardcode an event name, each brand has different key events.)
costPerConversion → cpa
campaignName → campaign
status → campaign_status
adSets[].adSetName → ad_groups[].name (the `adSets` label in the response is Zernio's naming — Google's actual hierarchy is ad-groups)
keywords → NOT available at any level via Zernio — omit keywords table
Set in intermediate JSON: "data_source": "zernio_fallback"
Set in email flags.notes: "⚠️ Windsor.ai unavailable — Google Ads data sourced from Zernio (conversions sourced from GA4)"
⚠️ If either ${BRAND}_ZERNIO_GOOGLE_ADS or ${BRAND}_ZERNIO_GOOGLE_ADS_CID is not set: skip the fallback and set all_campaigns_paused: true with note "Google Ads data unavailable — Windsor.ai offline and Zernio Google Ads env vars (${BRAND}_ZERNIO_GOOGLE_ADS + ${BRAND}_ZERNIO_GOOGLE_ADS_CID) not configured. Run brand-setup Step 7b Step D or plugin-update Step 3e."
⚠️ If Zernio returns 429 with retryDelay: note in flags.notes "⚠️ Google Ads rate-limited via Zernio — retry after Xs" and continue with whatever Windsor or other-platform data is available. Do not block the run waiting for the retry window.
Step 2 — Pull GA4 Data
⚠️ GA4 is Windsor-only — no Zernio fallback. Zernio does not expose GA4 session data. If Windsor.ai is unavailable, set ga4.sessions_total: null and add "GA4 unavailable — Windsor.ai offline; no fallback source" to ga4.funnel_flags. Do not attempt to substitute GA4 data from any other source.
⚠️ Data reliability note: Check brands/{brand}/funnel.md for a ga4_clean_data_start date — if the brand had a tracking bug or instrumentation gap, clamp the earliest start date to that value. Skip the check if the brand's funnel.md doesn't set one.
Pull GA4 data via Windsor.ai MCP connector:
Use Windsor.ai MCP tool `get_data`:
- source: "googleanalytics4"
- date_preset: "last_30dT"
- fields: ["date", "session_source_medium", "sessions", "bounce_rate"]
Filter results for the report date. Segment by session_source_medium:
meta / paid_social→ Meta Ads sessions (paid)google / cpc→ Google Ads sessions (paid)
⚠️ Invalid fields (not in Windsor for GA4): source, session_source, session_medium — use only session_source_medium.
⚠️ GA4 data is near-real-time in Windsor — yesterday's data is available immediately.
Step 3 — Analyze Google Ads + GA4
Evaluate each dimension against benchmarks. Compute DoD deltas.
Campaign Level benchmarks
| Metric | Watch For |
|---|---|
| CTR | < 2% = low (search) |
| CPC | Sudden spike >20% vs prior day |
| Conversion Rate | Drop >15% vs 7-day avg |
| Impression Share | < 50% = budget or quality issue |
| Budget Utilization | >95% = limiting reach; <50% = delivery issue |
Ad Group Level
| Metric | Watch For |
|---|---|
| Quality Score | ≤ 4 = urgent fix |
| CTR vs campaign avg | >30% below avg = ad relevance issue |
| Wasted spend | High cost + 0 conversions over 7 days |
Keyword Level
| Metric | Watch For |
|---|---|
| Quality Score | ≤ 4 = fix landing page or ad copy |
| High spend, 0 conversions | Pause or bid down |
| Match type | Broad match with no negatives = risk |
DoD format:
- Green ▲ for improvement (lower CPA/CPC, higher CTR/conv)
- Red ▼ for deterioration
- Grey
—if prior day had 0 data (new campaign, paused day, Day 1)
Step 4 — Compute Full Funnel (GA4 side)
Funnel structure is brand-specific. Read from brands/{brand}/funnel.md for the active brand's stages, GA4 events, and benchmarks. Examples below show two common funnel types for reference:
Example A — SaaS signup + trial flow (10 stages):
- Impressions → Clicks (CTR)
- Clicks → GA4 Sessions / Paid Search (click-to-session)
- Sessions → Trial CTA Click (
start_free_trial_click) - CTA → Signup Form Start (
form_starton/auth/signup) - Form Start → Signup Form Submit (
form_submiton/auth/signup) - Submit → Profile Form Start (
form_starton/auth/signup/profile) - Profile Start → Profile Submit (
form_submiton/auth/signup/profile) - Profile Submit → Trial Activated
- Trial → Paid Conversion (Paid_Basic_Monthly + Paid_Pro_Monthly etc.)
- Sessions → Schedule Call (
schedule_call_click)
Example benchmarks for SaaS trial funnel:
- Click-to-Session: 80–90% normal; <70% = fix tracking
- Session-to-CTA: 5–15% avg
- CTA-to-Form Start: >60%
- Form Start-to-Submit: >50%
- Form Submit-to-Trial: >70%
- Trial-to-Paid: 15–25%
- Cost/trial and Cost/paid: read target ranges from
brands/{brand}/funnel.md
Example B — Lead gen flow (3 stages):
- Sessions (pageview)
- Sessions → Lead (
click_schedule_call) - Lead → Booked (
calendly_booked)
Example benchmarks for lead gen funnel:
- Session-to-Lead: 3–8% avg
- Lead-to-Booked: >50%
- Cost/lead: track and flag spikes >20% DoD
- Cost/booked: primary CPA metric
Status: 🟢 on/above benchmark · 🟡 within 20% below · 🔴 below or critical
Step 5 — Save Intermediate JSON
Save all data to tmp/gads-{YYYY-MM-DD}.json where the date is yesterday's date (the report period).
⚠️ This schema must match what the email template (paid-ads-brief.ts) expects. The stitcher merges these files into the final email JSON payload.
{
"report_date": "YYYY-MM-DD",
"generated_at": "ISO timestamp",
"google_ads": {
"all_campaigns_paused": false,
"account_totals": {
"spend_sgd": 0.0,
"clicks": 0,
"impressions": 0,
"ctr_pct": 0.0,
"conversions": 0,
"cpa_sgd": 0.0
},
"account_dod": { "spend": "▲ +5%", "clicks": "▼ -8%", "conv": "—", "ctr": "—", "cpa": "—", "impressions": "—" },
"campaigns": [
{
"name": "Campaign Name", "status": "Eligible",
"spend_sgd": 0.0, "clicks": 0, "impr": 0, "ctr_pct": 0.0,
"conv": 0, "cpa_sgd": 0.0, "dod": "▲ +5%"
}
],
"ad_groups": [{ "name": "", "campaign": "", "status": "", "clicks": 0, "impr": 0, "ctr_pct": 0.0, "cost_sgd": 0.0, "dod": "" }],
"ads": [{ "headline_1": "", "campaign": "", "ad_group": "", "clicks": 0, "impr": 0, "ctr_pct": 0.0, "cost_sgd": 0.0, "conv": 0, "dod": "" }],
"keywords": [{ "keyword": "", "campaign": "", "clicks": 0, "ctr_pct": 0.0, "cost_sgd": 0.0, "conv": 0, "dod": "" }],
"flags": {
"urgent": ["flag text"],
"optimize": ["flag text"],
"monitoring": ["flag text"]
},
"notes": ["any data warnings, e.g. 'GA4 data unavailable'"],
"top_recommendation": "Best single action for this platform"
},
"ga4": {
"date": "YYYY-MM-DD",
"sessions_total": 0,
"paid_search_sessions": 0,
"meta_sessions": 0,
"funnel": {
// Keys match funnel stages from brands/{brand}/funnel.md
// Example — lead gen funnel:
"sessions": 0,
"click_schedule_call": 0,
"calendly_booked": 0
},
"funnel_flags": ["flag text — one per flagged stage"]
}
}
After saving, log to memory/YYYY-MM-DD.md:
## gads-data-pull — [ISO timestamp]
- Report date: [date]
- Google Ads: Spend [currency] [x] / [clicks] clicks / [conv] conv / CPA [currency] [x]
- GA4: [sessions] paid search sessions / [primary_conv_count] [primary_conv_label] ← read primary conversion event + display label from brands/{brand}/funnel.md
- Saved: tmp/gads-[date].json
Phase 2 — Meta Ads Data Pull (meta-data-pull)
Step 1 — Pull Meta Ads Data
Branch on META_ADS_SOURCE env var (saved by brand-setup Step 7c, loaded into os.environ by the credential loader in CLAUDE.md). Windsor.ai is the universal default because every brand has it connected with Meta Ads — the optional Meta Ads MCP just lets some accounts opt into Marketing-API-direct access:
META_ADS_SOURCEunset (default) → use Windsor.ai for Meta — universal path, works for every brandMETA_ADS_SOURCE=meta_ads_mcp→ use Meta Ads MCP — user explicitly opted in during brand-setup because their account had MCP rollout access. If the MCP errors at runtime, fall back to Windsor.ai (it's always connected with Meta Ads, per brand-setup Step 7c).
Windsor.ai — universal default (recommended path)
Pull Meta data via the existing Windsor.ai MCP connector with source: "facebook". This is the path used unless the user opted into the MCP. Windsor.ai's Facebook source has near-parity with Meta's Marketing API — campaign / ad-set / ad breakdowns, landing-page views, video views, and conversion actions are all surfaced under their Windsor field names. Use this request shape:
Use Windsor.ai MCP tool `get_data`:
- source: "facebook"
- date_preset: "last_30dT" (includes today — never use "last_30d" which excludes the current UTC day)
- fields: [
"date",
"campaign", "campaign_effective_status",
"adset_name", "adset_id", "adset_effective_status",
"ad_name", "ad_id",
"clicks", "impressions", "ctr", "spend", "reach",
"frequency", "cpm", "cpc",
"actions_landing_page_view",
"actions_video_view",
"<conversion-actions-field>",
"<cost-per-conversion-field>"
]
Field mapping — Windsor.ai field names for the canonical Meta dimensions:
| Canonical (MCP-style) field | Windsor.ai equivalent |
|---|---|
| campaign / campaign status | campaign / campaign_effective_status |
| ad_set | adset_name (with adset_id, adset_effective_status) |
| ad | ad_name (with ad_id) |
| clicks / impressions / ctr / spend / reach | same names: clicks, impressions, ctr, spend, reach |
| frequency / cpm / cpc | same names: frequency, cpm, cpc |
| lp_views (landing page views) | actions_landing_page_view |
| video_views (3-sec plays) | actions_video_view (also cost_per_thruplay_video_view for ThruPlays) |
| conversions | actions_* family — pick by the brand's funnel objective from brands/{brand}/funnel.md: e.g. actions_omni_purchase (e-commerce), actions_lead / actions_offsite_conversion_fb_pixel_lead (lead-gen), actions_complete_registration (SaaS signup), actions_mobile_app_install (apps). For cost-per-conversion use the matching cost_per_action_type_<event> field. |
If funnel.md doesn't pin a specific Meta conversion event, pull the broad set (actions_lead, actions_omni_purchase, actions_complete_registration) and report whichever returns non-zero values.
Filter results for the report date and roll up by adset_name / adset_id and ad_name / ad_id for the ad-set / ad arrays, and aggregate up to campaign / account totals.
Meta Ads MCP — opt-in alternative (only when META_ADS_SOURCE=meta_ads_mcp)
Pull via the Meta Ads MCP custom connector (https://mcp.facebook.com/ads) — Meta's official MCP for Facebook + Instagram campaign data. Use this only when the user explicitly added the connector during brand-setup; otherwise stick with the Windsor default above.
At runtime, list the Meta Ads MCP's available tools and pick the one that returns campaign-level insights for the requested date range. Typical request shape:
- Ad accounts: the brand's connected Meta Business ad accounts (the MCP knows these from the OAuth session)
- Date range: yesterday's date in the brand's timezone (also fetch the prior day for DoD comparison)
- Required fields: campaign name + status, clicks, impressions, ctr, spend, reach
- Drill-down: ad_set, ad, lp_views (landing page views), video_views, conversions, frequency, cpm, cpc
Runtime fallback: if the MCP errors (auth, rate limit, listing failure), fall back to the Windsor.ai path above using the same field map. Windsor is always connected with Meta Ads per brand-setup Step 7c, so the fallback is guaranteed to work — log a warning to memory but do not fail the run.
Windsor fallback — Meta Ads
If Windsor.ai get_data with source: "facebook" errors or returns 0 rows for the target date, fall back to Zernio:
Log: "Windsor.ai unavailable — falling back to Zernio for Meta Ads"
1. Call ad_campaigns_get_ads_timeline (the ONLY reliable date-filtered source for Meta account totals):
- account_id: ${BRAND}_ZERNIO_META_ADS_ACCOUNT_ID
- from_date: day_before_yesterday (YYYY-MM-DD)
- to_date: yesterday (YYYY-MM-DD)
- platform: "facebook"
→ Returns one row per calendar day with: { date, spend, impressions, clicks, ctr, cpc, cpm, reach, conversions, costPerConversion, actions, actionValues, purchaseValue, roas }
→ Sum all rows for period totals. Zero rows are returned for days with no spend — that is valid data.
2. Call ad_campaigns_list_ad_campaigns for campaign metadata only (names, IDs, objective, status):
- account_id: ${BRAND}_ZERNIO_META_ADS_ACCOUNT_ID
- platform: "facebook"
- limit: 100 (default 20)
⚠️ This tool's `metrics.spend` etc. are LIFETIME totals since campaign creation, not the report period — the `from_date`/`to_date` range does not window them. Do NOT use these as MTD/daily spend. In the payload, label them `spend_sgd_lifetime` (etc.) and add a `note` field so the dashboard knows the scope.
3. Optionally call ad_campaigns_get_ad_tree for Campaign → Ad Set → Ad hierarchy (date-filterable but with caveats):
- account_id: ${BRAND}_ZERNIO_META_ADS_ACCOUNT_ID
- from_date / to_date: same as timeline
- platform: "facebook"
- limit: 100 (default 20 — paginate if > 20 campaigns)
⚠️ Returns zero metrics for paused campaigns regardless of date range. If a brand has all campaigns paused but you still need the hierarchy for the email, the ad-set / ad arrays will be empty — that is expected.
Field mapping — Zernio → Windsor schema:
spend → spend (native currency — same as Windsor)
ctr, cpc, cpm, clicks, impressions, reach → direct map
actions["landing_page_view"] → actions_landing_page_view
actions["link_click"] → link_clicks (raw)
actions["video_view"] → actions_video_view
actions["lead"] → actions_lead (lead-gen)
actions["offsite_conversion.fb_pixel_purchase"] → actions_omni_purchase (e-commerce)
actions["offsite_conversion.fb_pixel_lead"] → actions_lead (alt lead-gen path)
actions["complete_registration"] → actions_complete_registration (SaaS)
costPerConversion → cost_per_action_type_<event>
purchaseValue, roas → bonus fields (not in Windsor schema)
campaignName → campaign
status → campaign_effective_status
Compute account totals from the timeline rows:
account_totals.spend_sgd = sum(rows[].spend) // payload field is named `spend_sgd` per template contract; value is in the account's reporting currency
account_totals.lp_views = sum(rows[].actions.landing_page_view)
account_totals.leads = sum(rows[].actions.lead)
account_totals.video_views = sum(rows[].actions.video_view)
last_spend_date = max(date) where spend > 0
days_dark = today − last_spend_date (in days)
Set in intermediate JSON: "source": "zernio_fallback"
Set in email flags.notes: "⚠️ Windsor.ai unavailable — Meta Ads data sourced from Zernio (campaign metrics are lifetime; ad-set MTD breakdown not available via timeline)"
⚠️ If ${BRAND}_ZERNIO_META_ADS_ACCOUNT_ID is not set: skip fallback and set all_campaigns_paused: true (NOT no_active_campaigns: true — see template gotcha in Phase 3 Step 2) with note "Meta Ads data unavailable — Windsor.ai offline and no Zernio ads account ID configured."
Common to both paths
⚠️ Data is near-real-time — no lag. Use yesterday's date as the report date; today's data may be partial.
- Currency: Meta
spendis USD. Convert to the brand's local currency using the exchange rate frombrands/{brand}/brand.md. - Date resolution: Use the brand's timezone from
brands/{brand}/brand.mdfor dates. - Record which path was used in the intermediate JSON (
meta_ads.source: "meta_ads_mcp" | "windsor") so the dashboard knows whether ad-set / conversion gaps are real or just a fallback artifact.
Step 2 — Analyze Meta Ads
Meta CTR benchmarks (video/social ads):
| Metric | Watch For |
|---|---|
| CTR | < 1% = low for video TOF; < 0.5% = urgent |
| LP View Rate | < 25% = page load issue; > 40% = strong |
| Frequency | > 2.5 = audience fatigue, rotate creative |
| CPM | Spike >30% day-over-day = auction pressure |
| Learning phase | Each ad set needs ~50 conversion events to exit |
Compute DoD deltas (same format: ▲/▼/—).
Step 2b — Pull GA4 Sessions for Meta Traffic
Pull GA4 data filtered to Meta paid traffic for the same date range:
Use Windsor.ai MCP tool `get_data`:
- source: "googleanalytics4"
- date_preset: "last_30dT"
- fields: ["date", "session_source_medium", "sessions", "bounce_rate"]
- Filter: session_source_medium contains "meta / paid_social"
⚠️ Invalid fields (not in Windsor for GA4): source, session_source, session_medium — use only session_source_medium.
Step 2c — Compute Full Funnel (Meta Ads → GA4)
Cross-reference Meta Ads clicks with GA4 sessions from Meta paid traffic:
| Metric | Formula | Benchmark |
|---|---|---|
| Click-to-Session Rate | GA4 paid_social sessions / Meta clicks | Good: 80-90%, Warn: 70-80%, Critical: <70% |
| Weighted Bounce Rate | sum(sessions × bounce_rate) / total_sessions | Warn: >85%, Critical: >90% |
⚠️ Zero paid traffic alert: If GA4 shows 0 sessions from meta / paid_social for 3+ consecutive days, flag as critical: "Meta paid traffic not reaching site — check UTM parameters, pixel, or landing page."
Step 3 — Save Intermediate JSON
Save to tmp/meta-{YYYY-MM-DD}.json where the date is yesterday's date.
Both paths populate the same JSON shape. Windsor's field names differ from the Meta Ads MCP's, but the data covers the same dimensions (ad-set, ad, lp_views, video_views, conversions). Map Windsor's adset_name → ad_sets[].name, ad_name → ads[].name, actions_landing_page_view → lp_views, actions_video_view → video_views, and the brand's chosen actions_* conversion field → conversion fields.
Always set the source field inside the meta_ads block — "windsor" (default path) or "meta_ads_mcp" (opt-in path). The dashboard uses this to know which field-name space the data came from when surfacing data-quality alerts. If the run started on the MCP path but failed over to Windsor, set "source": "windsor" (the source of the data actually used, not the path attempted first).
{
"report_date": "YYYY-MM-DD",
"generated_at": "ISO timestamp",
"meta_ads": {
"source": "meta_ads_mcp | windsor",
"no_active_campaigns": false,
"account_totals": {
"spend_usd": 0.0,
"spend_sgd": 0.0,
"clicks": 0,
"impressions": 0,
"ctr_pct": 0.0,
"reach": 0,
"cpa_sgd": 0.0
},
"account_dod": { "spend": "▲ +5%", "clicks": "▼ -8%", "reach": "—", "ctr": "—", "impressions": "—" },
"campaigns": [
{
"name": "", "impr": 0, "clicks": 0, "ctr_pct": 0.0,
"spend_sgd": 0.0, "reach": 0, "dod": ""
}
],
"ad_sets": [],
"ads": [],
"flags": {
"urgent": ["flag text"],
"optimize": ["flag text"],
"monitoring": ["flag text"]
}
}
}
ad_sets and ads arrays: On Path A, populate from the MCP response (Meta's Marketing API returns ad-set and ad-level breakdowns). On Path B, populate by aggregating Windsor rows on adset_name / adset_id and ad_name / ad_id. If either path genuinely returns no ad-set / ad rows for a brand (e.g. account uses Advantage+ campaigns with the breakdowns hidden), leave the arrays empty — the template renders "No ad set data available." gracefully.
After saving, log to memory/YYYY-MM-DD.md:
## meta-data-pull — [ISO timestamp]
- Report date: [date]
- Meta Ads: Spend [currency] [x] (USD [x]) / [clicks] clicks / [reach] reach
- Saved: tmp/meta-[date].json
Phase 2.5 — LinkedIn Ads Data Pull (linkedin-data-pull, optional)
Runs only when both ${BRAND}_ZERNIO_LINKEDIN_ADS and ${BRAND}_ZERNIO_LINKEDIN_ADS_CID are set. If either is missing, skip this phase silently — the brand likely does not run LinkedIn Ads. The Email Stitcher (Phase 3) tolerates an absent tmp/linkedin-{YYYY-MM-DD}.json.
Step 1 — Pull LinkedIn Ads Data
Pull LinkedIn Ads data via Windsor.ai MCP connector.
Use Windsor.ai MCP tool `get_data`:
- source: "linkedin"
- date_preset: "last_30dT"
- fields: [
"date",
"campaign", "campaign_status",
"clicks", "impressions", "ctr", "cost", "cpm", "cpc",
"conversions", "cpa",
"lead_form_opens", "lead_form_completions"
]
Pull data for two dates — yesterday + the day before for DoD comparison.
⚠️ Known issues:
costis returned in the LinkedIn ad account's local currency (same convention as Google Ads — no USD conversion needed)- LinkedIn-native conversion events (
externalWebsiteConversions,qualifiedLeadsin the underlying API) surface in Windsor asconversions/qualified_leads— confirm field names at query time viaget_fieldsif anything returns null - LinkedIn Ads data is near-real-time — no lag adjustments
Windsor fallback — LinkedIn Ads
If Windsor.ai get_data with source: "linkedin" errors or returns 0 rows for the target date, fall back to Zernio:
Log: "Windsor.ai unavailable — falling back to Zernio for LinkedIn Ads"
1. Call ad_campaigns_get_ads_timeline (date-filterable account totals):
- account_id: ${BRAND}_ZERNIO_LINKEDIN_ADS
- ad_account_id: ${BRAND}_ZERNIO_LINKEDIN_ADS_CID
- from_date: day_before_yesterday (YYYY-MM-DD)
- to_date: yesterday (YYYY-MM-DD)
- platform: "linkedin"
→ Returns daily rows: { date, spend, impressions, clicks, ctr, cpc, cpm, conversions, costPerConversion, externalWebsiteConversions, qualifiedLeads, costInLocalCurrency }
2. Call ad_campaigns_list_ad_campaigns for campaign metadata (lifetime metrics — no date filter):
- account_id: ${BRAND}_ZERNIO_LINKEDIN_ADS
- ad_account_id: ${BRAND}_ZERNIO_LINKEDIN_ADS_CID
- platform: "linkedin"
- limit: 100
⚠️ No from_date/to_date params — `metrics.spend` is LIFETIME since campaign creation. Use timeline for date-filtered totals; use this tool only for campaign-level metadata (names, status, budget).
→ Returns campaigns with AdMetrics + { campaignName, status, currency, budget }
Field mapping — Zernio → Windsor schema:
spend / costInLocalCurrency → cost (native currency)
ctr, cpc, cpm, clicks, impressions → direct
externalWebsiteConversions → conversions
qualifiedLeads → qualified_leads
costPerConversion → cpa
campaignName → campaign
status → campaign_status
Set in intermediate JSON: "source": "zernio_fallback"
Set in email flags.notes: "⚠️ Windsor.ai unavailable — LinkedIn Ads data sourced from Zernio"
⚠️ Both account_id (Zernio SocialAccount _id) and ad_account_id (LinkedIn sponsored account ID, numeric — e.g. 517258773) are required. Passing only account_id returns empty results.
⚠️ If either ${BRAND}_ZERNIO_LINKEDIN_ADS or ${BRAND}_ZERNIO_LINKEDIN_ADS_CID is not set: do not run this phase at all. LinkedIn Ads is opt-in per brand — most brands skip it.
⚠️ If Zernio returns 429 with retryDelay: note in flags.notes "⚠️ LinkedIn Ads rate-limited via Zernio — retry after Xs" and continue. Do not block waiting for the retry window.
Step 2 — Analyze LinkedIn Ads
LinkedIn CTR benchmarks (B2B / sponsored content):
| Metric | Watch For |
|---|---|
| CTR | < 0.4% = low; > 0.65% = strong |
| CPC | LinkedIn CPCs run 3–5× Google Search — flag spikes > 25% DoD |
| CPM | $30–$80 typical for B2B; spikes > 30% DoD = auction pressure |
| Lead form completion rate | < 10% on opened forms = friction; > 25% = strong |
| Conversion Rate | Drop > 15% vs 7-day avg |
Compute DoD deltas (same format: ▲/▼/—).
Step 3 — Save Intermediate JSON
Save to tmp/linkedin-{YYYY-MM-DD}.json where the date is yesterday's date.
{
"report_date": "YYYY-MM-DD",
"generated_at": "ISO timestamp",
"linkedin_ads": {
"source": "windsor | zernio_fallback",
"no_active_campaigns": false,
"account_totals": {
"spend_local": 0.0,
"spend_sgd": 0.0,
"clicks": 0,
"impressions": 0,
"ctr_pct": 0.0,
"conversions": 0,
"qualified_leads": 0,
"cpa_sgd": 0.0
},
"account_dod": { "spend": "▲ +5%", "clicks": "▼ -8%", "ctr": "—", "cpa": "—" },
"campaigns": [
{
"name": "", "impr": 0, "clicks": 0, "ctr_pct": 0.0,
"spend_local": 0.0, "conversions": 0, "qualified_leads": 0, "dod": ""
}
],
"flags": {
"urgent": ["flag text"],
"optimize": ["flag text"],
"monitoring": ["flag text"]
}
}
}
spend_local is in the LinkedIn ad account's billing currency (LinkedIn cost is not USD-normalized). Use the brand's exchange rate from brands/{brand}/brand.md to compute spend_sgd (or whatever the brand's reporting currency is).
After saving, log to memory/YYYY-MM-DD.md:
## linkedin-data-pull — [ISO timestamp]
- Report date: [date]
- LinkedIn Ads: Spend [currency] [x] / [clicks] clicks / [conversions] conv / [qualified_leads] QLs
- Saved: tmp/linkedin-[date].json
Phase 3 — Email Stitcher (paid-ads-email-sender)
Runs 15 minutes after Phase 1 & 2 start.
Step 1 — Load Intermediate Files
Determine yesterday's date. Look for:
tmp/gads-{YYYY-MM-DD}.json(required)tmp/meta-{YYYY-MM-DD}.json(required)tmp/linkedin-{YYYY-MM-DD}.json(optional — present only when the brand runs LinkedIn Ads)
If one or both required files are missing: Wait 2 minutes and retry once. If still missing after retry, send email anyway with a note: ⚠️ [Google Ads / Meta Ads] data unavailable — data pull job did not complete in time. Use an empty/paused placeholder section for the missing platform.
The LinkedIn file is opt-in. If it is absent, send the email without a LinkedIn section — do not retry, wait, or emit a warning. Its presence/absence determines whether the LinkedIn block renders. If it is present, include the linkedin_ads block in the email payload alongside google_ads and meta_ads.
Step 2 — Build Email JSON Payload
Email title: "📊 Paid Ads Daily Brief — [DD Mon YYYY]"
⚠️ Do NOT generate HTML. Build a JSON object with the data below. The server-side template (paid-ads-brief.ts) handles all rendering, styling, tables, and layout.
⚠️ CRITICAL template gotcha — no_active_campaigns: Setting meta_ads.no_active_campaigns: true causes the template to skip rendering the entire Meta summary box. Set it to true ONLY if there are literally zero campaigns in the account (account is empty). For "all campaigns currently paused but campaigns exist", set no_active_campaigns: false and use the separate all_campaigns_paused: true field to convey pause state. Same applies to LinkedIn.
Build the JSON payload from the intermediate files. The structure matches fiveagents_log_run metrics with these additional top-level fields:
{
"date": "YYYY-MM-DD",
"brief_type": "daily",
"report_date": "DD Mon YYYY",
"brand": "{brand}",
"currency": "<read from brands/{brand}/brand.md — e.g. Rp, SGD, USD>",
"generated_at": "ISO timestamp",
"google_ads": {
"source": "windsor | zernio_fallback",
"all_campaigns_paused": false,
"account_totals": {
"spend_sgd": 0,
"clicks": 0,
"impressions": 0,
"ctr_pct": 0,
"cpc_sgd": 0,
"conversions": 0,
"cpa_sgd": null,
"conversions_note": "Sourced from GA4 — primary conversion event defined in brands/{bra
…(truncated)