adloop 0.30.0__tar.gz → 0.31.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 (72) hide show
  1. {adloop-0.30.0 → adloop-0.31.0}/PKG-INFO +3 -2
  2. {adloop-0.30.0 → adloop-0.31.0}/README.md +2 -1
  3. {adloop-0.30.0 → adloop-0.31.0}/pyproject.toml +1 -1
  4. {adloop-0.30.0 → adloop-0.31.0}/pyproject.toml.orig +1 -1
  5. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gtm/write.py +233 -29
  6. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/adloop.md +4 -2
  7. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/server.py +80 -7
  8. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/__init__.py +0 -0
  9. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/__main__.py +0 -0
  10. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/_mcp_patches.py +0 -0
  11. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/__init__.py +0 -0
  12. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/ai_max.py +0 -0
  13. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/audiences.py +0 -0
  14. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/brands.py +0 -0
  15. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/client.py +0 -0
  16. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/conversion_actions.py +0 -0
  17. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/currency.py +0 -0
  18. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  19. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/enums.py +0 -0
  20. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/forecast.py +0 -0
  21. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/gaql.py +0 -0
  22. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/image_fetch.py +0 -0
  23. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/pmax.py +0 -0
  24. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/read.py +0 -0
  25. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/shopping.py +0 -0
  26. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/validate_only.py +0 -0
  27. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ads/write.py +0 -0
  28. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/auth.py +0 -0
  29. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/cli.py +0 -0
  30. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/config.py +0 -0
  31. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/crossref.py +0 -0
  32. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/diagnostics.py +0 -0
  33. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ga4/__init__.py +0 -0
  34. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ga4/client.py +0 -0
  35. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ga4/reports.py +0 -0
  36. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ga4/tracking.py +0 -0
  37. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/ga4/write.py +0 -0
  38. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gsc/__init__.py +0 -0
  39. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gsc/client.py +0 -0
  40. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gsc/reports.py +0 -0
  41. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gtm/__init__.py +0 -0
  42. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gtm/client.py +0 -0
  43. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gtm/entities.py +0 -0
  44. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/gtm/read.py +0 -0
  45. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/merchant/__init__.py +0 -0
  46. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/merchant/client.py +0 -0
  47. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/merchant/read.py +0 -0
  48. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/pagespeed.py +0 -0
  49. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/reddit/__init__.py +0 -0
  50. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/reddit/auth.py +0 -0
  51. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/reddit/client.py +0 -0
  52. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/reddit/read.py +0 -0
  53. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/reddit/schedule.py +0 -0
  54. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/reddit/write.py +0 -0
  55. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/__init__.py +0 -0
  56. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  57. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  58. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/commands/create-ad.md +0 -0
  59. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  60. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  61. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  62. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/rules_install.py +0 -0
  63. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/runtime.py +0 -0
  64. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/safety/__init__.py +0 -0
  65. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/safety/approval.py +0 -0
  66. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/safety/approval_card.html +0 -0
  67. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/safety/audit.py +0 -0
  68. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/safety/guards.py +0 -0
  69. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/safety/preview.py +0 -0
  70. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/tracking.py +0 -0
  71. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/upload_card.html +0 -0
  72. {adloop-0.30.0 → adloop-0.31.0}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.30.0
3
+ Version: 0.31.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -206,7 +206,8 @@ Off by default. Set `gtm.write_enabled: true` in `~/.adloop/config.yaml` and res
206
206
  |------|-------------|
207
207
  | `draft_gtm_tag` | Create a tag, or update one by `tag_id`. Updates are a read-modify-write: `parameters` merge by key and every field you don't pass (priority, consent settings, firing options, schedule, folder, …) is preserved. Tag types are canonical GTM template IDs (`googtag`, `gaawe`, `awct`, `sp`, `gclidw`, …) or `cvt_<id>` gallery templates. |
208
208
  | `draft_gtm_trigger` | Create a trigger, or update one by `trigger_id` (type is immutable). `custom_event_name` wires a `customEvent` trigger to a dataLayer event. |
209
- | `draft_delete_gtm_entity` | Delete a workspace tag or trigger. Triggers still referenced by a tag are refused up front, with the tags listed. |
209
+ | `draft_gtm_variable` | Create or update variables, e.g. Data Layer Variables for GA4 ecommerce values; renaming a referenced variable is refused, Custom JavaScript is gated |
210
+ | `draft_delete_gtm_entity` | Delete a workspace tag, trigger or variable. Triggers and variables still in use are refused up front, with the references listed. |
210
211
  | `draft_publish_gtm_workspace` | Publish a workspace live. The preview lists every pending change, including edits other people made in the GTM UI. |
