feat(gtm): write tools — create/update/delete tags + triggers + publish#35
Open
illia-sapryga wants to merge 3 commits intokLOsk:mainfrom
Open
feat(gtm): write tools — create/update/delete tags + triggers + publish#35illia-sapryga wants to merge 3 commits intokLOsk:mainfrom
illia-sapryga wants to merge 3 commits intokLOsk:mainfrom
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.