adloop 0.9.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {adloop-0.9.0 → adloop-0.11.0}/PKG-INFO +71 -21
  2. {adloop-0.9.0 → adloop-0.11.0}/README.md +69 -20
  3. {adloop-0.9.0 → adloop-0.11.0}/pyproject.toml +2 -1
  4. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/__init__.py +14 -1
  5. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/gaql.py +38 -1
  6. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/read.py +400 -5
  7. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/write.py +527 -15
  8. adloop-0.11.0/src/adloop/auth.py +290 -0
  9. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/cli.py +26 -50
  10. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/config.py +1 -1
  11. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/crossref.py +262 -0
  12. adloop-0.11.0/src/adloop/gtm/__init__.py +1 -0
  13. adloop-0.11.0/src/adloop/gtm/client.py +20 -0
  14. adloop-0.11.0/src/adloop/gtm/read.py +648 -0
  15. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/adloop.md +70 -4
  16. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/commands/analyze-performance.md +2 -0
  17. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/commands/optimize-campaign.md +2 -0
  18. adloop-0.11.0/src/adloop/runtime.py +127 -0
  19. adloop-0.11.0/src/adloop/safety/audit.py +78 -0
  20. adloop-0.11.0/src/adloop/safety/preview.py +110 -0
  21. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/server.py +499 -101
  22. adloop-0.9.0/src/adloop/auth.py +0 -196
  23. adloop-0.9.0/src/adloop/bundled_credentials.json +0 -13
  24. adloop-0.9.0/src/adloop/safety/audit.py +0 -40
  25. adloop-0.9.0/src/adloop/safety/preview.py +0 -58
  26. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/__main__.py +0 -0
  27. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/_mcp_patches.py +0 -0
  28. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/__init__.py +0 -0
  29. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/client.py +0 -0
  30. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/currency.py +0 -0
  31. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/enums.py +0 -0
  32. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/forecast.py +0 -0
  33. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ads/pmax.py +0 -0
  34. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/diagnostics.py +0 -0
  35. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ga4/__init__.py +0 -0
  36. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ga4/client.py +0 -0
  37. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ga4/reports.py +0 -0
  38. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/ga4/tracking.py +0 -0
  39. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/__init__.py +0 -0
  40. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  41. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/commands/create-ad.md +0 -0
  42. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  43. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  44. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/rules_install.py +0 -0
  45. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/safety/__init__.py +0 -0
  46. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/safety/guards.py +0 -0
  47. {adloop-0.9.0 → adloop-0.11.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: The AI command center for Google Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -10,6 +10,7 @@ Requires-Dist: fastmcp>=3.0.0
10
10
  Requires-Dist: google-ads>=29.0.0
11
11
  Requires-Dist: google-analytics-data>=0.20.0
12
12
  Requires-Dist: google-analytics-admin>=0.27.0
13
+ Requires-Dist: google-api-python-client>=2.100.0
13
14
  Requires-Dist: google-auth-oauthlib>=1.0.0
14
15
  Requires-Dist: pyyaml>=6.0
15
16
  Requires-Dist: pytest>=8.0 ; extra == 'dev'
@@ -38,12 +39,31 @@ Description-Content-Type: text/markdown
38
39
 
39
40
  An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
40
41
 
41
- `pip install adloop`
42
+ **[☁️ Skip the setup — use AdLoop Cloud (free beta)](https://getadloop.com)**  ·  or self-host: `pip install adloop`
42
43
 
43
44
  </div>
44
45
 
46
+ > [!TIP]
47
+ > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the same 61 tools from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
48
+
45
49
  ---
46
50
 
51
+ ## Cloud or Self-Hosted?
52
+
53
+ Both versions run the same 61 tools with the same safety model. The difference is who handles the plumbing:
54
+
55
+ | | ☁️ [AdLoop Cloud](https://getadloop.com) | 🛠️ Self-hosted (this repo) |
56
+ |---|---|---|
57
+ | **Setup** | Connect Google in two clicks | ~5 min: own Google Cloud project + `adloop init` |
58
+ | **Google Cloud project** | Not needed | Required (free) |
59
+ | **Ads developer token** | Not needed | Required (from your MCC) |
60
+ | **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
61
+ | **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
62
+ | **Updates** | Automatic | `pip install -U adloop` |
63
+ | **Price** | Free during beta | Free forever (MIT) |
64
+
65
+ **Not sure? [Start with Cloud](https://getadloop.com)** — it's the fastest way to see what AdLoop can do, and it's the only way to use AdLoop from claude.ai or ChatGPT. Self-host when you want everything on your own machine or need to modify the code. And if you're here to hack on AdLoop itself: welcome, keep scrolling.
66
+
47
67
  ## What It Solves
48
68
 
49
69
  AdLoop exists because managing Google Ads alongside your code is a mess. These are the specific problems it handles:
@@ -58,6 +78,8 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
58
78
 
59
79
  - **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
60
80
 
81
+ - **"Are conversions even being tagged on every page?"** AdLoop reads your live Google Tag Manager container, joins it against the events in your codebase and the events firing in GA4, and tells you exactly which conversions are being captured, which tags are paused, which page-scope filters are too narrow, and which codebase events have no tag at all — the kind of three-way audit GTM Preview can't give you in a single view.
82
+
61
83
  - **"I don't know if my EU consent setup is causing data gaps."** In Europe, 30-70% of users reject analytics cookies. AdLoop accounts for this automatically — it won't diagnose a normal GDPR consent gap as broken tracking.
62
84
 
63
85
  ## Built From Real Usage
@@ -66,9 +88,9 @@ Every tool exists because of an actual problem hit while running real Google Ads
66
88
 
67
89
  The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
68
90
 
69
- ## All 43 Tools
91
+ ## All 61 Tools
70
92
 
71
- > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
93
+ > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init` — or zero setup on [AdLoop Cloud](https://getadloop.com)
72
94
 
73
95
  ### Diagnostics
74
96
 
@@ -103,8 +125,11 @@ The best features come from real workflows. If you're using AdLoop and find your
103
125
  | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
104
126
  | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
105
127
  | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
128
+ | `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
106
129
  | `run_gaql` | Arbitrary GAQL queries for anything else |
107
130
 
131
+ > **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
132
+
108
133
  ### Cross-Reference Tools (GA4 + Ads Combined)
109
134
 
110
135
  These tools call both APIs internally and return unified results with auto-generated insights. They're the core of what makes AdLoop different from having separate GA4 and Ads tools.
@@ -122,6 +147,27 @@ These tools call both APIs internally and return unified results with auto-gener
122
147
  | `validate_tracking` | Compare event names found in your codebase against what GA4 actually records. Returns matched, missing, and unexpected events with diagnostics. |
123
148
  | `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for any event, with recommended parameters for well-known events (sign_up, purchase, etc.) and optional trigger wrappers. |
124
149
 
150
+ ### Google Tag Manager Tools
151
+
152
+ These tools read the live GTM container and join it with the codebase + GA4 to find tracking gaps that pure GA4 inspection can't catch — page-scoped triggers, paused tags, dynamic event names, brittle CSS selectors, and codebase events with no tag wired up at all.
153
+
154
+ | Tool | What It Does |
155
+ |------|-------------|
156
+ | `audit_event_coverage` | **The flagship.** Three-way join: codebase events ↔ GTM tags ↔ GA4 actual fires. For each event name in `expected_events`, returns one of 10 statuses (`ok`, `no_tag_no_fire`, `tag_paused`, `tag_active_but_not_firing`, `gtm_only_firing`, `ga4_only`, etc.) plus auto-generated insights for the gaps. |
157
+ | `list_gtm_accounts` | Discover accessible GTM accounts |
158
+ | `list_gtm_containers` | List containers under an account — returns numeric `container_id` (needed by other tools), public `GTM-XXXXXXX` ID, and usage context (web/iOS/Android/server) |
159
+ | `list_gtm_tags` | Every tag in the live container with parsed event names and resolved firing/blocking trigger names |
160
+ | `get_gtm_tag` | Full raw config for a single tag — every parameter, firing/blocking triggers with filter conditions, priority, pause status, sampling |
161
+ | `list_gtm_triggers` | Every trigger with filter conditions parsed to readable text (e.g. `{{Page Path}} contains service-promotions`, `{{Form ID}} NOT contains wf-form-...`). Renders the `negate` flag explicitly. |
162
+ | `get_gtm_trigger` | Full trigger config + reverse lookup of every tag that uses it. Includes parsed `element_visibility` block (selector, on-screen ratio, firing frequency) for elementVisibility triggers and `group_member_trigger_ids` for triggerGroup types |
163
+ | `list_gtm_variables` | Custom variables (data layer, constants, JS) plus enabled built-in variables |
164
+ | `list_gtm_workspaces` | List drafts (workspaces) under a container — workspace IDs are needed by `get_gtm_workspace_diff` |
165
+ | `get_gtm_workspace_diff` | Drafted-but-not-published changes — common cause of "I edited a tag but nothing happened". Returns `is_clean: true` when nothing is pending. |
166
+ | `list_gtm_versions` | Publish history with version IDs and entity counts. Use to correlate a metric drop with a recent publish. |
167
+ | `get_gtm_version` | Full metadata + tag/trigger names for a single historical container version |
168
+
169
+ > **Setup for GTM tools** — Enable the **Tag Manager API v2** in your GCP project, then add your AdLoop credentials' email (the OAuth user, or the service account email if using a service account) as a **Read** user on the GTM container under Admin → User Management. Service accounts pick up access on the next call. **OAuth users upgrading from an earlier AdLoop version must re-authorize once**: the GTM scope is new, so delete `~/.adloop/token.json` and run any tool to re-consent — until then GTM tools return a permissions error.
170
+
125
171
  ### Planning Tools
126
172
 
127
173
  | Tool | What It Does |
@@ -145,6 +191,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
145
191
  | `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
146
192
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
147
193
  | `add_negative_keywords` | Propose negative keywords directly on a campaign |
194
+ | `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
195
+ | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
148
196
  | `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
149
197
  | `pause_entity` | Pause a campaign, ad group, ad, or keyword |
150
198
  | `enable_entity` | Re-enable a paused entity |
@@ -196,12 +244,11 @@ AdLoop manages real ad spend, so safety is not optional.
196
244
 
197
245
  ## Setup
198
246
 
199
- > **⚠️ Built-in OAuth credentials are temporarily unavailable while Google verification is pending.**
200
- > Google limits unverified OAuth apps to 100 users, and AdLoop has reached that cap. New users will see a **"This app is blocked"** error if they pick the built-in option in the wizard.
247
+ > **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
201
248
  >
202
- > **What this means for you:** until Google completes verification, **bring your own Google Cloud project** — it takes ~5 minutes, has no user cap, and the `adloop init` wizard guides you through it. Status updates: [Discussion #13](https://github.com/kLOsk/adloop/discussions/13).
249
+ > **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no developer token, EU-hosted.
203
250
  >
204
- > *(Existing users whose tokens were already issued before the cap continue to work — only first-time sign-ins are blocked.)*
251
+ > *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
205
252
 
206
253
  ### Install
207
254
 
@@ -223,7 +270,7 @@ uv run adloop init
223
270
 
224
271
  ### What `adloop init` does
225
272
 
226
- The wizard defaults to the "bring your own Google Cloud project" path while verification is pending. It walks you through:
273
+ The wizard walks you through:
227
274
 
228
275
  1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
229
276
  2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
@@ -233,8 +280,6 @@ The wizard defaults to the "bring your own Google Cloud project" path while veri
233
280
  6. **Safety defaults** — budget cap and dry-run preference
234
281
  7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
235
282
 
236
- The wizard does still offer AdLoop's built-in credentials as a non-default option for existing users whose tokens predate the cap. Picking that option for a brand-new Google account will fail at the consent screen — the wizard warns you about this before you choose.
237
-
238
283
  ### Requirements
239
284
 
240
285
  - Python 3.11+
@@ -243,7 +288,7 @@ The wizard does still offer AdLoop's built-in credentials as a non-default optio
243
288
 
244
289
  ### Google Ads Developer Token
245
290
 
246
- A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
291
+ A developer token is **always required**. Your OAuth client handles Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
247
292
 
248
293
  1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
249
294
  2. In the MCC, go to **Tools & Settings → API Center**
@@ -265,7 +310,7 @@ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatical
265
310
 
266
311
  ### Custom Google Cloud Project Setup
267
312
 
268
- This is the default path while built-in credentials are blocked by Google's 100-user cap. The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
313
+ The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
269
314
 
270
315
  #### Step 1 — Google Cloud Project
271
316
 
@@ -347,6 +392,7 @@ Ask your AI assistant things like:
347
392
  - *"Draft a new responsive search ad for my main campaign."*
348
393
  - *"Which landing pages get paid traffic but don't convert?"*
349
394
  - *"Is my tracking set up correctly? Compare my codebase events against GA4."*
395
+ - *"Audit my Google Tag Manager container — which conversions are being captured and where are the gaps?"*
350
396
  - *"What keywords should I target for [product]? Find ideas and estimate the budget."*
351
397
  - *"How much budget would I need for these keywords in Germany?"*
352
398
  - *"Create a new search campaign for [product feature] with a €20/day budget."*
@@ -358,7 +404,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
358
404
  | Section | Key | Default | Description |
359
405
  |---------|-----|---------|-------------|
360
406
  | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
361
- | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
407
+ | `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
362
408
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
363
409
  | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
364
410
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
@@ -373,11 +419,11 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
373
419
  ```
374
420
  src/adloop/
375
421
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
376
- ├── server.py # FastMCP server — 43 tool registrations with safety annotations
422
+ ├── server.py # FastMCP server — 61 tool registrations with safety annotations
377
423
  ├── config.py # Config loader (~/.adloop/config.yaml)
378
- ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
424
+ ├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts; GA4 / Ads / GTM scopes
379
425
  ├── cli.py # Interactive 'adloop init' setup wizard
380
- ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
426
+ ├── crossref.py # Cross-reference tools (GA4 + Ads + GTM combined analysis)
381
427
  ├── tracking.py # Tracking validation + code generation tools
382
428
  ├── ga4/
383
429
  │ ├── client.py # GA4 Data + Admin API clients
@@ -390,6 +436,9 @@ src/adloop/
390
436
  │ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
391
437
  │ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
392
438
  │ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
439
+ ├── gtm/
440
+ │ ├── client.py # Google Tag Manager API v2 client
441
+ │ └── read.py # Live container fetching, tag/trigger/variable parsing, workspace diff, version history
393
442
  └── safety/
394
443
  ├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
395
444
  ├── preview.py # Change plans and previews
@@ -411,9 +460,10 @@ What's been shipped and what's next:
411
460
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
412
461
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
413
462
  - ~~PyPI package~~ ✓ — `pip install adloop`
414
- - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required (**currently blocked at 100-user cap** pending Google verification; `adloop init` defaults to the [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) path until verification completes)
463
+ - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version, live in beta: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
415
464
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
416
465
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
466
+ - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
417
467
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
418
468
  - **Video walkthrough**
419
469
 
@@ -427,14 +477,14 @@ MIT — see [LICENSE](LICENSE).
427
477
 
428
478
  ## Privacy
429
479
 
430
- AdLoop runs entirely on your machine. No data is collected, stored, or transmitted to any server. See [PRIVACY.md](PRIVACY.md) for the full privacy policy.
480
+ The open-source version runs entirely on your machine. No data is collected, stored, or transmitted to any server. See [PRIVACY.md](PRIVACY.md) for the full privacy policy. AdLoop Cloud has its own [privacy policy](https://getadloop.com/datenschutz) and [DPA](https://getadloop.com/avv).
431
481
 
432
482
  ---
433
483
 
434
484
  <div align="center">
435
485
 
436
- **If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop).**
486
+ **If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop) or [try the hosted version](https://getadloop.com).**
437
487
 
438
- Made by [@kLOsk](https://github.com/kLOsk) | [Privacy Policy](PRIVACY.md)
488
+ Made by [@kLOsk](https://github.com/kLOsk) | [AdLoop Cloud](https://getadloop.com) | [Privacy Policy](PRIVACY.md)
439
489
 
440
490
  </div>
@@ -14,12 +14,31 @@
14
14
 
15
15
  An MCP server that gives your AI assistant read + write access to Google Ads and GA4 — with safety guardrails that prevent accidental spend.
16
16
 
17
- `pip install adloop`
17
+ **[☁️ Skip the setup — use AdLoop Cloud (free beta)](https://getadloop.com)** &nbsp;·&nbsp; or self-host: `pip install adloop`
18
18
 
19
19
  </div>
20
20
 
21
+ > [!TIP]
22
+ > **[AdLoop Cloud](https://getadloop.com) is the hosted version of this project — live now, free during beta (limited seats).** Connect Google in two clicks and use the same 61 tools from claude.ai, ChatGPT, Claude Code, Cursor, or Gemini. No Google Cloud project, no developer token, no OAuth verification wait. EU-hosted, GDPR-first, DPA included.
23
+
21
24
  ---
22
25
 
26
+ ## Cloud or Self-Hosted?
27
+
28
+ Both versions run the same 61 tools with the same safety model. The difference is who handles the plumbing:
29
+
30
+ | | ☁️ [AdLoop Cloud](https://getadloop.com) | 🛠️ Self-hosted (this repo) |
31
+ |---|---|---|
32
+ | **Setup** | Connect Google in two clicks | ~5 min: own Google Cloud project + `adloop init` |
33
+ | **Google Cloud project** | Not needed | Required (free) |
34
+ | **Ads developer token** | Not needed | Required (from your MCC) |
35
+ | **Works with** | claude.ai, ChatGPT, Claude Code, Cursor, Gemini | Claude Code, Cursor, Claude Desktop, any local MCP client |
36
+ | **Where your data flows** | EU servers (Germany), GDPR-first, DPA included | 100% your machine — nothing leaves it |
37
+ | **Updates** | Automatic | `pip install -U adloop` |
38
+ | **Price** | Free during beta | Free forever (MIT) |
39
+
40
+ **Not sure? [Start with Cloud](https://getadloop.com)** — it's the fastest way to see what AdLoop can do, and it's the only way to use AdLoop from claude.ai or ChatGPT. Self-host when you want everything on your own machine or need to modify the code. And if you're here to hack on AdLoop itself: welcome, keep scrolling.
41
+
23
42
  ## What It Solves
24
43
 
25
44
  AdLoop exists because managing Google Ads alongside your code is a mess. These are the specific problems it handles:
@@ -34,6 +53,8 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
34
53
 
35
54
  - **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
36
55
 
56
+ - **"Are conversions even being tagged on every page?"** AdLoop reads your live Google Tag Manager container, joins it against the events in your codebase and the events firing in GA4, and tells you exactly which conversions are being captured, which tags are paused, which page-scope filters are too narrow, and which codebase events have no tag at all — the kind of three-way audit GTM Preview can't give you in a single view.
57
+
37
58
  - **"I don't know if my EU consent setup is causing data gaps."** In Europe, 30-70% of users reject analytics cookies. AdLoop accounts for this automatically — it won't diagnose a normal GDPR consent gap as broken tracking.
38
59
 
39
60
  ## Built From Real Usage
@@ -42,9 +63,9 @@ Every tool exists because of an actual problem hit while running real Google Ads
42
63
 
43
64
  The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
44
65
 
45
- ## All 43 Tools
66
+ ## All 61 Tools
46
67
 
47
- > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
68
+ > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init` — or zero setup on [AdLoop Cloud](https://getadloop.com)
48
69
 
49
70
  ### Diagnostics
50
71
 
@@ -79,8 +100,11 @@ The best features come from real workflows. If you're using AdLoop and find your
79
100
  | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
80
101
  | `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most |
81
102
  | `get_audience_performance` | Audience segment performance — remarketing, in-market, affinity, demographics |
103
+ | `get_demographic_targeting` | List demographic criteria (age/gender/parental status/income) on an ad group or campaign |
82
104
  | `run_gaql` | Arbitrary GAQL queries for anything else |
83
105
 
106
+ > **Compact mode** — `get_campaign_performance`, `get_keyword_performance`, `get_search_terms`, and `get_ad_performance` accept `compact=true`: account totals, breakdowns, top-10 rows, and pre-computed offender lists (zero-conversion spenders, low-QS keywords, negative-keyword candidates, thin RSAs) instead of every row. ~90% smaller responses — built for account audits so raw tables don't flood your AI's context.
107
+
84
108
  ### Cross-Reference Tools (GA4 + Ads Combined)
85
109
 
86
110
  These tools call both APIs internally and return unified results with auto-generated insights. They're the core of what makes AdLoop different from having separate GA4 and Ads tools.
@@ -98,6 +122,27 @@ These tools call both APIs internally and return unified results with auto-gener
98
122
  | `validate_tracking` | Compare event names found in your codebase against what GA4 actually records. Returns matched, missing, and unexpected events with diagnostics. |
99
123
  | `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for any event, with recommended parameters for well-known events (sign_up, purchase, etc.) and optional trigger wrappers. |
100
124
 
125
+ ### Google Tag Manager Tools
126
+
127
+ These tools read the live GTM container and join it with the codebase + GA4 to find tracking gaps that pure GA4 inspection can't catch — page-scoped triggers, paused tags, dynamic event names, brittle CSS selectors, and codebase events with no tag wired up at all.
128
+
129
+ | Tool | What It Does |
130
+ |------|-------------|
131
+ | `audit_event_coverage` | **The flagship.** Three-way join: codebase events ↔ GTM tags ↔ GA4 actual fires. For each event name in `expected_events`, returns one of 10 statuses (`ok`, `no_tag_no_fire`, `tag_paused`, `tag_active_but_not_firing`, `gtm_only_firing`, `ga4_only`, etc.) plus auto-generated insights for the gaps. |
132
+ | `list_gtm_accounts` | Discover accessible GTM accounts |
133
+ | `list_gtm_containers` | List containers under an account — returns numeric `container_id` (needed by other tools), public `GTM-XXXXXXX` ID, and usage context (web/iOS/Android/server) |
134
+ | `list_gtm_tags` | Every tag in the live container with parsed event names and resolved firing/blocking trigger names |
135
+ | `get_gtm_tag` | Full raw config for a single tag — every parameter, firing/blocking triggers with filter conditions, priority, pause status, sampling |
136
+ | `list_gtm_triggers` | Every trigger with filter conditions parsed to readable text (e.g. `{{Page Path}} contains service-promotions`, `{{Form ID}} NOT contains wf-form-...`). Renders the `negate` flag explicitly. |
137
+ | `get_gtm_trigger` | Full trigger config + reverse lookup of every tag that uses it. Includes parsed `element_visibility` block (selector, on-screen ratio, firing frequency) for elementVisibility triggers and `group_member_trigger_ids` for triggerGroup types |
138
+ | `list_gtm_variables` | Custom variables (data layer, constants, JS) plus enabled built-in variables |
139
+ | `list_gtm_workspaces` | List drafts (workspaces) under a container — workspace IDs are needed by `get_gtm_workspace_diff` |
140
+ | `get_gtm_workspace_diff` | Drafted-but-not-published changes — common cause of "I edited a tag but nothing happened". Returns `is_clean: true` when nothing is pending. |
141
+ | `list_gtm_versions` | Publish history with version IDs and entity counts. Use to correlate a metric drop with a recent publish. |
142
+ | `get_gtm_version` | Full metadata + tag/trigger names for a single historical container version |
143
+
144
+ > **Setup for GTM tools** — Enable the **Tag Manager API v2** in your GCP project, then add your AdLoop credentials' email (the OAuth user, or the service account email if using a service account) as a **Read** user on the GTM container under Admin → User Management. Service accounts pick up access on the next call. **OAuth users upgrading from an earlier AdLoop version must re-authorize once**: the GTM scope is new, so delete `~/.adloop/token.json` and run any tool to re-consent — until then GTM tools return a permissions error.
145
+
101
146
  ### Planning Tools
102
147
 
103
148
  | Tool | What It Does |
@@ -121,6 +166,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
121
166
  | `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
122
167
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
123
168
  | `add_negative_keywords` | Propose negative keywords directly on a campaign |
169
+ | `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
170
+ | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income) — exclusions by default |
124
171
  | `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
125
172
  | `pause_entity` | Pause a campaign, ad group, ad, or keyword |
126
173
  | `enable_entity` | Re-enable a paused entity |
@@ -172,12 +219,11 @@ AdLoop manages real ad spend, so safety is not optional.
172
219
 
173
220
  ## Setup
174
221
 
175
- > **⚠️ Built-in OAuth credentials are temporarily unavailable while Google verification is pending.**
176
- > Google limits unverified OAuth apps to 100 users, and AdLoop has reached that cap. New users will see a **"This app is blocked"** error if they pick the built-in option in the wizard.
222
+ > **AdLoop uses your own (free) Google Cloud project for OAuth.** The `adloop init` wizard walks you through it — a one-time setup of about 5 minutes, with no shared user caps and no waiting on anyone's verification review. AdLoop does not ship built-in OAuth credentials.
177
223
  >
178
- > **What this means for you:** until Google completes verification, **bring your own Google Cloud project** — it takes ~5 minutes, has no user cap, and the `adloop init` wizard guides you through it. Status updates: [Discussion #13](https://github.com/kLOsk/adloop/discussions/13).
224
+ > **Prefer zero setup?** [**AdLoop Cloud**](https://getadloop.com) is the hosted version: connect Google in two clicks — no Cloud project, no developer token, EU-hosted.
179
225
  >
180
- > *(Existing users whose tokens were already issued before the cap continue to work — only first-time sign-ins are blocked.)*
226
+ > *(Upgrading from ≤0.9 with built-in credentials? Those sign-ins were retired in 0.10 — run `adloop init` once to switch to your own project.)*
181
227
 
182
228
  ### Install
183
229
 
@@ -199,7 +245,7 @@ uv run adloop init
199
245
 
200
246
  ### What `adloop init` does
201
247
 
202
- The wizard defaults to the "bring your own Google Cloud project" path while verification is pending. It walks you through:
248
+ The wizard walks you through:
203
249
 
204
250
  1. **Google Cloud setup** — creates a project, enables the three APIs, generates an OAuth client (see [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) below for the exact steps the wizard refers you to)
205
251
  2. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
@@ -209,8 +255,6 @@ The wizard defaults to the "bring your own Google Cloud project" path while veri
209
255
  6. **Safety defaults** — budget cap and dry-run preference
210
256
  7. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
211
257
 
212
- The wizard does still offer AdLoop's built-in credentials as a non-default option for existing users whose tokens predate the cap. Picking that option for a brand-new Google account will fail at the consent screen — the wizard warns you about this before you choose.
213
-
214
258
  ### Requirements
215
259
 
216
260
  - Python 3.11+
@@ -219,7 +263,7 @@ The wizard does still offer AdLoop's built-in credentials as a non-default optio
219
263
 
220
264
  ### Google Ads Developer Token
221
265
 
222
- A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
266
+ A developer token is **always required**. Your OAuth client handles Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
223
267
 
224
268
  1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
225
269
  2. In the MCC, go to **Tools & Settings → API Center**
@@ -241,7 +285,7 @@ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatical
241
285
 
242
286
  ### Custom Google Cloud Project Setup
243
287
 
244
- This is the default path while built-in credentials are blocked by Google's 100-user cap. The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
288
+ The wizard refers to these steps — do them in your browser before running `adloop init` (or while it waits at the OAuth prompt).
245
289
 
246
290
  #### Step 1 — Google Cloud Project
247
291
 
@@ -323,6 +367,7 @@ Ask your AI assistant things like:
323
367
  - *"Draft a new responsive search ad for my main campaign."*
324
368
  - *"Which landing pages get paid traffic but don't convert?"*
325
369
  - *"Is my tracking set up correctly? Compare my codebase events against GA4."*
370
+ - *"Audit my Google Tag Manager container — which conversions are being captured and where are the gaps?"*
326
371
  - *"What keywords should I target for [product]? Find ideas and estimate the budget."*
327
372
  - *"How much budget would I need for these keywords in Germany?"*
328
373
  - *"Create a new search campaign for [product feature] with a €20/day budget."*
@@ -334,7 +379,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
334
379
  | Section | Key | Default | Description |
335
380
  |---------|-----|---------|-------------|
336
381
  | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
337
- | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
382
+ | `google` | `credentials_path` | *(empty)* | Path to OAuth client JSON or service account key. Empty = `~/.adloop/credentials.json`, else Application Default Credentials. |
338
383
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
339
384
  | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
340
385
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
@@ -349,11 +394,11 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
349
394
  ```
350
395
  src/adloop/
351
396
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
352
- ├── server.py # FastMCP server — 43 tool registrations with safety annotations
397
+ ├── server.py # FastMCP server — 61 tool registrations with safety annotations
353
398
  ├── config.py # Config loader (~/.adloop/config.yaml)
354
- ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
399
+ ├── auth.py # OAuth 2.0 flow (user-supplied credentials, headless fallback) + service accounts; GA4 / Ads / GTM scopes
355
400
  ├── cli.py # Interactive 'adloop init' setup wizard
356
- ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
401
+ ├── crossref.py # Cross-reference tools (GA4 + Ads + GTM combined analysis)
357
402
  ├── tracking.py # Tracking validation + code generation tools
358
403
  ├── ga4/
359
404
  │ ├── client.py # GA4 Data + Admin API clients
@@ -366,6 +411,9 @@ src/adloop/
366
411
  │ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
367
412
  │ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
368
413
  │ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
414
+ ├── gtm/
415
+ │ ├── client.py # Google Tag Manager API v2 client
416
+ │ └── read.py # Live container fetching, tag/trigger/variable parsing, workspace diff, version history
369
417
  └── safety/
370
418
  ├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
371
419
  ├── preview.py # Change plans and previews
@@ -387,9 +435,10 @@ What's been shipped and what's next:
387
435
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
388
436
  - **Claude Desktop one-click install** — `adloop install claude-desktop` (and/or a `.dxt` extension bundle) that writes the AdLoop MCP entry into `claude_desktop_config.json` automatically, so Claude Desktop + Cowork users don't have to hand-edit JSON
389
437
  - ~~PyPI package~~ ✓ — `pip install adloop`
390
- - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required (**currently blocked at 100-user cap** pending Google verification; `adloop init` defaults to the [Custom Google Cloud Project Setup](#custom-google-cloud-project-setup) path until verification completes)
438
+ - ~~[AdLoop Cloud](https://getadloop.com)~~ ✓ — the hosted version, live in beta: no Google Cloud project, no developer token, connect Google in two clicks (EU-hosted, GDPR-first)
391
439
  - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
392
440
  - ~~Behavioral eval suites~~ ✓ — 28 prompt-and-expectation tests covering read, write, tracking, and planning workflows
441
+ - ~~Google Tag Manager integration~~ ✓ — read tools for tags, triggers, variables, workspaces, and version history, plus the `audit_event_coverage` three-way join across codebase events, GTM tags, and GA4 actual fires
393
442
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
394
443
  - **Video walkthrough**
395
444
 
@@ -403,14 +452,14 @@ MIT — see [LICENSE](LICENSE).
403
452
 
404
453
  ## Privacy
405
454
 
406
- AdLoop runs entirely on your machine. No data is collected, stored, or transmitted to any server. See [PRIVACY.md](PRIVACY.md) for the full privacy policy.
455
+ The open-source version runs entirely on your machine. No data is collected, stored, or transmitted to any server. See [PRIVACY.md](PRIVACY.md) for the full privacy policy. AdLoop Cloud has its own [privacy policy](https://getadloop.com/datenschutz) and [DPA](https://getadloop.com/avv).
407
456
 
408
457
  ---
409
458
 
410
459
  <div align="center">
411
460
 
412
- **If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop).**
461
+ **If AdLoop helps you run Google Ads, GA4, and tracking code from one place — [give it a star](https://github.com/kLOsk/adloop) or [try the hosted version](https://getadloop.com).**
413
462
 
414
- Made by [@kLOsk](https://github.com/kLOsk) | [Privacy Policy](PRIVACY.md)
463
+ Made by [@kLOsk](https://github.com/kLOsk) | [AdLoop Cloud](https://getadloop.com) | [Privacy Policy](PRIVACY.md)
415
464
 
416
465
  </div>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.9.0"
3
+ version = "0.11.0"
4
4
  description = "The AI command center for Google Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "google-ads>=29.0.0",
15
15
  "google-analytics-data>=0.20.0",
16
16
  "google-analytics-admin>=0.27.0",
17
+ "google-api-python-client>=2.100.0",
17
18
  "google-auth-oauthlib>=1.0.0",
18
19
  "pyyaml>=6.0",
19
20
  ]
@@ -1,8 +1,12 @@
1
1
  """AdLoop — MCP server connecting Google Ads + GA4 + codebase."""
2
2
 
3
3
  import sys
4
+ from importlib.metadata import PackageNotFoundError, version
4
5
 
5
- __version__ = "0.9.0"
6
+ try:
7
+ __version__ = version("adloop")
8
+ except PackageNotFoundError: # running from a source tree without install
9
+ __version__ = "0.0.0.dev0"
6
10
 
7
11
 
8
12
  def main() -> None:
@@ -42,6 +46,15 @@ def main() -> None:
42
46
  sys.exit(130)
43
47
  return
44
48
 
49
+ # Process-global side effects (signal handlers, heartbeat thread, and
50
+ # the stdio cancellation-race monkeypatch) are deliberately installed
51
+ # here — in the stdio entry point — rather than at adloop.server import
52
+ # time, so embedding the server in an ASGI app stays side-effect-free.
53
+ from adloop import _mcp_patches, diagnostics
54
+
55
+ diagnostics.install()
56
+ _mcp_patches.install()
57
+
45
58
  from adloop.server import mcp
46
59
 
47
60
  mcp.run()
@@ -89,7 +89,29 @@ _GAQL_ERROR_HINTS = {
89
89
 
90
90
 
91
91
  def _parse_gaql_error(exc: Exception) -> str:
92
- """Extract a human-readable message from Google Ads gRPC errors."""
92
+ """Extract a human-readable message from Google Ads gRPC errors.
93
+
94
+ Prefers the structured ``failure.errors[]`` a GoogleAdsException
95
+ carries — Google's own message names the exact field/clause at
96
+ fault (e.g. PROHIBITED_FIELD_IN_SELECT_CLAUSE with the field name),
97
+ which is far more actionable than a generic hint. Known error codes
98
+ still get the hint appended as a suffix.
99
+ """
100
+ failure = getattr(exc, "failure", None)
101
+ errors = getattr(failure, "errors", None) if failure is not None else None
102
+ if errors:
103
+ parts = []
104
+ for err in errors:
105
+ code = str(getattr(err, "error_code", "") or "").strip()
106
+ message = str(getattr(err, "message", "") or "").strip()
107
+ parts.append(" — ".join(p for p in (code, message) if p))
108
+ detail = " | ".join(p for p in parts if p)
109
+ if detail:
110
+ for known, hint in _GAQL_ERROR_HINTS.items():
111
+ if known in detail:
112
+ return f"{detail} (hint: {hint})"
113
+ return detail
114
+
93
115
  raw = str(exc)
94
116
  for code, hint in _GAQL_ERROR_HINTS.items():
95
117
  if code in raw:
@@ -137,6 +159,21 @@ def _to_python(obj: object) -> object:
137
159
  # AdTextAsset and similar message types
138
160
  if hasattr(obj, "text") and isinstance(getattr(obj, "text", None), str):
139
161
  return obj.text
162
+ # Nested proto-plus messages (targeting settings, criteria, ...) —
163
+ # serialize to a dict instead of collapsing to their str() repr.
164
+ try:
165
+ import proto
166
+
167
+ if isinstance(obj, proto.Message):
168
+ return type(obj).to_dict(
169
+ obj, preserving_proto_field_name=True, use_integers_for_enums=False
170
+ )
171
+ except ImportError:
172
+ pass
173
+ if hasattr(obj, "DESCRIPTOR"):
174
+ from google.protobuf.json_format import MessageToDict
175
+
176
+ return MessageToDict(obj, preserving_proto_field_name=True)
140
177
  return str(obj)
141
178
 
142
179