211
212
  | `draft_rollback_gtm_version` | Republish an older container version live. The preview is the diff from the live version to the target; the plan pins the live version, so apply refuses if someone publishes in between. Workspaces are left as they are. |
212
213
 
@@ -178,7 +178,8 @@ Off by default. Set `gtm.write_enabled: true` in `~/.adloop/config.yaml` and res
178
178
  |------|-------------|
179
179
  | `draft_gtm_tag` | Create a tag, or update one by `tag_id`. Updates are a read-modify-write: `parameters` merge by key and every field you don't pass (priority, consent settings, firing options, schedule, folder, …) is preserved. Tag types are canonical GTM template IDs (`googtag`, `gaawe`, `awct`, `sp`, `gclidw`, …) or `cvt_<id>` gallery templates. |
180
180
  | `draft_gtm_trigger` | Create a trigger, or update one by `trigger_id` (type is immutable). `custom_event_name` wires a `customEvent` trigger to a dataLayer event. |
181
- | `draft_delete_gtm_entity` | Delete a workspace tag or trigger. Triggers still referenced by a tag are refused up front, with the tags listed. |
181
+ | `draft_gtm_variable` | Create or update variables, e.g. Data Layer Variables for GA4 ecommerce values; renaming a referenced variable is refused, Custom JavaScript is gated |
182
+ | `draft_delete_gtm_entity` | Delete a workspace tag, trigger or variable. Triggers and variables still in use are refused up front, with the references listed. |
182
183
  | `draft_publish_gtm_workspace` | Publish a workspace live. The preview lists every pending change, including edits other people made in the GTM UI. |
183
184
  | `draft_rollback_gtm_version` | Republish an older container version live. The preview is the diff from the live version to the target; the plan pins the live version, so apply refuses if someone publishes in between. Workspaces are left as they are. |
184
185
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.30.0"
3
+ version = "0.31.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.30.0"
3
+ version = "0.31.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -18,10 +18,10 @@ Three gates sit on top of that, all enforced here rather than advised:
18
18
  every page they fire on. Creating or editing one, or publishing a
19
19
  workspace (or rolling back to a version) that adds or changes one, is
20
20
  refused unless this is on. Pausing
21
- or deleting one is always allowed (both reduce what runs). The gate matches
22
- the tag type ``html``: a Custom JavaScript variable or a custom template
23
- tag (``cvt_…``), which can inject a script, is not covered and has to be
24
- reviewed in the GTM UI.
21
+ or deleting one is always allowed (both reduce what runs). Creating or
22
+ editing a Custom JavaScript variable (``jsm``) is gated the same way. A
23
+ custom template (``cvt_…``), which runs sandboxed code, is not covered
24
+ and has to be reviewed in the GTM UI.
25
25
  * Concurrency — every update/delete pins the entity fingerprint read at draft
26
26
  time, and publish pins a digest of the workspace's pending changes. A
27
27
  rollback pins the live version it replaces and the target's fingerprint. If
@@ -78,6 +78,25 @@ KNOWN_TRIGGER_TYPES = {
78
78
  "triggerGroup",
79
79
  }
80
80
 
81
+ # GTM API variable type values the draft accepts. Custom templates use
82
+ # ``cvt_<id>`` like tags. ``jsm`` (Custom JavaScript) runs code on the site
83
+ # and is gated by gtm.allow_custom_html.
84
+ KNOWN_VARIABLE_TYPES: dict[str, str] = {
85
+ "v": "Data Layer Variable",
86
+ "c": "Constant",
87
+ "u": "URL",
88
+ "k": "1st-Party Cookie",
89
+ "j": "JavaScript Variable (reads a global, runs no code)",
90
+ "aev": "Auto-Event Variable",
91
+ "d": "DOM Element",
92
+ "f": "HTTP Referrer",
93
+ "e": "Custom Event",
94
+ "smm": "Lookup Table",
95
+ "remm": "RegEx Table",
96
+ "jsm": "Custom JavaScript (gated by gtm.allow_custom_html)",
97
+ }
98
+ CUSTOM_JAVASCRIPT = "jsm"
99
+
81
100
  # Trigger request fields the draft tool can set, keyed by the tool's
82
101
  # snake_case argument name.
