feat(gtm): write tools — create/update/delete tags + triggers + publish#35
feat(gtm): write tools — create/update/delete tags + triggers + publish#35illia-sapryga wants to merge 3 commits into
Conversation
Adds Google Tag Manager API v2 read tools and a cross-reference tool
that joins codebase events, GTM tags, and GA4 actual fires — the kind
of three-way audit GTM Preview cannot give in a single view.
The premise: GA4 + Ads alone tell you what fired, codebase grep tells
you intent, GTM tells you wiring. Each in isolation hides real bugs —
paused tags, page-scoped triggers that under-fire, dynamic event names
hiding real coverage, codebase events with no GTM tag at all. Joining
all three is the only way to spot them in one read.
Validated against a real production container: surfaced that a
client's GA4 form_submit and click_to_call tags were scoped to
/service-promotions/* only, while their Enhanced Conversions for Leads
caught every form submit sitewide — an asymmetric Smart Bidding
signal that no single-source diagnostic surfaces.
12 new tools
- audit_event_coverage (in crossref.py) — the flagship. Three-way join
with 10 distinct status types (ok, no_tag_no_fire, tag_paused,
tag_active_but_not_firing, gtm_only_firing, ga4_only,
ok_auto_collected, etc.) plus auto-generated insights for every gap.
- list_gtm_accounts / list_gtm_containers — discovery
- list_gtm_tags / get_gtm_tag — parsed GA4 event names + resolved
firing/blocking trigger names; built-in trigger IDs (>= 2147479553)
resolved to readable names instead of "(unknown)"
- list_gtm_triggers / get_gtm_trigger — filter conditions parsed to
text including the negate flag rendered as "NOT contains" (silent
meaning-inversion if missed); type-specific blocks for triggerGroup
(group_member_trigger_ids) and elementVisibility (selector,
selector_type, firing_frequency, on_screen_ratio,
use_dom_change_listener)
- list_gtm_variables — custom variables + enabled built-ins
- list_gtm_workspaces / get_gtm_workspace_diff — drafted-but-not-
published changes, the common cause of "I edited a tag and nothing
happened"
- list_gtm_versions / get_gtm_version — publish history for
correlating metric drops with recent publishes
Architecture
- src/adloop/gtm/{client.py, read.py} — new module
- auth.py — _GTM_SCOPES + get_gtm_credentials; tagmanager.readonly
added to _ALL_SCOPES so OAuth users get GTM access on next re-auth
- crossref.py — audit_event_coverage joins GTM + GA4 + codebase
- server.py — 12 new @mcp.tool registrations under _READONLY
- pyproject.toml — google-api-python-client>=2.100.0 (GTM API v2 has
no gRPC client; Discovery-built service)
Setup
Enable Tag Manager API v2 in the GCP project, add the AdLoop OAuth
user / service account email as a Read user on the GTM container under
Admin > User Management. Service-account access takes effect on the
next API call (no token refresh needed).
Safety
All read-only. Write tools (draft_gtm_*, publish_gtm_version, rollback,
etc.) are deliberately deferred to a follow-up — a bad GTM publish has
higher blast radius than any Ads write (deleting the GA4 config tag
kills all site tracking instantly), so the read + audit surface ships
first to validate the diagnose-then-recommend pattern before adding
write paths.
README updated with the new section, setup notes, project structure,
and a roadmap entry. Tool count: 43 -> 55.
Adds 50 unit tests covering: - All pure-function parsers in gtm/read.py: _params_dict, _summarize_filter (including negate-flag rendering), _resolve_trigger (custom + built-in IDs), _trigger_group_member_ids, _element_visibility_summary (case-insensitive selectorType), _parse_trigger (type-specific dispatch), _parse_variable. - audit_event_coverage status determination — one test per status code (ok, no_tag_no_fire, tag_paused, tag_active_but_not_firing, ok_auto_collected, ga4_fires_no_tag, gtm_only_firing, gtm_only_not_firing, auto_event_only, ga4_only). - Insight generation for the four most common gap categories: missing tag, paused tag, dynamic-event tag, custom HTML tag. - Matrix shape contracts: required keys, container summary, GA4 error short-circuit, alphabetical event sort. Tests caught a real bug: the `ok` status was matched on `in_gtm AND any_active_tag AND ga4_fires` without requiring `in_codebase`, making `gtm_only_firing` (intended semantics: \"event fires from a GTM tag but isn't in the codebase, possibly stale\") unreachable. Fixed by adding `in_codebase` to the `ok` condition. No user-facing surface area change for events that ARE in the codebase — only events not in the codebase but with active firing tags are affected, and they now correctly route to gtm_only_firing instead of the over-eager ok. 234 tests pass (was 184 baseline + 50 new from this commit).
Builds on PR kLOsk#29 (GTM read tools) to add the full write surface: create / update / delete for tags + triggers, plus workspace publish. Pattern matches the existing AdLoop safety model: draft_* → ChangePlan + store_plan + return preview with plan_id confirm_and_apply(plan_id, dry_run=true|false) → executes via the GTM API ## Tools added | Tool | Notes | |---|---| | `draft_gtm_tag` | Create tag (googtag, gclidw, html, gaawe, awcc, awud, awcr, awct, fls, flc, ua, img). Auto-resolves Default Workspace when workspace_id omitted. | | `draft_gtm_trigger` | Create trigger (pageview, click, linkClick, formSubmission, customEvent, elementVisibility, scroll_depth, youtube_video, history_change, timer, javascript_error). | | `draft_update_gtm_tag` | Partial update of an existing tag — preserves the tag's fingerprint for optimistic concurrency, and any field the caller doesn't pass keeps its current value. | | `draft_update_gtm_trigger` | Same pattern for triggers. Trigger type itself is immutable (delete + create instead). | | `draft_delete_gtm_tag` | Hard delete; warns it's irreversible. | | `draft_delete_gtm_trigger` | Hard delete; warns it's irreversible AND that GTM rejects the delete if any tag still references the trigger. | | `publish_gtm_workspace` | Compile workspace into a ContainerVersion + publish to live. The actual go-live moment, separated from drafting. | ## Tag types accepted The validator includes both Google's built-in template IDs (googtag, awcc, awud, gaawe, etc.) and the verbose long-form aliases (google_ads_calls_from_website, google_ads_user_provided_data, etc.). Floodlight templates (flc, fls) are included for legacy advertisers. ## OAuth scopes `auth.py` extends `_GTM_SCOPES` (and `_ALL_SCOPES`) from `tagmanager.readonly` to also include: - `tagmanager.edit.containers` (create/update/delete tags + triggers) - `tagmanager.edit.containerversions` (compile workspace into a version) - `tagmanager.publish` (set the version live) Existing OAuth tokens issued under the old (read-only) scope set will need to be deleted from `~/.adloop/token.json` so the next API call triggers a re-auth that picks up the new scopes. Service-account auth needs the SA email upgraded from "Read" to "Publish" on the container in GTM Admin → User Management. ## Dispatch A small block at the top of `_execute_plan` in `ads/write.py` detects GTM operations and routes them to the GTM apply handlers (which fetch their own GTM client instead of reusing the Google Ads client). The dispatch list: create_gtm_tag, create_gtm_trigger, publish_gtm_workspace, update_gtm_tag, update_gtm_trigger, delete_gtm_tag, delete_gtm_trigger ## Workspace handling GTM auto-creates a fresh "Default Workspace" after every publish; the old one becomes immutable ("Workspace is already submitted"). The `_resolve_workspace()` helper grabs the current Default Workspace by name on every apply call so callers don't need to track which numeric workspace ID is currently active. ## Tests `tests/test_gtm_write.py` — 22 tests covering: - All 4 draft validators (invalid types, required fields, duplicate-update rejection, custom-event-name requirement) - Real Google template IDs accepted (googtag, awcc, awud, etc.) - Long-name rejection - Filter persistence on triggers - Partial-update semantics on tags + triggers - Delete warnings (irreversible) for both tags + triggers - Publish warning (LIVE on next refresh) - MCP registration of all 7 tools - Dispatch wiring contains all 7 ops Validated against a real BGI Tint container (GTM-T68KKNMK) end-to-end: - Created Google Tag (AW-...), Conversion Linker, GFN Phone Snippet (awcc template), GA4 Event tags - Updated tag names + parameters - Deleted a Custom HTML tag, replaced with awcc template - Published 4 container versions (8, 9, 10, 11) ## Test plan - [x] Full suite passes: 255/255 tests (PR kLOsk#29 baseline + 22 GTM-write) - [x] Service-account write path verified end-to-end against live container `GTM-T68KKNMK` - [x] OAuth scope expansion compatible with existing PR kLOsk#29 auth flow
18f6f6d to
7225024
Compare
|
Holding this behind #29. Beyond the rebase there are three blockers: (1) every |
|
Addendum to my July 7 blockers, from a deeper pass — items the rebase needs beyond what's already listed:
Heads-up on sequencing: I'm bringing #29 up to v0.10 maintainer-side now (with attribution), so the read layer should be on main shortly — rebase this on top of that once it lands, which also shrinks this PR's diff to just the write surface. The safety-model design here is still the strongest part — draft→plan→confirm with per-op blocklist names, |
Depends on #29
This PR is stacked on top of #29 ("feat(gtm): Tag Manager API integration with three-way tracking audit"). The current diff against
mainshows #29's changes + this PR's additions; once #29 merges, the diff will narrow to just the GTM write tools added here.Summary
Builds on #29 (GTM read tools) to add the full write surface — create / update / delete for tags + triggers, plus workspace publish. Pattern matches the existing AdLoop safety model (draft + ChangePlan + confirm_and_apply).
Tools added (7)
draft_gtm_taggoogtag,gclidw,html,gaawe,awcc,awud,awcr,awct,fls,flc,ua,img). Auto-resolves Default Workspace whenworkspace_idis omitted.draft_gtm_triggerpageview,click,linkClick,formSubmission,customEvent,elementVisibility, etc.).draft_update_gtm_tagdraft_update_gtm_triggerdraft_delete_gtm_tagdraft_delete_gtm_triggerpublish_gtm_workspaceContainerVersion+ publish to live. The actual go-live moment, separated from drafting.Tag types accepted
The validator includes both Google's built-in template IDs (
googtag,awcc,awud,gaawe, …) and the verbose long-form aliases (google_ads_calls_from_website,google_ads_user_provided_data, …). Floodlight templates (flc,fls) are included for legacy advertisers.OAuth scopes
auth.pyextends_GTM_SCOPES(and_ALL_SCOPES) fromtagmanager.readonlyto also include:tagmanager.edit.containers(create/update/delete tags + triggers)tagmanager.edit.containerversions(compile workspace into a version)tagmanager.publish(set the version live)Migration: existing OAuth tokens issued under #29's read-only scope set will need to be deleted from
~/.adloop/token.jsonso the next API call triggers a re-auth that picks up the new scopes. Service-account auth needs the SA email upgraded from "Read" to "Publish" on the container in GTM Admin → User Management.Dispatch routing
A small block at the top of
_execute_planinads/write.pydetects GTM operations and routes them to the GTM apply handlers (which fetch their own GTM client instead of reusing the Google Ads client). The dispatch list:Workspace handling
GTM auto-creates a fresh "Default Workspace" after every publish; the old one becomes immutable (
Workspace is already submitted). The_resolve_workspace()helper grabs the current Default Workspace by name on every apply call so callers don't need to track which numeric workspace ID is currently active.Validated end-to-end
This batch was used to build out conversion tracking on a real GTM container (
GTM-T68KKNMK, BGI Tint):AW-11437481610), Conversion Linker, GFN Phone Snippet (awcctemplate), GA4 Event tags (phone_click,form_submit_contact,cta_buttons_click){Platform} - {Type} - {Event} - {Scope}convention)awcctemplateTest plan
tests/test_gtm_write.pycovers:Why no other modules are touched
This PR is intentionally scoped to just the GTM write surface. Conversion-action management, asset extension tools, and other Google Ads write tools live in separate PRs (#34) so the diff here stays focused and reviewable.