adloop 0.31.0__tar.gz → 0.32.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.31.0 → adloop-0.32.0}/PKG-INFO +1 -1
  2. {adloop-0.31.0 → adloop-0.32.0}/pyproject.toml +1 -1
  3. {adloop-0.31.0 → adloop-0.32.0}/pyproject.toml.orig +1 -1
  4. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/write.py +142 -17
  5. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/adloop.md +3 -3
  6. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/approval_card.html +13 -3
  7. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/server.py +9 -8
  8. {adloop-0.31.0 → adloop-0.32.0}/README.md +0 -0
  9. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/__init__.py +0 -0
  10. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/__main__.py +0 -0
  11. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/_mcp_patches.py +0 -0
  12. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/__init__.py +0 -0
  13. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/ai_max.py +0 -0
  14. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/audiences.py +0 -0
  15. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/brands.py +0 -0
  16. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/client.py +0 -0
  17. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/conversion_actions.py +0 -0
  18. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/currency.py +0 -0
  19. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  20. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/image_fetch.py +0 -0
  24. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/pmax.py +0 -0
  25. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/read.py +0 -0
  26. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/shopping.py +0 -0
  27. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ads/validate_only.py +0 -0
  28. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/auth.py +0 -0
  29. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/cli.py +0 -0
  30. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/config.py +0 -0
  31. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/crossref.py +0 -0
  32. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/diagnostics.py +0 -0
  33. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/__init__.py +0 -0
  34. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/client.py +0 -0
  35. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/reports.py +0 -0
  36. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/tracking.py +0 -0
  37. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/ga4/write.py +0 -0
  38. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gsc/__init__.py +0 -0
  39. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gsc/client.py +0 -0
  40. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gsc/reports.py +0 -0
  41. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/__init__.py +0 -0
  42. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/client.py +0 -0
  43. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/entities.py +0 -0
  44. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/read.py +0 -0
  45. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/gtm/write.py +0 -0
  46. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/merchant/__init__.py +0 -0
  47. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/merchant/client.py +0 -0
  48. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/merchant/read.py +0 -0
  49. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/pagespeed.py +0 -0
  50. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/__init__.py +0 -0
  51. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/auth.py +0 -0
  52. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/client.py +0 -0
  53. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/read.py +0 -0
  54. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/schedule.py +0 -0
  55. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/reddit/write.py +0 -0
  56. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/__init__.py +0 -0
  57. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  58. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  59. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/create-ad.md +0 -0
  60. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  61. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  62. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  63. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/rules_install.py +0 -0
  64. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/runtime.py +0 -0
  65. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/__init__.py +0 -0
  66. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/approval.py +0 -0
  67. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/audit.py +0 -0
  68. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/guards.py +0 -0
  69. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/safety/preview.py +0 -0
  70. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/tracking.py +0 -0
  71. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/upload_card.html +0 -0
  72. {adloop-0.31.0 → adloop-0.32.0}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.31.0