83
102
  _TRIGGER_FIELDS = {
@@ -314,25 +333,29 @@ def _summarize_changes(status: dict) -> list[dict]:
314
333
 
315
334
 
316
335
  def _custom_html_changes(status: dict) -> list[dict]:
317
- """Custom HTML tags the publish would add or change (deletes are fine)."""
318
- return [
319
- {"tag_id": c["tag"].get("tagId"), "name": c["tag"].get("name"),
320
- "change_status": c.get("changeStatus")}
321
- for c in status.get("workspaceChange", []) or []
322
- if "tag" in c
323
- and c["tag"].get("type") == CUSTOM_HTML
324
- and c.get("changeStatus") != "deleted"
325
- ]
336
+ """Custom HTML tags and Custom JavaScript variables the publish would add
337
+ or change (deletes are fine)."""
338
+ out = []
339
+ for c in status.get("workspaceChange", []) or []:
340
+ if c.get("changeStatus") == "deleted":
341
+ continue
342
+ if "tag" in c and c["tag"].get("type") == CUSTOM_HTML:
343
+ out.append({"tag_id": c["tag"].get("tagId"), "name": c["tag"].get("name"),
344
+ "change_status": c.get("changeStatus")})
345
+ elif "variable" in c and c["variable"].get("type") == CUSTOM_JAVASCRIPT:
346
+ out.append({"variable_id": c["variable"].get("variableId"), "name": c["variable"].get("name"),
347
+ "change_status": c.get("changeStatus")})
348
+ return out
326
349
 
327
350
 
328
351
  def _custom_html_pending_changes(pending: list[dict]) -> list[dict]:
329
352
  """The same check against a stored preview's summarized pending changes."""
330
353
  return [
331
- {"tag_id": c.get("entity_id"), "name": c.get("name"),
354
+ {"entity_kind": c.get("entity_kind"), "id": c.get("entity_id"), "name": c.get("name"),
332
355
  "change_status": c.get("change_status")}
333
356
  for c in pending
334
- if c.get("entity_kind") == "tag"
335
- and c.get("type") == CUSTOM_HTML
357
+ if ((c.get("entity_kind") == "tag" and c.get("type") == CUSTOM_HTML)
358
+ or (c.get("entity_kind") == "variable" and c.get("type") == CUSTOM_JAVASCRIPT))
336
359
  and c.get("change_status") != "deleted"
337
360
  ]
338
361
 
@@ -610,6 +633,152 @@ def draft_gtm_trigger(
610
633
  return _store(operation, "gtm_trigger", trigger_id, changes, warnings=warnings)
611
634
 
612
635
 
636
+ def _variable_references(ws, ws_path: str, name: str, *, skip_variable_id: str = "") -> list[dict]:
637
+ """Tags, triggers and variables whose fields mention {{name}}."""
638
+ if not name:
639
+ return []
640
+ needle = "{{" + name + "}}"
641
+ found = []
642
+ for kind, method, id_field in (("tag", ws.tags, "tagId"), ("trigger", ws.triggers, "triggerId"),
643
+ ("variable", ws.variables, "variableId")):
644
+ for entity in method().list(parent=ws_path).execute().get(kind, []) or []:
645
+ if kind == "variable" and str(entity.get(id_field)) == skip_variable_id:
646
+ continue
647
+ if needle in json.dumps(entity):
648
+ found.append({"kind": kind, "id": entity.get(id_field), "name": entity.get("name")})
649
+ return found
650
+
651
+
652
+ def draft_gtm_variable(
653
+ config: AdLoopConfig,
654
+ *,
655
+ account_id: str,
656
+ container_id: str,
657
+ variable_id: str = "",
658
+ workspace_id: str = "",
659
+ name: str = "",
660
+ variable_type: str = "",
661
+ data_layer_key: str = "",
662
+ default_value: str | None = None,
663
+ parameters: list[dict] | None = None,
664
+ notes: str | None = None,
665
+ ) -> dict:
666
+ """Create (no ``variable_id``) or update (``variable_id`` set) a variable."""
667
+ _require_writes(config)
668
+ operation = "gtm_update_variable" if variable_id else "gtm_create_variable"
669
+ blocked = _blocked(operation, config)
670
+ if blocked:
671
+ return blocked
672
+
673
+ errors: list[str] = _validate_parameters(parameters, "parameters")
674
+ if name and (len(name) > 200 or "{" in name or "}" in name):
675
+ errors.append("name must be 1-200 characters without curly braces")
676
+ if variable_id and variable_type:
677
+ errors.append("variable_type cannot be changed on an existing variable; delete it and create a new one")
678
+ if not variable_id:
679
+ if not name:
680
+ errors.append("name is required when creating a variable")
681
+ if variable_type not in KNOWN_VARIABLE_TYPES and not variable_type.startswith(_CUSTOM_TEMPLATE_PREFIX):
682
+ errors.append(
683
+ f"variable_type '{variable_type}' invalid; valid: "
684
+ + ", ".join(f"{k} ({v})" for k, v in KNOWN_VARIABLE_TYPES.items())
685
+ + ", or cvt_<id> for a custom template"
686
+ )
687
+ if variable_type == "v" and not data_layer_key and not any(
688
+ p.get("key") == "name" for p in parameters or []
689
+ ):
690
+ errors.append("data_layer_key is required for a Data Layer Variable, e.g. 'ecommerce.value'")
691
+ if (data_layer_key or default_value is not None) and variable_type and variable_type != "v":
692
+ errors.append("data_layer_key and default_value only apply to Data Layer Variables (variable_type='v')")
693
+ if variable_type == CUSTOM_JAVASCRIPT and not config.gtm.allow_custom_html:
694
+ return _custom_html_refusal("a Custom JavaScript variable")
695
+ if errors:
696
+ return {"error": "Validation failed", "details": errors}
697
+
698
+ # Data Layer Variable fields, from the shortcut arguments.
699
+ shortcut: list[dict] = []
700
+ if data_layer_key:
701
+ shortcut += [
702
+ {"type": "INTEGER", "key": "dataLayerVersion", "value": "2"},
703
+ {"type": "TEMPLATE", "key": "name", "value": data_layer_key},
704
+ ]
705
+ if default_value is not None:
706
+ shortcut += [
707
+ {"type": "BOOLEAN", "key": "setDefaultValue", "value": "true"},
708
+ {"type": "TEMPLATE", "key": "defaultValue", "value": default_value},
709
+ ]
710
+
711
+ client = _client(config)
712
+ ws_id, ws_name = _resolve_workspace(client, account_id, container_id, workspace_id)
713
+ base = {
714
+ "account_id": account_id,
715
+ "container_id": container_id,
716
+ "workspace_id": ws_id,
717
+ "workspace_name": ws_name,
718
+ }
719
+ ws = _workspaces(client)
720
+ warnings = [
721
+ f"This edits workspace '{ws_name}' only. Nothing changes on the site "
722
+ "until the workspace is published (draft_publish_gtm_workspace)."
723
+ ]
724
+ variables = ws.variables().list(parent=_ws_path(base)).execute().get("variable", []) or []
725
+
726
+ if not variable_id:
727
+ if any(v.get("name") == name for v in variables):
728
+ return {"error": f"A variable named '{name}' already exists in workspace '{ws_name}'; update it with its variable_id instead."}
729
+ body: dict[str, Any] = {
730
+ "name": name,
731
+ "type": variable_type,
732
+ "parameter": _merge_parameters(shortcut, list(parameters or [])),
733
+ }
734
+ if notes:
735
+ body["notes"] = notes
736
+ warnings.append(f"Tags and triggers use it as {{{{{name}}}}}.")
737
+ return _store(operation, "gtm_variable", ws_id, {**base, "variable": body}, warnings=warnings)
738
+
739
+ var_path = f"{_ws_path(base)}/variables/{variable_id}"
740
+ existing = ws.variables().get(path=var_path).execute()
741
+ if existing.get("type") == CUSTOM_JAVASCRIPT and not config.gtm.allow_custom_html:
742
+ return _custom_html_refusal("editing a Custom JavaScript variable")
743
+ if (data_layer_key or default_value is not None) and existing.get("type") != "v":
744
+ return {"error": "data_layer_key and default_value only apply to Data Layer Variables"}
745
+ patch: dict[str, Any] = {}
746
+ if name and name != existing.get("name"):
747
+ if any(v.get("name") == name for v in variables):
748
+ return {"error": f"A variable named '{name}' already exists in workspace '{ws_name}'."}
749
+ users = _variable_references(ws, _ws_path(base), existing.get("name", ""), skip_variable_id=str(variable_id))
750
+ if users:
751
+ return {
752
+ "error": (
753
+ f"Variable '{existing.get('name')}' is referenced by {len(users)} tag(s), "
754
+ "trigger(s) or variable(s), and renaming it through the API would break those "
755
+ "{{…}} references. Rename it in the GTM UI, which updates them."
756
+ ),
757
+ "referenced_by": users,
758
+ }
759
+ patch["name"] = name
760
+ updates = shortcut + list(parameters or [])
761
+ if updates:
762
+ patch["parameter"] = _merge_parameters(existing.get("parameter", []), updates)
763
+ if notes is not None:
764
+ patch["notes"] = notes
765
+ patch = {k: v for k, v in patch.items() if existing.get(k) != v}
766
+ if not patch:
767
+ return {"error": "No changes: every field passed already matches the variable."}
768
+
769
+ changes = {
770
+ **base,
771
+ "variable_id": str(variable_id),
772
+ "variable_name": existing.get("name"),
773
+ "variable_type": existing.get("type"),
774
+ "fingerprint": existing.get("fingerprint"),
775
+ "patch": patch,
776
+ "before": {k: existing.get(k) for k in patch},
777
+ }
778
+ warnings.append("Every tag and trigger that uses this variable picks up the new value.")
779
+ return _store(operation, "gtm_variable", variable_id, changes, warnings=warnings)
780
+
781
+
613
782
  def draft_delete_gtm_entity(
614
783
  config: AdLoopConfig,
615
784
  *,
@@ -619,10 +788,10 @@ def draft_delete_gtm_entity(
619
788
  entity_id: str,
620
789
  workspace_id: str = "",
621
790
  ) -> dict:
622
- """Delete a workspace tag or trigger (``entity_type`` = tag | trigger)."""
791
+ """Delete a workspace tag, trigger or variable."""
623
792
  _require_writes(config)
624
- if entity_type not in ("tag", "trigger"):
625
- return {"error": "entity_type must be 'tag' or 'trigger'"}
793
+ if entity_type not in ("tag", "trigger", "variable"):
794
+ return {"error": "entity_type must be 'tag', 'trigger' or 'variable'"}
626
795
  operation = f"gtm_delete_{entity_type}"
627
796
  blocked = _blocked(operation, config)
628
797
  if blocked:
@@ -639,9 +808,22 @@ def draft_delete_gtm_entity(
639
808
  "workspace_name": ws_name,
640
809
  }
641
810
  ws = _workspaces(client)
642
- api = ws.tags() if entity_type == "tag" else ws.triggers()
811
+ api = getattr(ws, f"{entity_type}s")()
643
812
  existing = api.get(path=f"{_ws_path(base)}/{entity_type}s/{entity_id}").execute()
644
813
 
814
+ if entity_type == "variable":
815
+ users = _variable_references(ws, _ws_path(base), existing.get("name", ""), skip_variable_id=str(entity_id))
816
+ if users:
817
+ return {
818
+ "error": (
819
+ f"Variable '{existing.get('name')}' is referenced by {len(users)} "
820
+ "tag(s), trigger(s) or variable(s); deleting it would break "
821
+ "them. Remove the {{" + str(existing.get("name")) + "}} "
822
+ "references first."
823
+ ),
824
+ "referenced_by": users,
825
+ }
826
+
645
827
  if entity_type == "trigger":
646
828
  # GTM refuses to delete a referenced trigger; say which tags up front
647
829
  # instead of letting apply fail with a bare 400.
@@ -754,13 +936,19 @@ def draft_publish_gtm_workspace(
754
936
 
755
937
 
756
938
  def _custom_html_in_diff(diff: dict) -> list[dict]:
757
- """Custom HTML tags a version change would add or change (removals are fine)."""
758
- tags = diff.get("tags", {})
939
+ """Custom HTML tags and Custom JavaScript variables a version change would
940
+ add or change (removals are fine)."""
941
+ tags, variables = diff.get("tags", {}), diff.get("variables", {})
759
942
  return [
760
943
  {"tag_id": t.get("id"), "name": t.get("name"), "change_status": status}
761
944
  for status in ("added", "changed")
762
945
  for t in tags.get(status, [])
763
946
  if t.get("type") == CUSTOM_HTML
947
+ ] + [
948
+ {"variable_id": v.get("id"), "name": v.get("name"), "change_status": status}
949
+ for status in ("added", "changed")
950
+ for v in variables.get(status, [])
951
+ if v.get("type") == CUSTOM_JAVASCRIPT
764
952
  ]
765
953
 
766
954
 
@@ -945,13 +1133,17 @@ def _check_target_unchanged(client, plan: ChangePlan) -> dict:
945
1133
  return target
946
1134
 
947
1135
 
1136
+ def _entity_kind(plan: ChangePlan) -> str:
1137
+ return plan.entity_type.replace("gtm_", "")
1138
+
1139
+
948
1140
  def _entity_api(client, plan: ChangePlan):
949
1141
  ws = _workspaces(client)
950
- return ws.tags() if plan.entity_type == "gtm_tag" else ws.triggers()
1142
+ return getattr(ws, f"{_entity_kind(plan)}s")()
951
1143
 
952
1144
 
953
1145
  def _entity_path(plan: ChangePlan) -> str:
954
- kind = "tag" if plan.entity_type == "gtm_tag" else "trigger"
1146
+ kind = _entity_kind(plan)
955
1147
  return f"{_ws_path(plan.changes)}/{kind}s/{plan.changes[f'{kind}_id']}"
956
1148
 
957
1149
 
@@ -974,6 +1166,10 @@ def _check_gates(config: AdLoopConfig, plan: ChangePlan) -> None:
974
1166
  elif op == "gtm_update_tag":
975
1167
  html = (changes.get("tag_type") == CUSTOM_HTML
976
1168
  and changes["patch"] != {"paused": True})
1169
+ elif op == "gtm_create_variable":
1170
+ html = changes["variable"].get("type") == CUSTOM_JAVASCRIPT
1171
+ elif op == "gtm_update_variable":
1172
+ html = changes.get("variable_type") == CUSTOM_JAVASCRIPT
977
1173
  else:
978
1174
  html = op == "gtm_create_tag" and changes["tag"].get("type") == CUSTOM_HTML
979
1175
  if html:
@@ -997,7 +1193,7 @@ def preflight(config: AdLoopConfig, plan: ChangePlan) -> dict:
997
1193
  changes = plan.changes
998
1194
  checks: dict[str, Any] = {"workspace": changes.get("workspace_name")}
999
1195
  try:
1000
- if plan.operation in ("gtm_create_tag", "gtm_create_trigger"):
1196
+ if plan.operation in ("gtm_create_tag", "gtm_create_trigger", "gtm_create_variable"):
1001
1197
  _workspaces(client).get(path=_ws_path(changes)).execute()
1002
1198
  checks["workspace_exists"] = True
1003
1199
  elif plan.operation == "gtm_publish_workspace":
@@ -1082,7 +1278,15 @@ def _apply(config: AdLoopConfig, plan: ChangePlan) -> dict:
1082
1278
  "type": resp.get("type"), "workspace_id": changes["workspace_id"],
1083
1279
  "published": False}
1084
1280
 
1085
- if op in ("gtm_update_tag", "gtm_update_trigger"):
1281
+ if op == "gtm_create_variable":
1282
+ resp = ws.variables().create(
1283
+ parent=_ws_path(changes), body=changes["variable"]
1284
+ ).execute()
1285
+ return {"variable_id": resp.get("variableId"), "name": resp.get("name"),
1286
+ "type": resp.get("type"), "workspace_id": changes["workspace_id"],
1287
+ "published": False}
1288
+
1289
+ if op in ("gtm_update_tag", "gtm_update_trigger", "gtm_update_variable"):
1086
1290
  api = _entity_api(client, plan)
1087
1291
  path = _entity_path(plan)
1088
1292
  current = _check_fingerprint(api, path, changes.get("fingerprint"),
@@ -1094,18 +1298,18 @@ def _apply(config: AdLoopConfig, plan: ChangePlan) -> dict:
1094
1298
  body.update(copy.deepcopy(changes["patch"]))
1095
1299
  resp = api.update(path=path, body=body,
1096
1300
  fingerprint=current.get("fingerprint")).execute()
1097
- kind = "tag" if plan.entity_type == "gtm_tag" else "trigger"
1301
+ kind = _entity_kind(plan)
1098
1302
  return {f"{kind}_id": resp.get(f"{kind}Id"), "name": resp.get("name"),
1099
1303
  "updated_fields": sorted(changes["patch"]),
1100
1304
  "workspace_id": changes["workspace_id"], "published": False}
1101
1305
 
1102
- if op in ("gtm_delete_tag", "gtm_delete_trigger"):
1306
+ if op in ("gtm_delete_tag", "gtm_delete_trigger", "gtm_delete_variable"):
1103
1307
  api = _entity_api(client, plan)
1104
1308
  path = _entity_path(plan)
1105
1309
  _check_fingerprint(api, path, changes.get("fingerprint"),
1106
1310
  plan.entity_type.replace("gtm_", ""))
1107
1311
  api.delete(path=path).execute()
1108
- kind = "tag" if plan.entity_type == "gtm_tag" else "trigger"
1312
+ kind = _entity_kind(plan)
1109
1313
  return {f"deleted_{kind}_id": changes[f"{kind}_id"],
1110
1314
  "workspace_id": changes["workspace_id"], "published": False}
1111
1315
 
@@ -131,14 +131,16 @@ These tools call both APIs internally and return unified results with computed `
131
131
  |------|-------------|----------------|
132
132
  | `draft_gtm_tag` | Create a tag (no `tag_id`) or update one (`tag_id`) in a workspace. On update, `parameters` merge by key and every field you don't pass is preserved | `name`, `tag_type` (create only: `googtag`, `gaawe`, `awct`, `awcc`, `awud`, `sp`, `gclidw`, `flc`, `fls`, `img`, `html`, `cvt_<id>`), `parameters`, `firing_trigger_ids`, `blocking_trigger_ids`, `paused` |
133
133
  | `draft_gtm_trigger` | Create a trigger (no `trigger_id`) or update one | `name`, `trigger_type` (camelCase API values: `pageview`, `domReady`, `click`, `linkClick`, `formSubmission`, `customEvent`, `elementVisibility`, …), `custom_event_name`, `filters` |
134
- | `draft_delete_gtm_entity` | Delete a workspace tag or trigger. Referenced triggers are refused with the referencing tags listed | `entity_type` (`tag`/`trigger`), `entity_id` |
134
+ | `draft_gtm_variable` | Create a variable (no `variable_id`) or update one. Data Layer Variables take `data_layer_key` (e.g. `ecommerce.value`); renaming a variable that tags or triggers use as `{{name}}` is refused | `name`, `variable_type` (`v` Data Layer, `c` Constant, `u` URL, `k` cookie, `j` JS global, `aev`, `d`, `smm`, `remm`, `jsm` gated, `cvt_<id>`), `data_layer_key`, `default_value`, `parameters` |
135
+ | `draft_delete_gtm_entity` | Delete a workspace tag, trigger or variable. Referenced triggers and variables are refused with the references listed | `entity_type` (`tag`/`trigger`/`variable`), `entity_id` |
135
136
  | `draft_publish_gtm_workspace` | Publish a workspace LIVE. Preview lists every pending change — including other people's UI edits | `version_name`, `version_notes`, `workspace_id` |
136
137
  | `draft_rollback_gtm_version` | Republish an older container version LIVE. Preview is the diff from the live version to the target; destructive, double confirmation | `version_id` |
137
138
 
138
139
  **GTM write rules:**
139
140
  - Writes are off unless the user sets `gtm.write_enabled: true`; enabling it asks for re-consent with the Tag Manager edit + publish scopes. The Google account also needs Edit (and Publish) permission on the container.
140
141
  - Tag/trigger drafts only change a **workspace**. Nothing changes on the site until `draft_publish_gtm_workspace` is applied — say so when presenting a tag preview.
141
- - **Custom HTML (`html`) is gated** by `gtm.allow_custom_html`: creating/editing one, or publishing a workspace that adds/changes one, is refused while it is off. Prefer a built-in template. Pausing or deleting an HTML tag is always allowed. The gate matches the tag type `html` only — a Custom JavaScript variable or a custom template tag (`cvt_…`), which can inject a script, is not covered and has to be reviewed in the GTM UI.
142
+ - **Custom HTML (`html`) is gated** by `gtm.allow_custom_html`: creating/editing one, or publishing a workspace that adds/changes one, is refused while it is off. Prefer a built-in template. Pausing or deleting an HTML tag is always allowed. Custom JavaScript variables (`jsm`) are gated the same way, including when a publish or rollback would add or change one. A custom template (`cvt_…`) runs sandboxed code and is not covered; review it in the GTM UI.
143
+ - **GA4 purchase events:** read the purchase `dataLayer.push` in the codebase, create one Data Layer Variable per value the event needs (`draft_gtm_variable` with `data_layer_key` such as `ecommerce.transaction_id`, `ecommerce.value`, `ecommerce.currency`, `ecommerce.items`), a `customEvent` trigger for the `purchase` event, then a `gaawe` tag that sends them as `{{DLV - …}}` parameters. Check `list_gtm_variables` first: reuse a variable that reads the same key instead of creating a duplicate.
142
144
  - Always read before writing: `list_gtm_tags` / `list_gtm_triggers` for IDs, `get_gtm_workspace_diff` before publishing. Present the publish preview's `pending_changes` in full — the user is approving all of them, not just the ones AdLoop drafted.
143
145
  - Updates and deletes pin the entity fingerprint and publish pins the workspace state; if someone edits the container after the preview, apply refuses. Re-draft rather than retrying.
144
146
  - The dry run for a publish runs a Tag Manager quick preview, so compiler errors show up before a version exists. The apply result names the version it replaced (`previous_live_version_id`) — that is the rollback, and if publishing a created version failed, the error carries both ids.
@@ -2547,7 +2547,79 @@ def draft_gtm_trigger(
2547
2547
  )
2548
2548
 
2549
2549
 
2550
- @_tool(title="Draft deleting a Tag Manager tag or trigger", annotations=_DESTRUCTIVE, tags={"gtm"})
2550
+ @_tool(title="Draft a Tag Manager variable", annotations=_WRITE, tags={"gtm"})
2551
+ @_safe
2552
+ def draft_gtm_variable(
2553
+ name: str = "",
2554
+ variable_type: str = "",
2555
+ data_layer_key: str = "",
2556
+ default_value: str | None = None,
2557
+ parameters: _DictListOpt = None,
2558
+ notes: str | None = None,
2559
+ variable_id: str = "",
2560
+ workspace_id: str = "",
2561
+ gtm_account_id: str = "",
2562
+ gtm_container_id: str = "",
2563
+ ) -> dict:
2564
+ """Draft creating or updating a Tag Manager variable: returns a PREVIEW.
2565
+
2566
+ Without variable_id this creates a variable in a workspace; with
2567
+ variable_id it updates the existing one. Data Layer Variables, the usual
2568
+ source for GA4 ecommerce values, take data_layer_key (e.g.
2569
+ "ecommerce.value") instead of raw parameters. Tags and triggers use a
2570
+ variable as {{name}}; renaming one they reference is refused, because
2571
+ the references would break (the GTM UI renames safely). Custom
2572
+ JavaScript variables run code on the site and are refused unless
2573
+ gtm.allow_custom_html is set. The edit lands in the workspace and
2574
+ changes nothing on the site until the workspace is published. Refused
2575
+ unless gtm.write_enabled is set.
2576
+
2577
+ Args:
2578
+ name: Variable name, 1-200 characters without curly braces.
2579
+ Required when creating a variable.
2580
+ variable_type: Required when creating, fixed afterwards: v (Data
2581
+ Layer Variable), c (Constant), u (URL), k (1st-party cookie), j
2582
+ (JavaScript Variable, reads a global), aev (Auto-Event
2583
+ Variable), d (DOM element), f (HTTP referrer), e (Custom event),
2584
+ smm (Lookup table), remm (RegEx table), jsm (Custom JavaScript,
2585
+ gated) or cvt_<id> for a custom template.
2586
+ data_layer_key: For Data Layer Variables: the dataLayer key to read,
2587
+ dot notation for nested values, e.g. "ecommerce.transaction_id".
2588
+ default_value: For Data Layer Variables: the value when the key is
2589
+ missing. None sets no default.
2590
+ parameters: GTM parameter dicts with at least "type" and "key" for
2591
+ the other types, e.g. a Constant's [{"type": "TEMPLATE", "key":
2592
+ "value", "value": "G-ABC123"}]. On update they merge by key.
2593
+ notes: Free-form note stored on the variable. None keeps the current
2594
+ note.
2595
+ variable_id: Existing variable ID to update (see list_gtm_variables).
2596
+ Empty creates a new variable.
2597
+ workspace_id: Workspace to edit (see list_gtm_workspaces). Empty uses
2598
+ the Default Workspace, or the only workspace there is.
2599
+ gtm_account_id: Numeric GTM account ID (see list_gtm_accounts). Empty
2600
+ uses gtm.account_id from the config.
2601
+ gtm_container_id: Numeric GTM container ID (see list_gtm_containers).
2602
+ Empty uses gtm.container_id from the config.
2603
+ """
2604
+ from adloop.gtm.write import draft_gtm_variable as _impl
2605
+
2606
+ gtm_account_id, gtm_container_id = _gtm_defaults(gtm_account_id, gtm_container_id)
2607
+ return _impl(
2608
+ current_config(),
2609
+ account_id=gtm_account_id,
2610
+ container_id=gtm_container_id,
2611
+ variable_id=variable_id,
2612
+ workspace_id=workspace_id,
2613
+ name=name,
2614
+ variable_type=variable_type,
2615
+ data_layer_key=data_layer_key,
2616
+ default_value=default_value,
2617
+ parameters=parameters,
2618
+ notes=notes,
2619
+ )
2620
+
2621
+
2622
+ @_tool(title="Draft deleting a Tag Manager tag, trigger or variable", annotations=_DESTRUCTIVE, tags={"gtm"})
2551
2623
  @_safe
2552
2624
  def draft_delete_gtm_entity(
2553
2625
  entity_type: str,
@@ -2556,18 +2628,19 @@ def draft_delete_gtm_entity(
2556
2628
  gtm_account_id: str = "",
2557
2629
  gtm_container_id: str = "",
2558
2630
  ) -> dict:
2559
- """Draft deleting a Tag Manager tag or trigger — returns a PREVIEW.
2631
+ """Draft deleting a Tag Manager tag, trigger or variable — returns a PREVIEW.
2560
2632
 
2561
2633
  The entity is removed from a workspace and stays on the site until the
2562
- workspace is published. A trigger that any tag still references is
2563
- refused up front, with the referencing tags named. Refused unless
2634
+ workspace is published. A trigger that any tag still references, or a
2635
+ variable that a tag, trigger or other variable uses as {{name}}, is
2636
+ refused up front, with the references named. Refused unless
2564
2637
  gtm.write_enabled is set. Returns the entity that would be deleted, the
2565
2638
  resolved workspace and a plan_id.
2566
2639
 
2567
2640
  Args:
2568
- entity_type: "tag" or "trigger".
2569
- entity_id: Numeric ID of the entity (see list_gtm_tags and
2570
- list_gtm_triggers).
2641
+ entity_type: "tag", "trigger" or "variable".
2642
+ entity_id: Numeric ID of the entity (see list_gtm_tags,
2643
+ list_gtm_triggers and list_gtm_variables).
2571
2644
  workspace_id: Workspace to edit (see list_gtm_workspaces). Empty uses
2572
2645
  the Default Workspace, or the only workspace there is.
2573
2646
  gtm_account_id: Numeric GTM account ID (see list_gtm_accounts). Empty
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes