Skip to content

feat(gtm): write tools — create/update/delete tags + triggers + publish#35

Open
illia-sapryga wants to merge 3 commits intokLOsk:mainfrom
illia-sapryga:feat/gtm-write-tools
Open

feat(gtm): write tools — create/update/delete tags + triggers + publish#35
illia-sapryga wants to merge 3 commits intokLOsk:mainfrom
illia-sapryga:feat/gtm-write-tools

Conversation

@illia-sapryga
Copy link
Copy Markdown
Contributor

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 main shows #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)

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 is omitted.
draft_gtm_trigger Create trigger (pageview, click, linkClick, formSubmission, customEvent, elementVisibility, etc.).
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, …) 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.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)

Migration: existing OAuth tokens issued under #29's 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 routing

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.

Validated end-to-end

This batch was used to build out conversion tracking on a real GTM container (GTM-T68KKNMK, BGI Tint):

  • Created Google Tag (AW-11437481610), Conversion Linker, GFN Phone Snippet (awcc template), GA4 Event tags (phone_click, form_submit_contact, cta_buttons_click)
  • Updated tag names + parameters (renamed all tags + triggers to the Beaver Brothers {Platform} - {Type} - {Event} - {Scope} convention)
  • Deleted a Custom HTML tag (the old GFN snippet) and replaced with the dedicated awcc template
  • Published 4 container versions (8, 9, 10, 11)

Test plan

  • Full suite passes: 255/255 tests (PR feat(gtm): Tag Manager API integration with three-way tracking audit #29 baseline 234 + 21 new GTM-write tests)
  • tests/test_gtm_write.py covers:
    • 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
  • Service-account write path verified end-to-end against live container
  • OAuth scope expansion compatible with existing PR feat(gtm): Tag Manager API integration with three-way tracking audit #29 auth flow

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.

illia-sapryga and others added 3 commits April 30, 2026 00:46
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant