PORTAL / LIBRARY / monthly-report

[ REPORTING & OPS ]

/monthly-report

When the user invokes the skill with extra instructions, translate the request into one or more `--no-*` flags before running the script.

Download the skill file (.md)

Placeholders like ACME Agency, <id> and you@example.com mark values that are per-agency — your install fills them with YOUR clients and accounts. If a section references a helper script you don't have yet, it ships with that workflow's install.

Monthly Performance Report Generator

Triggers

Natural-language → CLI flag mapping

When the user invokes the skill with extra instructions, translate the request into one or more --no-* flags before running the script. Default is always the full report — only suppress what the user explicitly asks to skip.

User saysAdd to command
"skip search terms", "no Google keywords", "don't show search terms", "ne pokazuj search termove"--no-search-terms
"no Facebook", "skip Meta", "bez Mete", "don't include Facebook"--no-meta
"no Google", "skip Google", "bez Googlea"--no-google
"skip GHL", "no CRM", "bez GHL-a"--no-ghl
"skip ClickUp", "no work summary", "bez sto smo napravili"--no-clickup

Examples:

If the user only says the client name with no qualifiers, run the full report — Meta + Google + search terms + GHL + ClickUp + everything.

What this skill does

  1. Identifies the client from the argument, looks up in clients.json
  2. Auto-discovers any missing config (Meta account, Google Ads ID, Drive folder, Slack channel, GHL location)
  3. Fetches data from all available sources in parallel
  4. Generates a branded multi-page PDF report
  5. Uploads PDF to Google Drive and posts summary to Slack
  6. Reports any missing data sources in the Slack message

Report sections (in order)

SectionSourceNotes
Hero + period badgeclients.json reference_assets.logoClient logo + "Izvjestaj o rezultatima"
GHL lead overviewGHL Opportunities APIBig lead number (GHL is source of truth). Top section is intentionally lead-count-only — no combined spend, no combined CPL, no per-platform spend cards. Per-platform spend and CPL are shown in the Meta and Google sections below.
Meta KPIs (5 cards)Meta Insights API (campaign level)Spend, clicks, impressions, leads, Cijena po leadu (no CTR). Auto-splits into TWO sub-sections (Lead generation + Prodaja/kupovine) when the client ran both OUTCOME_LEADS and OUTCOME_SALES campaigns in the same period — purchase sub-section shows Kupovine + Cijena po kupovini instead of Leadovi + CPL
Google KPIs (5 cards)Google Ads API (campaigns)Spend, clicks, impressions, conversions OR "Kvalifikovani leadovi" (when <id> is set), Cijena po leadu
Demographics chartsMeta breakdowns + Google gender_view/deviceGender + device bar charts, both platforms side by side
Top 3 Meta adsMeta Insights API (ad level) + creativesReal creative thumbnail (uses image_url, not thumbnail_url), anonymized as "Oglas 1 / Oglas 2 / Oglas 3" by spend rank (raw ad names like "DON'T LAUNCH" / "test v2" never leak to clients), spend, clicks, leads, CPL — ranked by spend
Top 10 Google search termsGoogle Ads search_term_viewTable with impressions, clicks, cost, conversions — sorted by cost. Auto-skipped when <id>: true or --no-search-terms
Landing page screenshotsPlaywright + FirecrawlUp to 3 pages, viewport screenshot + content analysis. URLs filtered through landing_page_excludes
Drive creativesGoogle Drive APIUp to 9 images from Design → Year → Month folder, with Drive links. No file names shown (clients name files inconsistently)
GHL CRM pipelineGHL Pipelines + Opportunities APIStage breakdown for leads created in the period, top stages as KPI cards + full list, won count + win rate. Filter is createdAt, NOT lastStageChangeAt
Work summary (ClickUp)ClickUp APIDetected work categories + always-on items (no counts, no raw task names) + closing sentence emphasizing continuous work
ConclusionAuto-generatedCroatian summary of leads, spend, CPL
Next stepsAuto-generated or customUp to 6 smart defaults based on active platforms, or custom via --next-steps

Per-client config flags (clients.json)

Persistent overrides set in each client's entry in [clients.json](../../ACME Agency/clients/clients.json). All optional. Use these for client-specific quirks that should apply to every future run.

FlagTypeEffect
language"hr" or "bs"Language for the month label in the hero badge. Default "hr" shows Croatian months (Siječanj, Veljača, Ožujak...). Set to "bs" for Bosnian/Serbian clients to show Januar, Februar, Mart, etc.
ad_accountstring OR arrayMeta ad account ID. Pass an array to combine multiple accounts into one report (e.g. during a transition between two accounts). Totals, top ads, and demographics aggregate across all accounts. Single string still works for the common case.
<id>stringOptional override for the Drive folder where creatives live. Set this when the auto-discovery (search top-level for "Design"/"Dizajn"/"Kreativ" folder) doesn't find the right one — points the recursive month walker at a specific folder ID instead.
ad_account_primarystringOptional. When ad_account is an array, this is the long-term canonical account — informational only, not used by report logic but kept so other scripts know which one to write to.
<id>stringCounts only this Google Ads conversion action by name (e.g. "Qualifed Lead"). Avoids double-counting when both "Submit lead form" and "Qualifed Lead" are tracked. The Google KPI label switches to "Kvalifikovani leadovi".
<id>booleanSplits Google Ads conversions into "real leads" vs "Lokalne akcije" (Google Maps "Get directions" clicks). When true, fetches segments.<id> breakdown and groups any action matching `/local action\direction/i` into the directions bucket. The headline "Leadovi" KPI counts only real leads (form / call / qualified / GHL events); a separate "Lokalne akcije" KPI card shows directions count, and an explanatory note appears below the Google KPI strip. Use for clinics / brick-and-mortar businesses (e.g. ACME Agency) where Google Maps directions clicks are tracked as conversions but aren't real form leads — without this, a single campaign can show 239 "conversions" that are actually 3 leads + 236 directions clicks.
<id>booleanSkips the entire Google search terms section. Use when terms are noisy, sensitive, or off-brand.
landing_page_excludesstring[]Substring matches; URLs containing any are filtered from the landing-page screenshots. Use when Google Ads final URLs include marketing site pages that aren't real funnel landings.
ghl_location_idstringGHL Location ID — wires the GHL lead overview, pipeline section, and ±10% variance check.
ghl_api_key_envstringEither an env var name (e.g. "<id>", preferred — keeps tokens out of git) OR a literal pit-... token (legacy from registry sheet sync). [resolveGhlConfig()](../../ACME Agency/scripts/lib/ghl.mjs) handles both.

Runtime overrides (CLI flags)

Per-run flags. Additive on top of clients.json defaults — if a flag is set in clients.json AND on the CLI, the CLI just reinforces the skip. If only clients.json says skip, it still skips.

FlagEffect
--no-metaSkip Meta KPI section, top ads, and Meta demographics
--no-googleSkip Google KPI section, search terms, and Google demographics
--no-search-termsSkip Google search terms section only (Google KPIs + demographics still show)
--no-ghlSkip GHL lead overview and GHL pipeline section
--no-clickupSkip "Što smo napravili" section
--month NReport on N months ago (default: 1 = last calendar month)
--since YYYY-MM-DD --until YYYY-MM-DDCustom date range — overrides --month. Must be used together. File is saved as <id>.pdf, Slack title becomes "Izvjestaj" (not "Mjesecni izvjestaj"), and the hero badge shows a range label in the client's language.
--html-onlyGenerate HTML pACME Agencyw only — no PDF, no Drive upload, no Slack
--next-steps "..."Custom newline-separated next steps (overrides auto-generated)
--allRun for every eligible client

Quality controls (already in code)

These are guardrails baked into the pipeline — listed here so future runs trust them and don't reimplement.

How to run

# Full execution — PDF + Drive + Slack (default = full report)
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency"

# Different month (2 months ago)
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --month 2

# Custom next steps
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --next-steps "Testirati nove video oglase\nSkalirati kampanju za implantologiju"

# HTML pACME Agencyw only — no PDF, no Drive, no Slack
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --html-only

# Suppress sections at runtime (one-off, no clients.json edit needed)
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --no-search-terms
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --no-google
node ACME Agency/scripts/monthly_report.mjs --client "ACME Agency" --no-meta --no-search-terms

# All eligible clients
node ACME Agency/scripts/monthly_report.mjs --all

Step-by-step

Phase 1 — Identify client

  1. Read ACME Agency/clients/clients.json
  2. Look up the client name (case-insensitive, fuzzy match on full_name or key)
  3. If not found, list available clients and ask the user

Phase 1.5 — Auto-cascade from registry sheet (built into the script)

The script now runs ensureClientHydrated() automatically before every report. If any of these fields are missing in the client's clients.json entry, it calls syncClientToJson() to backfill from the team-maintained registry sheet and re-reads the entry:

ad_account, google_ads_id, ghl_location_id, ghl_api_key_env, drive_folder_id, slack_channel, clickup_list_id

Why: the team maintains the sheet as the canonical source of truth. Whenever someone adds a new ad account ID, GHL location, or ClickUp list to the sheet, the next monthly report run picks it up and writes it into clients.json — no manual sync needed. Console logs [hydrate] lines so you can see it happen.

This is part of the 3-step client lookup cascade documented in [ACME Agency/CLAUDE.md](../../ACME Agency/CLAUDE.md): clients.json → registry sheet → API discovery (last resort).

Phase 2 — Auto-discover missing config (rare fallback)

If after the sheet cascade some fields are STILL missing (because the sheet doesn't have them either), discover from the actual APIs and update both the sheet and clients.json. Most of the time you can skip this phase entirely — the cascade handles it.

Step 2.1: Meta ad_account If null:

node -e "
import { metaGet } from './ACME Agency/scripts/lib/meta_ads.mjs';
const r = await metaGet('<id>/owned_ad_accounts', { fields: 'name,account_id', limit: 200 });
for (const a of r.data) console.log(a.account_id, a.name);
"

Find the account matching the client name (case-insensitive). Update clients.json with "ad_account": "act_XXXXX". If no match → proceed without Meta.

Step 2.2: Google google_ads_id If null:

node -e "
import { getAccessToken, searchQuery } from './ACME Agency/scripts/lib/google_ads.mjs';
const token = await getAccessToken();
const r = await searchQuery('<id>', 'SELECT customer_client.id, customer_client.descriptive_name FROM customer_client WHERE customer_client.manager = false', token);
for (const c of r) console.log(c.customerClient.id, c.customerClient.descriptiveName);
"

Find matching account. Update clients.json. If no match → proceed without Google.

Step 2.3: Google Drive drive_folder_id If null:

gws drive files list --query "name contains '<ClientName>' and mimeType='application/vnd.google-apps.folder'" --fields "files(id,name)"

Look for folder under Klijenti/. Update clients.json. If no match → skip Drive upload.

Step 2.4: Slack slack_channel If null: search Slack channels for client name match. Update clients.json. If no match → skip Slack.

Step 2.5: GHL ghl_location_id If ghl_api_key_env exists but ghl_location_id is missing: ask the user for the location ID (visible in GHL URL: app.your-domain.example/v2/location/<ID>/dashboard). You cannot discover it from the API token alone. If missing → proceed without GHL, note in Slack message.

Phase 3 — Run the script

node ACME Agency/scripts/monthly_report.mjs --client "<ClientName>"

The script handles everything: data fetching (parallel), HTML generation, PDF rendering, Drive upload, and Slack posting. Takes 1-3 minutes.

Phase 4 — Verify and report

After the script completes, check console output for:

Report to user: PDF path, Drive link, Slack channel, and any missing data sources.

Phase 5 — (Optional) Deeper agent-based analysis

monthly-report is intentionally a monolithic autonomous pipeline — the script handles its own data fetching, analysis, PDF rendering, Drive upload, and Slack post. Unlike /meta-ads-analyze and /google-ads-optimize, monthly-report does NOT route through analyst + slack-reporter by default, because the script already produces a finished deliverable.

When to invoke the analyst on top of monthly-report's output:

How: Read the PDF (or the script's intermediate JSON if exposed via --save-data), then invoke the analyst subagent with the data. Hand the analyst's findings to slack-reporter for posting as a thread reply on the original monthly-report message.

This is opt-in. The default flow stays as Phase 1-4 above.

Diagnostician hook

If a data source fails twice with the same error during the script run (Meta OAuthException, Google Ads <id>, GHL 401, ClickUp 403, Drive 404 on a known folder), invoke the diagnostician subagent before retrying. These errors almost always have UI/permission/token-rotation root causes that loops cannot fix.

Data pipeline details

Landing page discovery

URLs are collected from two sources:

  1. Meta Adsobject_story_spec.link_data.link from ad creatives
  2. Google Adsad_group_ad.ad.final_urls from all ads that ran in the period

Platform redirects are auto-filtered: fb.me, facebook.com, instagram.com, google.com, l.facebook.com, goo.gl. These are not real landing pages.

Fallback logic: If Meta only has lead forms (URLs are all fb.me), Google Ads final URLs are used as the primary source. Most lead gen clients have real landing pages in Google Ads even when Meta uses native forms.

Up to 3 unique URLs are screenshotted (Playwright, 1280x900 viewport) and analyzed (Firecrawl for title + description).

Drive creative discovery

Navigates: client drive_folder_id → finds folder matching "design"/"dizajn"/"kreativ" → year subfolder (e.g. "2026") → month subfolder (matches Croatian/English/Bosnian/numeric names like "03.Mart", "Ozujak", "March").

Subfolder recursion: If the month folder contains subfolders instead of images (e.g. "Oglasi 1", "Oglasi 2"), it recurses one level to find images inside.

Downloads up to 9 images, resizes to 400px, converts to base64, and includes Drive view links. File names are intentionally not displayed in the report — clients name files inconsistently (1.png, 2.png, banner_v3.jpg) and surfacing them is noise.

ClickUp work areas summary

Finds the client's ClickUp list in Klijenti space (6748685) by normalized name matching (strips spaces: "ACME Agency" matches "ACME Agency Oftamologija"). Pulls closed tasks for the reporting period — but does NOT show counts or raw task names to the client.

Instead, the report shows:

  1. Detected work categories (only those with at least one matching task that month) — campaigns optimization, creative preparation, copy, landing pages, Google Ads, Meta Ads, CRM/automation, client communication.
  2. Always-on items that are present every month regardless of ClickUp activity — daily monitoring, performance analysis, budget rebalancing, regular communication, minor tweaks/tests.
  3. Closing sentence emphasizing continuous behind-the-scenes work.

The result always looks like 8-10 substantive items even on light months. Clients never see exact task counts (which makes the team look slower than they are on quiet months) or raw ClickUp task names (which leak internal jargon).

Next steps

If --next-steps "text" is provided, uses that custom text (newline-separated).

If not provided, auto-generates up to 6 smart steps based on active platforms:

Demographics

Optimization

Key files

FilePurpose
ACME Agency/scripts/monthly_report.mjsMain orchestrator — CLI, PDF render, Drive upload, Slack
ACME Agency/scripts/lib/report_data.mjsData fetching — Meta, Google, GHL, ClickUp, Drive creatives, landing pages
ACME Agency/scripts/lib/report_template.mjsHTML template — all sections, CSS, bar charts, multi-page layout
ACME Agency/scripts/lib/pdf_render.mjsPlaywright PDF renderer with branded header/footer
ACME Agency/scripts/lib/meta_ads.mjsMeta Ads API — insights, creatives, demographics
ACME Agency/scripts/lib/google_ads.mjsGoogle Ads API — campaigns, search terms, gender/device breakdown
ACME Agency/scripts/lib/ghl.mjsGHL Opportunities API (leads)
ACME Agency/scripts/lib/clickup.mjsClickUp API — space lists, closed tasks
ACME Agency/scripts/lib/google_drive.mjsDrive API — folder navigation, upload, image download
ACME Agency/scripts/lib/firecrawl.mjsLanding page content scraping
ACME Agency/clients/clients.jsonClient registry

Important rules