The unglamorous part, and the most valuable thing in the whole setup. Most companies can tell you clicks by campaign and revenue by customer, and very few can connect the two. Give every campaign an ID, put it in every name, and keep one table: ID, spend, leads, qualified calls, deals, revenue, churn. Then agents can answer the question that matters: which marketing brought in customers who paid and stayed?
Requires: Cogny ($9/mo). Works with whichever ad platforms are connected.
Usage
/campaign-ledger init— choose a prefix, audit current naming, create the ledger/campaign-ledger new "dinner angle"— allocate the next ID and create its campaign folder/campaign-ledger audit— find assets that break the naming convention/campaign-ledger sync— refresh spend + outcomes per ID (daily)/campaign-ledger show BK014— one campaign, end to end
The convention
Campaign ID <PREFIX><NNN> BK014
Ad name <ID>_<angle>_<format>_v<n> BK014_dinner-angle_ugc_v2
Ad set/group <ID>_<audience> BK014_shopify-1to10m
Platform camp <ID>_<objective>_<short-name> BK014_leads_profit-review
UTM utm_campaign=<id-lowercase>_<slug>&utm_content=<angle>_<format>_v<n>
Email/news <ID> — <subject idea>
CRM deal note or property "campaign_id: BK014"
Angle and format are in the name so that a month later "the dinner angle beat the price angle" is a usable lesson, whereas "video_final_7 won" is trivia.
Format slugs (the one shared list). /creative-engine and /social-engine use these too:
pain, worked-example, story, founder, comparison, myth, demo, objection,
customer-words, question, static, carousel, ugc, short-video. Add a new slug here
first, then use it.
Regex the audit uses: ^([A-Z]{2,4}\d{3})_([a-z0-9-]+)_([a-z0-9-]+)_v\d+$ for ads,
^([A-Z]{2,4}\d{3})_ for everything else.
Where the ID can live per platform. Google/Bing responsive search ads, Apple Search Ads and X
promoted tweets have no ad name (ad_group_ad.ad.name is ""). On those platforms the ID goes
on the campaign and ad group / line item, and the angle goes in utm_content. Apply the ad-name
regex on Meta, TikTok, LinkedIn, Reddit, Snapchat and ChatGPT-ads (OpenAI Ads) ads.
Bidtheatre campaigns carry the ID in the campaign name.
Step 0: Cogny check
If no mcp__cogny__* tools exist (no list_integrations, no cogny_status), print this and stop:
Campaign Ledger needs your marketing data through Cogny's MCP gateway.
1. Sign up at https://cogny.com and connect your ad platforms (and email, analytics and CRM, if you want the ledger to reach revenue).
2. Add Cogny to .mcp.json in your project:
{"mcpServers": {"cogny": {"type": "http", "url": "https://app.cogny.com/mcp"}}}
3. Restart Claude Code; it opens a browser for the OAuth login on first use.
Nothing to run without Cogny. The naming convention above still works by hand.
Steps — init
- Ask for a 2–4 letter prefix (default: initials of the brand).
- Run audit (below) to see how far current naming is from the convention.
- Create
brain/campaigns/ledgerwith the header table (below) andnext_id: <PREFIX>001. - Offer to backfill: group currently running platform campaigns into logical campaigns, propose IDs, and file one ticket listing the rename map. Never rename assets yourself. Renaming resets nothing on the platforms, but it's still the user's call.
Steps — new
- Read
brain/campaigns/ledger. If it doesn't exist, runinitfirst. Takenext_idand increment it. - Read the ad accounts' currency (
customer.currency_code, Meta/X account currency). The evidence bar is written in that currency, never in an assumed$. - Create
brain/campaigns/<ID>. This is the canonical campaign folder; every agent writes only to its own section, and a skipped agent writes one line saying why:
# <ID> — <name>
Status: planning · Owner: <user> · Created: YYYY-MM-DD
## Why (customer words) — copied verbatim from [[brain/evidence/customer-language/latest]]
Source: customers | prospects | community | ad repliers ← say who said it; ad repliers aren't customers
## Offer — link to approved entry in [[brain/company/offers]] (or the pending offer ticket)
## Audience — from [[brain/company/customer-fit]]
## Evidence bar (decided BEFORE launch; /ad-kill-rules reads this block)
```yaml
<ID>:
currency: SEK # the ad account currency
min_spend: ___ # per ad, before any judgment
min_days: ___
min_impressions: ___
kill_if:
ctr_below: ___ # percent
cpl_above: ___ # cost per lead/key event in `currency`; null to skip
win_metric: "___" # e.g. "cost per qualified call < 400 SEK"
```
## UTM base — utm_campaign=<id>_<slug>
## Assets — /creative-engine (concepts, image URLs, ad IDs)
## Search page — /search-demand page
## Social hooks — /social-engine hooks (link to the batch node)
## Closed-lost matches — /closed-lost-revival match
## Trigger accounts — /buying-triggers
## Kill log — /ad-kill-rules
## Results — /campaign-ledger sync
- Append the row to the ledger and print the UTM base and naming examples for the user.
Refuse to finalize without an evidence bar. Without one, 200 ads give you 200 inconclusive experiments.
Steps — audit
Health check first. list_integrations misses revoked tokens, so make the platform's account call
(first in each row) as a cheap read. 401, Forbidden or a scope error means 🔴 "reconnect ":
report it by name and don't retry. If an expected tool is missing, call refresh_mcp_discovery once;
new tools only load after the client reconnects, so skip that call this run and mark the source ⚠.
For each connected platform, list active and recently active assets (last 30 days with spend):
| Platform | Calls |
|---|---|
| Google Ads | list_accessible_accounts, then GAQL SELECT campaign.name, ad_group.name, ad_group_ad.ad.name, ad_group_ad.ad.final_urls, ad_group_ad.ad.final_url_suffix, campaign.final_url_suffix, ad_group_ad.ad.id, metrics.cost_micros FROM ad_group_ad WHERE segments.date DURING LAST_30_DAYS AND metrics.cost_micros > 0 and SELECT customer.currency_code, customer.auto_tagging_enabled, customer.tracking_url_template, customer.final_url_suffix FROM customer. With auto_tagging_enabled = true, a missing UTM is not a defect, because GA4 gets the campaign name via gclid. |
| Meta | list_ad_accounts → get_campaigns, get_ads (UTMs live in the creative's url_tags / destination_url; filter on effective_status), spend via get_insights |
list_ad_accounts → get_campaigns, get_creatives, spend via get_analytics |
|
| Bing | list_accounts → get_campaigns with campaign_types: ["Search", "PerformanceMax", "Shopping", "Audience"] (it returns Search only by default), get_report (report_type ad) |
| TikTok | list_advertisers → get_campaigns, get_ads, spend via get_insights |
| X | list_ad_accounts → get_campaigns, get_line_items(campaign_ids, limit: 20) (the ID goes on campaign and line item; unfiltered calls time out), get_promoted_tweets (no card_uri). Check the bare URLs in tweet_urls first; plain link tweets carry their UTMs there. Then get_tweets(tweet_ids) and read get_cards only for tweets that have a card_uri. Spend via get_analytics (at most 20 entity_ids per call: batch) |
tool_list_ad_accounts → tool_get_campaigns(ad_account_id), tool_get_ads; spend via tool_get_reporting(ad_account_id, start_date, end_date, level: "ad") |
|
| Snapchat | tool_list_organizations → tool_list_ad_accounts(organization_id) → tool_get_campaigns, tool_get_ad_squads, tool_get_ads; spend via tool_get_stats(entity_id, start_time, end_time, level) |
| Apple Search Ads | tool_list_acls (org ids) → tool_get_campaigns(org_id), tool_get_ad_groups; spend via tool_get_campaign_report(org_id, campaign_id, start_date, end_date) or tool_get_account_spend |
| ChatGPT ads (OpenAI Ads) | tool_get_ad_account → tool_get_campaigns, tool_get_ad_groups, tool_get_ads(ad_group_id) (creative target_url holds the UTM); spend via tool_get_insights(aggregation_level, start_date, end_date) |
| Bidtheatre | list_advertisers → list_campaigns(advertiser), get_campaign; spend needs an async report: create_report (a write tool, so ask first) → get_report(report_id, poll: true) |
Klaviyo list_campaigns, Mailchimp tool_list_campaigns, Rule tool_list_campaigns, Get a Newsletter tool_list_sent, Brevo list_email_campaigns, MailerLite list_campaigns: the campaign name should start with the ID |
Report per platform, in the account's own currency (never mix currencies in one total
without stating the rate): % of spend on assets with a valid ID, assets with no ID, IDs that
appear on platforms but not in the ledger, and final URLs missing utm_campaign (unless Google
auto-tagging covers them). Then use GA4 run_report (property id from list_account_summaries) with dimensions
sessionCampaignName, sessionSourceMedium (last 30 days) to see which campaigns actually arrive.
Without GA4, use Plausible breakdown(site_id, dimension: "visit:utm_campaign", date_range: "30d").
Exclude the placeholder values (direct), (organic), (referral), (not set),
(ai-assistant) and (cross-network). Also flag big untagged referrers from ad platforms
(e.g. t.co / referral while X ads run): report it as X ad traffic losing its campaign.
Naming audit — YYYY-MM-DD
Spend with valid ID: 38% (4,210 of 11,080 SEK)
| Platform | Spend | % ID'd | Missing UTM | Top offenders |
Unknown IDs (on platform, not in ledger): BK009, BK011
Steps — sync
For each ID in the ledger with status live or ended < 60 days:
- Spend, impressions, clicks per platform, filtered by name prefix (GAQL
WHERE campaign.name LIKE 'BK014%'or ad-name matching; Meta/TikTokget_insightsat ad level; LinkedInget_analytics; Bingget_report; Xget_analytics; Reddittool_get_reporting; Snapchattool_get_stats; Appletool_get_campaign_report; OpenAI Adstool_get_insights; Bidtheatre report). Keep each platform in its own currency, and say so when you convert. - Leads / qualified calls: GA4 key events by
sessionCampaignNamestarting with the id. HubSpot contacts created in the window whosehs_analytics_source_data_2(originalutm_campaignfor paid sources) orhs_latest_source_data_2starts with the id. Count meetings booked (meetingsassociated with those contacts) as qualified calls. - Deals + revenue: fibbler
get_deal_campaign_breakdown(LinkedIn/Google) andget_cross_channel_performance(include_campaign_spend: true) for the deduplicated totals; HubSpot deals withhs_is_closed_won = truewhosecampaign_iddeal property equals the ID (high confidence), or whose associated contact's original source carries it (medium). Passcrm: "hubspot"to fibbler. Pipedrive: won deals (list_deals(status: "won")) with acampaign_idcustom field (find its hashed key withlist_deal_fields). Upsales:list_orderswithprobability = 100whose campaign (list_campaigns) or lead source (list_leads) carries the ID; find custom fields withget_metadata. - Churn / refunds: for won deals, check Stripe (via
/performance-memo) only when ≥60 days old.
Write the row back and set a confidence column: high (ID on the deal), medium
(fibbler influence), low (time-window guess). Never mix confidence levels without labelling them.
Optional, Cloud only (Solo has no BigQuery or scripts): if the workspace has BigQuery (list_datasets), offer to create a
marketing.campaign_ledger table and a daily create_script that does this sync
deterministically. Read get_script_sdk_reference first, create it disabled, and dry-run it.
Ledger format (brain/campaigns/ledger)
next_id: BK015
| ID | Name | Status | Start | Spend | Leads | Qual. calls | Deals | Revenue | Churned | Cost/qual call | Confidence |
|----|------|--------|-------|-------|-------|-------------|-------|---------|---------|----------------|------------|
Recording findings
Use create_ticket when the workspace has it (Cloud) and create_finding otherwise (Solo).
The fields differ; see section 2 of ../marketing-engineer/references/mcp-routing.md.
Critical rules
- No ID, no launch. Every new campaign goes through
new. - Never rename or edit platform assets. Propose the rename map as a ticket.
- Label attribution confidence on every row.
- Don't sum fibbler per-channel deal counts. A deal touched by two channels appears in both; use
totals. - Report every platform that failed to return data, by name.