3
+ Version: 0.32.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.31.0"
3
+ version = "0.32.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.31.0"
3
+ version = "0.32.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 = [
@@ -550,9 +550,10 @@ def draft_responsive_search_ad(
550
550
  headlines: HEADLINE_1, HEADLINE_2, HEADLINE_3
551
551
  descriptions: DESCRIPTION_1, DESCRIPTION_2
552
552
 
553
- Google caps: at most 2 headlines per pin slot, at most 1 description per
554
- pin slot. Mixed plain-string and dict entries are allowed within a single
555
- call (e.g. brand pinned to HEADLINE_1, the rest unpinned).
553
+ Several assets may pin to the same position (Google rotates them there),
554
+ as long as every position stays fillable: by an asset pinned to it or by
555
+ a distinct unpinned asset. Mixed plain-string and dict entries are allowed
556
+ within a single call (e.g. brand pinned to HEADLINE_1, the rest unpinned).
556
557
  """
557
558
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
558
559
  from adloop.safety.preview import ChangePlan, store_plan
@@ -2055,12 +2056,19 @@ def remove_entity(
2055
2056
  if entity_type in ("campaign_asset", "customer_asset"):
2056
2057
  entity_id = entity_id.replace(",", "~")
2057
2058
 
2059
+ names, not_found = _describe_target(config, customer_id, entity_type, entity_id)
2060
+ if not_found:
2061
+ return {"error": not_found}
2062
+
2058
2063
  plan = ChangePlan(
2059
2064
  operation="remove_entity",
2060
2065
  entity_type=entity_type,
2061
2066
  entity_id=entity_id,
2062
2067
  customer_id=customer_id,
2063
- changes={"action": "REMOVE"},
2068
+ changes={"action": "REMOVE", **names, **(
2069
+ {"warnings": ["This entity is already REMOVED; applying changes nothing."]}
2070
+ if names.get("current_status") == "REMOVED" else {}
2071
+ )},
2064
2072
  # Removal is irreversible; the preview asks for a second explicit
2065
2073
  # confirmation, as Reddit removals already did.
2066
2074
  requires_double_confirm=True,
@@ -3457,7 +3465,6 @@ def _validate_rsa_assets(
3457
3465
  if len(descriptions) > 4:
3458
3466
  errors.append(f"Maximum 4 descriptions, got {len(descriptions)}")
3459
3467
 
3460
- headline_pin_counts: dict[str, int] = {}
3461
3468
  for i, h in enumerate(headlines):
3462
3469
  text = h["text"]
3463
3470
  pin = h["pinned_field"]
@@ -3472,13 +3479,8 @@ def _validate_rsa_assets(
3472
3479
  f"Headline {i + 1} pinned_field '{pin}' invalid; "
3473
3480
  f"must be one of {sorted(_VALID_HEADLINE_PINS)} or null"
3474
3481
  )
3475
- else:
3476
- headline_pin_counts[pin] = headline_pin_counts.get(pin, 0) + 1
3477
- for pin, count in headline_pin_counts.items():
3478
- if count > 2:
3479
- errors.append(f"At most 2 headlines may pin to {pin}; got {count}")
3482
+ errors.extend(_pin_fill_errors("headline", headlines, _VALID_HEADLINE_PINS))
3480
3483
 
3481
- description_pin_counts: dict[str, int] = {}
3482
3484
  for i, d in enumerate(descriptions):
3483
3485
  text = d["text"]
3484
3486
  pin = d["pinned_field"]
@@ -3493,15 +3495,37 @@ def _validate_rsa_assets(
3493
3495
  f"Description {i + 1} pinned_field '{pin}' invalid; "
3494
3496
  f"must be one of {sorted(_VALID_DESCRIPTION_PINS)} or null"
3495
3497
  )
3496
- else:
3497
- description_pin_counts[pin] = description_pin_counts.get(pin, 0) + 1
3498
- for pin, count in description_pin_counts.items():
3499
- if count > 1:
3500
- errors.append(f"At most 1 description may pin to {pin}; got {count}")
3498
+ errors.extend(_pin_fill_errors("description", descriptions, _VALID_DESCRIPTION_PINS))
3501
3499
 
3502
3500
  return errors
3503
3501
 
3504
3502
 
3503
+ def _pin_fill_errors(kind: str, assets: list[dict], positions: set[str]) -> list[str]:
3504
+ """Google's pinning rule: every position must be fillable, either by an
3505
+ asset pinned to it or by a distinct unpinned asset.
3506
+
3507
+ Google sets no cap on how many assets pin to one position (they rotate
3508
+ there), so several headlines pinned to HEADLINE_1 are fine as long as the
3509
+ other positions can still be filled. Breaking the rule fails at Google with
3510
+ NOT_ENOUGH_AVAILABLE_ASSET_LINKS_FOR_VALID_COMBINATION.
3511
+ """
3512
+ if not assets:
3513
+ return []
3514
+ pinned = {a["pinned_field"] for a in assets if a.get("pinned_field") in positions}
3515
+ if not pinned:
3516
+ return []
3517
+ empty = sorted(positions - pinned)
3518
+ unpinned = sum(1 for a in assets if not a.get("pinned_field"))
3519
+ if unpinned >= len(empty):
3520
+ return []
3521
+ return [
3522
+ f"Pinning leaves {', '.join(empty)} without enough {kind}s: "
3523
+ f"{len(empty)} position(s) have no pinned {kind} and need one distinct "
3524
+ f"unpinned {kind} each, but only {unpinned} {kind}(s) are unpinned. "
3525
+ f"Unpin a {kind}, add more unpinned ones, or pin one to each empty position."
3526
+ ]
3527
+
3528
+
3505
3529
  def _validate_rsa(
3506
3530
  ad_group_id: str,
3507
3531
  headlines: list[dict],
@@ -3815,6 +3839,98 @@ def _preflight_ad_group_checks(
3815
3839
  return errors, warnings
3816
3840
 
3817
3841
 
3842
+ # What the preview of a pause, enable or remove names besides the IDs:
3843
+ # (query resource, id fields, name field, extra fields) per entity type. The
3844
+ # names reach the approval card and the confirmation dialog, where an ID alone
3845
+ # is hard to check, and a lookup that finds nothing exposes a mistyped ID.
3846
+ _TARGET_LOOKUPS: dict[str, tuple[str, tuple[str, ...], str, dict[str, str]]] = {
3847
+ "campaign": ("campaign", ("campaign.id",), "campaign.name",
3848
+ {"current_status": "campaign.status"}),
3849
+ "ad_group": ("ad_group", ("ad_group.id",), "ad_group.name",
3850
+ {"campaign_name": "campaign.name", "current_status": "ad_group.status"}),
3851
+ "ad": ("ad_group_ad", ("ad_group_ad.ad.id",), "ad_group_ad.ad.name",
3852
+ {"ad_group_name": "ad_group.name", "campaign_name": "campaign.name",
3853
+ "current_status": "ad_group_ad.status"}),
3854
+ "keyword": ("ad_group_criterion", ("ad_group.id", "ad_group_criterion.criterion_id"),
3855
+ "ad_group_criterion.keyword.text",
3856
+ {"match_type": "ad_group_criterion.keyword.match_type",
3857
+ "ad_group_name": "ad_group.name", "campaign_name": "campaign.name",
3858
+ "current_status": "ad_group_criterion.status"}),
3859
+ "asset_group": ("asset_group", ("asset_group.id",), "asset_group.name",
3860
+ {"campaign_name": "campaign.name", "current_status": "asset_group.status"}),
3861
+ "negative_keyword": ("campaign_criterion", ("campaign.id", "campaign_criterion.criterion_id"),
3862
+ "campaign_criterion.keyword.text", {"campaign_name": "campaign.name"}),
3863
+ "campaign_criterion": ("campaign_criterion", ("campaign.id", "campaign_criterion.criterion_id"),
3864
+ "campaign_criterion.type", {"campaign_name": "campaign.name"}),
3865
+ "ad_group_criterion": ("ad_group_criterion", ("ad_group.id", "ad_group_criterion.criterion_id"),
3866
+ "ad_group_criterion.type",
3867
+ {"ad_group_name": "ad_group.name", "campaign_name": "campaign.name"}),
3868
+ }
3869
+
3870
+
3871
+ def _format_customer(customer_id: object) -> str:
3872
+ digits = str(customer_id or "").replace("-", "")
3873
+ return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" if len(digits) == 10 else digits
3874
+
3875
+
3876
+ def _describe_target(
3877
+ config: AdLoopConfig, customer_id: str, entity_type: str, entity_id: str
3878
+ ) -> tuple[dict, str | None]:
3879
+ """Names for a status change or removal preview: ``({names}, error)``.
3880
+
3881
+ One GAQL query reads the account name and, for the entity types above,
3882
+ the entity's name, its campaign or ad group and its current status. A
3883
+ failed lookup (API error, an ID format this does not know) returns no
3884
+ names and no error, so the preview falls back to the IDs. A lookup that
3885
+ succeeds but finds no such entity returns an error: the ID is wrong.
3886
+ """
3887
+ from adloop.ads.gaql import execute_query
3888
+
3889
+ spec = _TARGET_LOOKUPS.get(entity_type)
3890
+ parts = entity_id.split("~")
3891
+ if entity_type == "ad" and len(parts) == 2:
3892
+ parts = parts[1:] # adGroupId~adId: the ad id alone identifies it
3893
+ if spec and (len(parts) != len(spec[1]) or not all(p.isdigit() for p in parts)):
3894
+ spec = None
3895
+ try:
3896
+ if spec:
3897
+ resource, id_fields, name_field, extra = spec
3898
+ fields = ["customer.descriptive_name", "customer.id", name_field, *extra.values()]
3899
+ where = " AND ".join(f"{f} = {v}" for f, v in zip(id_fields, parts))
3900
+ rows = execute_query(
3901
+ config, customer_id,
3902
+ f"SELECT {', '.join(fields)} FROM {resource} WHERE {where} LIMIT 1",
3903
+ )
3904
+ else:
3905
+ rows = execute_query(
3906
+ config, customer_id, "SELECT customer.descriptive_name, customer.id FROM customer LIMIT 1"
3907
+ )
3908
+ except Exception:
3909
+ return {}, None
3910
+ if not rows:
3911
+ if spec:
3912
+ return {}, (
3913
+ f"No {entity_type.replace('_', ' ')} with ID {entity_id} in account "
3914
+ f"{_format_customer(customer_id) or 'the default account'}. Check the ID "
3915
+ "with a read tool (get_campaign_performance, get_ad_performance, ...)."
3916
+ )
3917
+ return {}, None
3918
+ row = rows[0]
3919
+ names: dict = {}
3920
+ account = row.get("customer.descriptive_name") or ""
3921
+ cid = _format_customer(row.get("customer.id"))
3922
+ if account or cid:
3923
+ names["account"] = f"{account} ({cid})" if account and cid else account or cid
3924
+ if spec:
3925
+ name = row.get(spec[2])
3926
+ if name:
3927
+ names["entity_name"] = str(name)
3928
+ for key, field_name in spec[3].items():
3929
+ if row.get(field_name) not in (None, ""):
3930
+ names[key] = str(row[field_name])
3931
+ return names, None
3932
+
3933
+
3818
3934
  def _draft_status_change(
3819
3935
  config: AdLoopConfig,
3820
3936
  operation: str,
@@ -3841,12 +3957,21 @@ def _draft_status_change(
3841
3957
  if errors:
3842
3958
  return {"error": "Validation failed", "details": errors}
3843
3959
 
3960
+ names, not_found = _describe_target(config, customer_id, entity_type, entity_id)
3961
+ if not_found:
3962
+ return {"error": not_found}
3963
+ changes: dict = {"target_status": target_status, **names}
3964
+ if names.get("current_status") == target_status:
3965
+ changes["warnings"] = [
3966
+ f"This {entity_type.replace('_', ' ')} is already {target_status}; applying changes nothing."
3967
+ ]
3968
+
3844
3969
  plan = ChangePlan(
3845
3970
  operation=operation,
3846
3971
  entity_type=entity_type,
3847
3972
  entity_id=entity_id,
3848
3973
  customer_id=customer_id,
3849
- changes={"target_status": target_status},
3974
+ changes=changes,
3850
3975
  )
3851
3976
  store_plan(plan)
3852
3977
  return plan.to_preview()
@@ -270,7 +270,7 @@ For images that have no public URL: a file on the user's computer, or an image t
270
270
  | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) |
271
271
  | `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` |
272
272
  | `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` |
273
- | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. |
273
+ | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. |
274
274
  | `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each |
275
275
  | `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values |
276
276
  | `draft_image_assets` | Create image assets for a campaign from local files, public image URLs or uploads (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server), `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) and/or `upload_ids` (`up_...` from `upload_images` / `list_uploads`) |
@@ -427,7 +427,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has
427
427
  7. **Pinning (use sparingly).** By default, pass headlines and descriptions as plain strings — Google rotates them for you and ad strength benefits from the diversity. Only switch to the dict shape `{"text": "...", "pinned_field": "HEADLINE_1"}` when the user has a concrete brand-safety, compliance, or messaging requirement (e.g. "the brand name must always appear", "the disclaimer must always show"). Trade-offs to flag before pinning:
428
428
  - Each pin reduces Google's ability to optimize headline order, which usually drops ad strength one notch.
429
429
  - Phone numbers should generally use a **call asset at the campaign level** (separate tool/manual setup) rather than a pinned headline — call assets are clickable on mobile and don't burn a headline slot. If the user asks to pin a phone number, suggest the call-asset path first.
430
- - Valid pin slots: `HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3` for headlines; `DESCRIPTION_1`, `DESCRIPTION_2` for descriptions. Google permits at most 2 headlines per slot and 1 description per slot — the draft tool enforces these caps.
430
+ - Valid pin slots: `HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3` for headlines; `DESCRIPTION_1`, `DESCRIPTION_2` for descriptions. Several assets may pin to the same slot and rotate there (Google suggests 2 or 3 unique ones per pinned slot), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. Pinning 3 keyword headlines to `HEADLINE_1` with 10 unpinned headlines is fine; pinning all 3 headlines of a 3-headline ad to `HEADLINE_1` is refused.
431
431
  - You can mix pinned dict entries with plain-string entries in a single call (e.g. brand pinned to `HEADLINE_1`, the rest unpinned).
432
432
  8. **Always set display paths** (`path1`, `path2`, max 15 chars each). These appear in the display URL (e.g. `example.com/Products/Pricing`) and significantly improve ad relevance. Derive them from the landing page URL structure or the ad's value proposition.
433
433
  9. Present the complete preview to the user — include any warnings from the pre-write checks, and explicitly call out which assets (if any) are pinned and the trade-off.
@@ -576,7 +576,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
576
576
 
577
577
  1. Call the appropriate read tool to confirm the entity exists and get its current status
578
578
  2. Call `pause_entity` or `enable_entity` (Google) or `pause_reddit_entity` / `enable_reddit_entity` (Reddit) with the entity type and ID
579
- 3. Present the preview with a clear warning about impact (e.g. "This will stop all ads in this campaign")
579
+ 3. Present the preview with a clear warning about impact (e.g. "This will stop all ads in this campaign"). Google Ads previews of pause, enable and remove name the target: `entity_name`, `account`, the parent `campaign_name` / `ad_group_name` and `current_status`. Repeat the names to the user, because a wrong account is the costly mistake; an ID that matches nothing is refused at draft time
580
580
  4. Wait for confirmation
581
581
 
582
582
  ### When user asks about landing page performance
@@ -203,15 +203,25 @@
203
203
  const p = plan.preview;
204
204
  $("platform").textContent = PLATFORMS[platformOf(p)];
205
205
  $("title").textContent = humanize(p.operation);
206
+ // Names resolved at draft time (pause, enable, remove) sit in changes;
207
+ // the IDs stay visible next to them, and the card falls back to IDs.
208
+ const ch = p.changes || {};
206
209
  const sub = [];
207
- if (p.entity_type) sub.push(String(p.entity_type).replace(/_/g, " ") + (p.entity_id ? " " + p.entity_id : ""));
208
- if (p.customer_id) sub.push(String(p.customer_id));
210
+ if (p.entity_type) {
211
+ let target = String(p.entity_type).replace(/_/g, " ");
212
+ if (ch.entity_name) target += " \u201c" + ch.entity_name + "\u201d" + (p.entity_id ? " (" + p.entity_id + ")" : "");
213
+ else if (p.entity_id) target += " " + p.entity_id;
214
+ sub.push(target);
215
+ }
216
+ if (ch.account) sub.push(String(ch.account));
217
+ else if (p.customer_id) sub.push(String(p.customer_id));
209
218
  sub.push("plan " + plan.planId);
210
219
  $("subtitle").textContent = sub.join(" · ");
211
220
 
212
221
  const dl = $("changes");
213
222
  dl.replaceChildren();
214
- const entries = Object.entries(p.changes || {});
223
+ const SHOWN_ELSEWHERE = new Set(["account", "entity_name", "warnings"]);
224
+ const entries = Object.entries(ch).filter(([k]) => !SHOWN_ELSEWHERE.has(k));
215
225
  entries.slice(0, MAX_ROWS).forEach(([k, v]) => row(dl, k, v));
216
226
  if (entries.length > MAX_ROWS) {
217
227
  const more = document.createElement("div");
@@ -3406,10 +3406,11 @@ def draft_responsive_search_ad(
3406
3406
 
3407
3407
  The preview shows exactly what will be created; confirm_and_apply executes
3408
3408
  the returned plan_id. The ad is created PAUSED. final_url is checked for
3409
- reachability first and an unreachable URL is refused. Google caps: at most
3410
- 2 headlines per pin slot, at most 1 description per pin slot. Mixed
3411
- plain-string and dict entries are allowed within a single call (e.g. brand
3412
- pinned to HEADLINE_1, the rest unpinned). Warnings note fewer than 8
3409
+ reachability first and an unreachable URL is refused. Several headlines
3410
+ may pin to the same position (Google rotates them there) as long as every
3411
+ position stays fillable, by an asset pinned to it or by a distinct
3412
+ unpinned one. Mixed plain-string and dict entries are allowed within a
3413
+ single call (e.g. brand pinned to HEADLINE_1, the rest unpinned). Warnings note fewer than 8
3413
3414
  headlines or 3 descriptions.
3414
3415
 
3415
3416
  Args:
@@ -3483,15 +3484,15 @@ def update_responsive_search_ad(
3483
3484
  uses the configured default account.
3484
3485
  headlines: LIST-REPLACE: None or [] means no change; a non-empty list
3485
3486
  fully swaps in for the existing headlines. Google's RSA constraints
3486
- apply: 3-15 headlines, max 30 chars each, at most 2 per pin slot.
3487
+ apply: 3-15 headlines, max 30 chars each, every pin position fillable.
3487
3488
  Each entry is a plain string (unpinned) or {"text": "...",
3488
3489
  "pinned_field": "HEADLINE_1"} (HEADLINE_1-3). Insertion tags such as
3489
3490
  {KeyWord:Clogged Drain Service} count as their default text, which
3490
3491
  is required.
3491
3492
  descriptions: LIST-REPLACE: None or [] means no change; a non-empty list
3492
- fully swaps in. 2-4 descriptions, max 90 chars each, at most 1 per
3493
- pin slot (DESCRIPTION_1, DESCRIPTION_2). Same entry format and
3494
- insertion-tag counting as headlines.
3493
+ fully swaps in. 2-4 descriptions, max 90 chars each, pins to
3494
+ DESCRIPTION_1 or DESCRIPTION_2 with every position fillable. Same
3495
+ entry format and insertion-tag counting as headlines.
3495
3496
  final_url: Empty means no change; non-empty replaces the final URL and
3496
3497
  is checked for reachability.
3497
3498
  path1: Empty means no change; non-empty sets the first display path
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
File without changes