[ 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.
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
/monthly-report ACME Agency— generate the full report for last month/monthly-report ACME Agency --month 2— full report, 2 months ago/monthly-report ACME Agency --since 2026-02-19 --until 2026-03-31— custom date range (e.g. "since campaign launch"). Hero badge shows "19. veljače – 31. ožujka 2026" (HR) or "19. februar – 31. mart 2026" (BS). File is saved as<id>.pdf(separate from the standard monthly file so they don't overwrite each other)./monthly-report— will prompt for client name
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 says | Add 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:
/monthly-report ACME Agency, skip search terms→node ... --client "ACME Agency" --no-search-terms/monthly-report ACME Agency, no Google→node ... --client "ACME Agency" --no-google/monthly-report ACME Agency, skip Meta and search terms→node ... --client "ACME Agency" --no-meta --no-search-terms
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
- Identifies the client from the argument, looks up in
clients.json - Auto-discovers any missing config (Meta account, Google Ads ID, Drive folder, Slack channel, GHL location)
- Fetches data from all available sources in parallel
- Generates a branded multi-page PDF report
- Uploads PDF to Google Drive and posts summary to Slack
- Reports any missing data sources in the Slack message
Report sections (in order)
| Section | Source | Notes |
|---|---|---|
| Hero + period badge | clients.json reference_assets.logo | Client logo + "Izvjestaj o rezultatima" |
| GHL lead overview | GHL Opportunities API | Big 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 charts | Meta breakdowns + Google gender_view/device | Gender + device bar charts, both platforms side by side |
| Top 3 Meta ads | Meta Insights API (ad level) + creatives | Real 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 terms | Google Ads search_term_view | Table with impressions, clicks, cost, conversions — sorted by cost. Auto-skipped when <id>: true or --no-search-terms |
| Landing page screenshots | Playwright + Firecrawl | Up to 3 pages, viewport screenshot + content analysis. URLs filtered through landing_page_excludes |
| Drive creatives | Google Drive API | Up to 9 images from Design → Year → Month folder, with Drive links. No file names shown (clients name files inconsistently) |
| GHL CRM pipeline | GHL Pipelines + Opportunities API | Stage 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 API | Detected work categories + always-on items (no counts, no raw task names) + closing sentence emphasizing continuous work |
| Conclusion | Auto-generated | Croatian summary of leads, spend, CPL |
| Next steps | Auto-generated or custom | Up 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.
| Flag | Type | Effect | |
|---|---|---|---|
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_account | string OR array | Meta 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> | string | Optional 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_primary | string | Optional. 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> | string | Counts 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> | boolean | Splits 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> | boolean | Skips the entire Google search terms section. Use when terms are noisy, sensitive, or off-brand. | |
landing_page_excludes | string[] | 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_id | string | GHL Location ID — wires the GHL lead overview, pipeline section, and ±10% variance check. | |
ghl_api_key_env | string | Either 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.
| Flag | Effect |
|---|---|
--no-meta | Skip Meta KPI section, top ads, and Meta demographics |
--no-google | Skip Google KPI section, search terms, and Google demographics |
--no-search-terms | Skip Google search terms section only (Google KPIs + demographics still show) |
--no-ghl | Skip GHL lead overview and GHL pipeline section |
--no-clickup | Skip "Što smo napravili" section |
--month N | Report on N months ago (default: 1 = last calendar month) |
--since YYYY-MM-DD --until YYYY-MM-DD | Custom 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-only | Generate HTML pACME Agencyw only — no PDF, no Drive upload, no Slack |
--next-steps "..." | Custom newline-separated next steps (overrides auto-generated) |
--all | Run 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.
- Meta lead counting uses precedence, not sum.
LEAD_ACTION_PRIORITYin [meta_ads.mjs](../../ACME Agency/scripts/lib/meta_ads.mjs) returns the first matching action type (onsite_conversion.lead_grouped→offsite_conversion.fb_pixel_lead→leadgen_grouped→lead). Never sums across types — Meta'sleadfield is already the aggregate.extractLeads()enforces this. - GHL is the source of truth for lead counts. The pipeline cross-checks Meta+Google totals against GHL; deviations >±10% in either direction print
[WARN] Lead variance ...to the console. See <id>.md. - Drive uploads upsert by filename. [
uploadOrUpdateFile()](../../ACME Agency/scripts/lib/google_drive.mjs) finds existing reports by name and PATCHes the content — same fileId, same shareable link across regenerations. Slack links don't go dead when you re-run. - GHL pipeline filter is
createdAt. Only leads CREATED in the reporting month are included, regardless of their current pipeline stage. SeegetOpportunities()in [ghl.mjs](../../ACME Agency/scripts/lib/ghl.mjs). - Zero-spend platforms are skipped automatically. If Meta has 0 spend AND 0 impressions in the period,
buildMetaSection()returns null and the entire Meta section (KPIs, demographics, top ads) is omitted from the report. Same for Google. The Pregled rezultata header drops the corresponding "Meta potrošnja" / "Google potrošnja" card too. Console logsMeta: no spend in period, skipping section. - Meta ad names are anonymized in client-facing output.
buildTopAdsFromMeta()replaces raw ad names withOglas 1 / Oglas 2 / Oglas 3by spend rank before they hit the PDF or Slack. Paradox uses internal naming conventions like "DON'T LAUNCH", "test v2", "STOP IT - broken tracking" that should never reach a client — the thumbnail + metrics carry all the information the client needs. Raw names are still preserved upstream (fetchAdCreatives→c.adName) if any other tool needs them. Rank is stable: "Oglas 1" is always the highest-spend ad after re-sorting matched creatives against the spend-sortedtoplist. - Meta auto-splits when lead-gen and purchase campaigns coexist. [
getCampaignObjectives()](../../ACME Agency/scripts/lib/meta_ads.mjs) fetches each campaign'sobjectivefield and classifies intolead(OUTCOME_LEADS),purchase(OUTCOME_SALES), orother. When BOTH lead and purchase buckets have spend in the same period, [buildMetaSection()](../../ACME Agency/scripts/lib/report_data.mjs) returns abyObjectiveobject and the template renders TWO Meta sub-sections — "Meta oglašavanje — Lead generation" with leads + CPL and "Meta oglašavanje — Prodaja / kupovine" with purchases + cost per purchase. Pregled rezultata adds a "Cijena po kupovini" card and excludes purchase spend from headline CPL math (lead-only spend / GHL leads). Single-objective clients see no change. Purchase counts use [extractPurchases()](../../ACME Agency/scripts/lib/meta_ads.mjs) with<id>precedence (same dedupe logic as leads). - Drive creatives use a recursive month walker. [
<id>()](../../ACME Agency/scripts/lib/report_data.mjs) walks the Design folder tree (max depth 5) looking for any subfolder whose name matches the target month (HR / BS / EN / numeric). Year folders are filtered to the target year; everything else (Country, Language, "Kreative" wrappers) is descended into. Handles all known client folder shapes:Design → Month,Design → Year → Month,Design → Country → Year → Month → Date subfolder. For odd cases, set<id>to point at a specific folder ID directly.
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
- Read
ACME Agency/clients/clients.json - Look up the client name (case-insensitive, fuzzy match on
full_nameor key) - 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:
HTML saved:— confirms HTML generatedPDF saved:— confirms PDF renderedDrive:— confirms upload with linkSlack: posted to— confirms Slack message- Any
errorlines for failed sections
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:
- The user asks for a deeper read on a specific anomaly the PDF surfaced
- A multi-month trend question that the script's per-month report can't answer
- A "what changed?" analysis comparing this month to last
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:
- Meta Ads —
object_story_spec.link_data.linkfrom ad creatives - Google Ads —
ad_group_ad.ad.final_urlsfrom 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:
- 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.
- Always-on items that are present every month regardless of ClickUp activity — daily monitoring, performance analysis, budget rebalancing, regular communication, minor tweaks/tests.
- 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:
- Meta active → new creative angles, A/B testing hooks, scaling winners, retargeting
- Google active → search terms ACME Agencyw, negative keyword expansion, RSA testing, extensions
- Landing pages found → A/B testing hero/CTA, Hotjar/Clarity session analysis
- GHL active → follow-up sequence optimization, won-lead pattern analysis
- General → ad fatigue refresh, strategy for next period
Demographics
- Meta:
fetchInsights()withbreakdowns: 'gender'andbreakdowns: 'device_platform' - Google:
gender_viewGAQL query +getDeviceBreakdown()(aggregated across campaigns) - Rendered as CSS horizontal bar charts, green for Meta, blue for Google
Optimization
- Meta ad-level data is fetched once and shared between landing page URL extraction and top ads section (no duplicate API calls)
- Google access token is fetched once and reused for all Google API calls
- All extended data (demographics, top ads, search terms, ClickUp) runs in parallel with
.catch()— failures are non-fatal
Key files
| File | Purpose |
|---|---|
ACME Agency/scripts/monthly_report.mjs | Main orchestrator — CLI, PDF render, Drive upload, Slack |
ACME Agency/scripts/lib/report_data.mjs | Data fetching — Meta, Google, GHL, ClickUp, Drive creatives, landing pages |
ACME Agency/scripts/lib/report_template.mjs | HTML template — all sections, CSS, bar charts, multi-page layout |
ACME Agency/scripts/lib/pdf_render.mjs | Playwright PDF renderer with branded header/footer |
ACME Agency/scripts/lib/meta_ads.mjs | Meta Ads API — insights, creatives, demographics |
ACME Agency/scripts/lib/google_ads.mjs | Google Ads API — campaigns, search terms, gender/device breakdown |
ACME Agency/scripts/lib/ghl.mjs | GHL Opportunities API (leads) |
ACME Agency/scripts/lib/clickup.mjs | ClickUp API — space lists, closed tasks |
ACME Agency/scripts/lib/google_drive.mjs | Drive API — folder navigation, upload, image download |
ACME Agency/scripts/lib/firecrawl.mjs | Landing page content scraping |
ACME Agency/clients/clients.json | Client registry |
Important rules
- Default is the FULL report. Meta + Google + search terms + GHL + ClickUp + everything. Suppress sections only when the user explicitly asks via
--no-*flags or whenclients.jsonflags are set. - Default period is always last calendar month (1st to last day) unless
--month Nis specified - Never ask the user for IDs — always look them up from
clients.jsonor auto-discover - Update clients.json with any discovered IDs so future runs skip discovery
- Proceed without missing sources — the report gracefully skips sections when data is unavailable (template short-circuits on null)
- Slack message in Bosnian — no emojis, per SLACK_REPORT_STANDARD.md
- Always share the Drive link immediately after upload. Drive uses
uploadOrUpdateFile()so the link is stable across regenerations. - GHL is source of truth for lead counts. Console will print
[WARN] Lead variance N% ...if Meta+Google deviates from GHL by more than ±10% — investigate tracking before sending the report to the client. - Landing page fallback — if Meta URLs are all platform redirects (fb.me, etc.), Google Ads final URLs are used
- Drive subfolder recursion — if month folder has subfolders (Oglasi 1, etc.), check inside them for images
- ClickUp name normalization — strip spaces before matching (handles "ACME Agency" vs "ACME Agency")
- Next steps — auto-generated (up to 6) based on active platforms unless custom text provided via
--next-steps