adloop 0.32.1__tar.gz → 0.33.1__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.32.1 → adloop-0.33.1}/PKG-INFO +1 -1
  2. {adloop-0.32.1 → adloop-0.33.1}/pyproject.toml +1 -1
  3. {adloop-0.32.1 → adloop-0.33.1}/pyproject.toml.orig +1 -1
  4. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/conversion_actions.py +97 -25
  5. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/write.py +2 -2
  6. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/adloop.md +16 -1
  7. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/approval.py +69 -0
  8. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/preview.py +5 -0
  9. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/server.py +76 -24
  10. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/upload_card.html +26 -5
  11. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/uploads.py +132 -34
  12. {adloop-0.32.1 → adloop-0.33.1}/README.md +0 -0
  13. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/__init__.py +0 -0
  14. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/__main__.py +0 -0
  15. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/_mcp_patches.py +0 -0
  16. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/__init__.py +0 -0
  17. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/ai_max.py +0 -0
  18. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/audiences.py +0 -0
  19. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/brands.py +0 -0
  20. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/client.py +0 -0
  21. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/currency.py +0 -0
  22. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/custom_conversion_goals.py +0 -0
  23. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/enums.py +0 -0
  24. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/forecast.py +0 -0
  25. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/gaql.py +0 -0
  26. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/image_fetch.py +0 -0
  27. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/pmax.py +0 -0
  28. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/read.py +0 -0
  29. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/shopping.py +0 -0
  30. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ads/validate_only.py +0 -0
  31. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/auth.py +0 -0
  32. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/cli.py +0 -0
  33. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/config.py +0 -0
  34. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/crossref.py +0 -0
  35. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/diagnostics.py +0 -0
  36. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/__init__.py +0 -0
  37. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/client.py +0 -0
  38. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/reports.py +0 -0
  39. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/tracking.py +0 -0
  40. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/ga4/write.py +0 -0
  41. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gsc/__init__.py +0 -0
  42. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gsc/client.py +0 -0
  43. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gsc/reports.py +0 -0
  44. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/__init__.py +0 -0
  45. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/client.py +0 -0
  46. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/entities.py +0 -0
  47. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/read.py +0 -0
  48. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/gtm/write.py +0 -0
  49. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/merchant/__init__.py +0 -0
  50. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/merchant/client.py +0 -0
  51. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/merchant/read.py +0 -0
  52. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/pagespeed.py +0 -0
  53. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/__init__.py +0 -0
  54. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/auth.py +0 -0
  55. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/client.py +0 -0
  56. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/read.py +0 -0
  57. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/schedule.py +0 -0
  58. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/reddit/write.py +0 -0
  59. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/__init__.py +0 -0
  60. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
  61. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/budget-plan.md +0 -0
  62. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/create-ad.md +0 -0
  63. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/create-campaign.md +0 -0
  64. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  65. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  66. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/rules_install.py +0 -0
  67. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/runtime.py +0 -0
  68. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/__init__.py +0 -0
  69. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/approval_card.html +0 -0
  70. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/audit.py +0 -0
  71. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/safety/guards.py +0 -0
  72. {adloop-0.32.1 → adloop-0.33.1}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.32.1
3
+ Version: 0.33.1
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.32.1"
3
+ version = "0.33.1"
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.32.1"
3
+ version = "0.33.1"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -25,6 +25,7 @@ import hashlib
25
25
  import math
26
26
  import re
27
27
  import threading
28
+ from dataclasses import dataclass
28
29
  from datetime import datetime, timezone as _tz
29
30
  from typing import TYPE_CHECKING, Callable
30
31
 
@@ -1009,8 +1010,16 @@ _CSV_COMMENT_PREFIX = "#"
1009
1010
  _MAX_CSV_BYTES = 5 * 1024 * 1024
1010
1011
 
1011
1012
 
1013
+ @dataclass(frozen=True)
1014
+ class UploadedCsv:
1015
+ """A conversion CSV that arrived as an upload instead of a local path."""
1016
+
1017
+ name: str
1018
+ data: bytes
1019
+
1020
+
1012
1021
  def _read_upload_csv(
1013
- csv_path: str,
1022
+ csv_path: "str | UploadedCsv",
1014
1023
  ) -> tuple[list[tuple[int, list[str]]], list[str], str]:
1015
1024
  """Read an upload CSV into ``(source_line, cells)`` records (header first).
1016
1025
 
@@ -1020,8 +1029,9 @@ def _read_upload_csv(
1020
1029
  Counting records instead would drift as soon as a comment or a bad row is
1021
1030
  dropped.
1022
1031
 
1023
- Local-only by design: the path is read from the machine running AdLoop, so
1024
- the tool refuses in server mode before it gets here. Errors name the file
1032
+ ``csv_path`` is a path on the machine running AdLoop (refused in server
1033
+ mode before it gets here) or an :class:`UploadedCsv` from the upload
1034
+ store, which a hosted server reads instead. Errors name the file
1025
1035
  and the schema, never file *content* — a hosted runtime can read files the
1026
1036
  caller is not allowed to see.
1027
1037
  """
@@ -1036,15 +1046,21 @@ def _read_upload_csv(
1036
1046
  # which is what timestamps without an offset are resolved against.
1037
1047
  timezone = ""
1038
1048
 
1039
- path = Path(csv_path).expanduser()
1040
- if not path.is_file():
1041
- return [], [f"CSV not found or not a regular file: {path}"], ""
1042
- if path.suffix.lower() != ".csv":
1043
- return [], [f"CSV must be a .csv file, got: {path.name}"], ""
1044
- try:
1045
- size = path.stat().st_size
1046
- except OSError as exc:
1047
- return [], [f"CSV could not be read: {exc.strerror or exc}"], ""
1049
+ import io
1050
+
1051
+ if isinstance(csv_path, UploadedCsv):
1052
+ path = None
1053
+ size = len(csv_path.data)
1054
+ else:
1055
+ path = Path(csv_path).expanduser()
1056
+ if not path.is_file():
1057
+ return [], [f"CSV not found or not a regular file: {path}"], ""
1058
+ if path.suffix.lower() != ".csv":
1059
+ return [], [f"CSV must be a .csv file, got: {path.name}"], ""
1060
+ try:
1061
+ size = path.stat().st_size
1062
+ except OSError as exc:
1063
+ return [], [f"CSV could not be read: {exc.strerror or exc}"], ""
1048
1064
  if size > _MAX_CSV_BYTES:
1049
1065
  return [], [
1050
1066
  f"CSV is {size / 1_048_576:.1f} MB; the limit is "
@@ -1053,7 +1069,11 @@ def _read_upload_csv(
1053
1069
  ], ""
1054
1070
 
1055
1071
  try:
1056
- with path.open("r", newline="", encoding="utf-8-sig") as handle:
1072
+ with (
1073
+ io.StringIO(csv_path.data.decode("utf-8-sig"), newline="")
1074
+ if path is None
1075
+ else path.open("r", newline="", encoding="utf-8-sig")
1076
+ ) as handle:
1057
1077
  position = {"line": 0}
1058
1078
 
1059
1079
  def _lines():
@@ -1164,7 +1184,7 @@ def _pad_to_header(raw: list[str], columns: int) -> list[str]:
1164
1184
 
1165
1185
 
1166
1186
  def _parse_call_conversion_csv(
1167
- csv_path: str, default_region: str = ""
1187
+ csv_path: "str | UploadedCsv", default_region: str = ""
1168
1188
  ) -> tuple[list[dict], list[str], list[str], list[dict]]:
1169
1189
  """Read the call-conversions CSV (local file) and normalize each row.
1170
1190
 
@@ -1310,7 +1330,8 @@ def draft_upload_call_conversions(
1310
1330
  config: AdLoopConfig,
1311
1331
  *,
1312
1332
  customer_id: str = "",
1313
- csv_path: str,
1333
+ csv_path: str = "",
1334
+ upload_id: str = "",
1314
1335
  default_region: str = "",
1315
1336
  consent: dict | None = None,
1316
1337
  ) -> dict:
@@ -1353,12 +1374,17 @@ def draft_upload_call_conversions(
1353
1374
  operation="upload_call_conversions",
1354
1375
  default_region=default_region,
1355
1376
  consent=consent,
1377
+ csv_path=csv_path,
1378
+ upload_id=upload_id,
1356
1379
  )
1380
+ if error:
1381
+ return error
1382
+ source, error = _csv_source(csv_path, upload_id)
1357
1383
  if error:
1358
1384
  return error
1359
1385
 
1360
1386
  rows, parse_advisories, skipped, error = _upload_parse_result(
1361
- *_parse_call_conversion_csv(csv_path, default_region),
1387
+ *_parse_call_conversion_csv(source, default_region),
1362
1388
  empty_message="CSV contained zero conversion rows",
1363
1389
  )
1364
1390
  if error:
@@ -1493,7 +1519,7 @@ def draft_upload_call_conversions(
1493
1519
  apply_only_payload={"rows": frozen_rows},
1494
1520
  )
1495
1521
  store_plan(plan)
1496
- return plan.to_preview()
1522
+ return _consume_upload(plan.to_preview(), upload_id)
1497
1523
 
1498
1524
 
1499
1525
  def _resolve_upload_action(
@@ -1714,6 +1740,8 @@ def _upload_draft_preflight(
1714
1740
  operation: str,
1715
1741
  default_region: str,
1716
1742
  consent: dict | None,
1743
+ csv_path: str = "",
1744
+ upload_id: str = "",
1717
1745
  ) -> tuple[str, dict | None, dict | None]:
1718
1746
  """The guards both upload drafts run before they touch the CSV.
1719
1747
 
@@ -1724,12 +1752,19 @@ def _upload_draft_preflight(
1724
1752
  from adloop.runtime import deployment_mode
1725
1753
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
1726
1754
 
1727
- if deployment_mode() == "server":
1755
+ if bool(csv_path) == bool(upload_id):
1756
+ return "", None, {
1757
+ "error": (
1758
+ "Pass exactly one of csv_path (a file on the machine running "
1759
+ "AdLoop) or upload_id (a CSV uploaded with upload_conversion_csv)."
1760
+ )
1761
+ }
1762
+ if csv_path and deployment_mode() == "server":
1728
1763
  return "", None, {
1729
1764
  "error": (
1730
- "This tool uploads conversions from a CSV file on the machine "
1731
- "running AdLoop and is not available on the hosted server. "
1732
- "Use the self-hosted AdLoop MCP server for conversion uploads."
1765
+ "csv_path names a file on the machine running AdLoop, which on "
1766
+ "the hosted server is not yours. Upload the CSV with "
1767
+ "upload_conversion_csv and pass its upload_id instead."
1733
1768
  )
1734
1769
  }
1735
1770
 
@@ -1754,6 +1789,37 @@ def _upload_draft_preflight(
1754
1789
  return region, consent_norm, None
1755
1790
 
1756
1791
 
1792
+ def _csv_source(csv_path: str, upload_id: str) -> "tuple[str | UploadedCsv | None, dict | None]":
1793
+ """The CSV to parse: the local path, or the uploaded file's bytes."""
1794
+ if not upload_id:
1795
+ return csv_path, None
1796
+ from adloop.uploads import UploadError, current_tenant, get_upload_store
1797
+
1798
+ try:
1799
+ upload, data = get_upload_store().get(current_tenant(), upload_id, kind="csv")
1800
+ except UploadError as exc:
1801
+ return None, {"error": f"{exc}. Upload the CSV again with upload_conversion_csv."}
1802
+ return UploadedCsv(name=upload.name, data=data), None
1803
+
1804
+
1805
+ def _consume_upload(preview: dict, upload_id: str) -> dict:
1806
+ """Delete an uploaded CSV once its plan holds the rows.
1807
+
1808
+ The plan carries everything apply needs, so the raw file (phone numbers,
1809
+ emails) has no reason to stay. A draft that failed keeps the file, so a
1810
+ retry with another default_region needs no new upload.
1811
+ """
1812
+ if upload_id:
1813
+ from adloop.uploads import current_tenant, get_upload_store
1814
+
1815
+ get_upload_store().delete(current_tenant(), upload_id)
1816
+ preview["source_upload"] = (
1817
+ f"{upload_id} was read and deleted; the plan holds the rows. A new "
1818
+ "draft needs a new upload."
1819
+ )
1820
+ return preview
1821
+
1822
+
1757
1823
  def _upload_parse_result(
1758
1824
  rows: list[dict],
1759
1825
  errors: list[str],
@@ -2262,7 +2328,7 @@ _OPTIONAL_EC_HEADERS = [
2262
2328
 
2263
2329
 
2264
2330
  def _parse_ec_for_leads_csv(
2265
- csv_path: str, default_region: str = ""
2331
+ csv_path: "str | UploadedCsv", default_region: str = ""
2266
2332
  ) -> tuple[list[dict], list[str], list[str], list[dict]]:
2267
2333
  """Parse the EC-for-Leads CSV (local file) and hash PII at parse time.
2268
2334
 
@@ -2463,7 +2529,8 @@ def draft_upload_enhanced_conversions_for_leads(
2463
2529
  config: AdLoopConfig,
2464
2530
  *,
2465
2531
  customer_id: str = "",
2466
- csv_path: str,
2532
+ csv_path: str = "",
2533
+ upload_id: str = "",
2467
2534
  default_region: str = "",
2468
2535
  consent: dict | None = None,
2469
2536
  ) -> dict:
@@ -2501,12 +2568,17 @@ def draft_upload_enhanced_conversions_for_leads(
2501
2568
  operation="upload_enhanced_conversions_for_leads",
2502
2569
  default_region=default_region,
2503
2570
  consent=consent,
2571
+ csv_path=csv_path,
2572
+ upload_id=upload_id,
2504
2573
  )
2574
+ if error:
2575
+ return error
2576
+ source, error = _csv_source(csv_path, upload_id)
2505
2577
  if error:
2506
2578
  return error
2507
2579
 
2508
2580
  rows, parse_advisories, skipped, error = _upload_parse_result(
2509
- *_parse_ec_for_leads_csv(csv_path, default_region),
2581
+ *_parse_ec_for_leads_csv(source, default_region),
2510
2582
  empty_message="CSV contained zero conversion rows",
2511
2583
  )
2512
2584
  if error:
@@ -2735,7 +2807,7 @@ def draft_upload_enhanced_conversions_for_leads(
2735
2807
  apply_only_payload={"rows": frozen_rows},
2736
2808
  )
2737
2809
  store_plan(plan)
2738
- return plan.to_preview()
2810
+ return _consume_upload(plan.to_preview(), upload_id)
2739
2811
 
2740
2812
 
2741
2813
  def _apply_upload_enhanced_conversions_for_leads(
@@ -3351,7 +3351,7 @@ def image_payloads(
3351
3351
  from adloop.uploads import UploadError, current_tenant, get_upload_store
3352
3352
 
3353
3353
  try:
3354
- upload, _data = get_upload_store().get(current_tenant(), upload_id)
3354
+ upload, _data = get_upload_store().get(current_tenant(), upload_id, kind="image")
3355
3355
  except UploadError as exc:
3356
3356
  errors.append(f"Upload {index + 1}: {exc}")
3357
3357
  continue
@@ -5135,7 +5135,7 @@ def load_image_bytes(payload: dict) -> tuple[bytes, PurePath]:
5135
5135
  if payload.get("upload_id"):
5136
5136
  from adloop.uploads import current_tenant, get_upload_store
5137
5137
 
5138
- upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
5138
+ upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]), kind="image")
5139
5139
  if upload.sha256 != payload.get("sha256"):
5140
5140
  raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
5141
5141
  return image_bytes, PurePosixPath(upload.name)
@@ -249,6 +249,21 @@ For images that have no public URL: a file on the user's computer, or an image t
249
249
  - **Using an upload:** `draft_image_assets(upload_ids=["up_..."])` for Google Ads; `"upload:up_..."` as `image_url`, `thumbnail_url` or a carousel card's `image_url` in `draft_reddit_ad` (AdLoop Cloud only, because Reddit downloads the image from a short-lived public link). Videos cannot be uploaded.
250
250
  - Uploads expire after 24 hours. Self-hosted without a card, pass local files directly (`image_paths`).
251
251
 
252
+ ### Offline Conversion Uploads
253
+
254
+ Conversions that happen offline (a sales call, a lead that turned into a deal) go back to Google Ads from a CSV in Google's upload template.
255
+
256
+ | Tool | When to Use | Key Parameters |
257
+ |------|-------------|----------------|
258
+ | `upload_conversion_csv` | The CSV is not on the machine running AdLoop (always on AdLoop Cloud). Shows the upload card, or returns an upload link | (none) |
259
+ | `draft_upload_call_conversions` | Calls from call assets or call-tracking numbers that became a conversion; matched by the caller's phone number | exactly one of `csv_path` (local file) or `upload_id`; `default_region` for numbers without a country code; `consent` |
260
+ | `draft_upload_enhanced_conversions_for_leads` | Form leads that became a conversion; matched by hashed email, phone or name and address | exactly one of `csv_path` or `upload_id`; `default_region`; `consent` |
261
+
262
+ - The conversion action named in the CSV's `Conversion Name` column must exist and be of type UPLOAD_CALLS (calls) or UPLOAD_CLICKS (leads); the draft checks it against the account.
263
+ - **The CSV holds personal data** (phone numbers, emails, names). An uploaded file lasts two hours at most and is deleted as soon as a draft has read it, so a second draft needs a new upload; a failed draft keeps it for a retry (for example with `default_region`). Lead identifiers are hashed while drafting. Never ask the user to paste rows into the chat.
264
+ - EEA traffic needs consent signals: pass `consent` or fill the CSV's `Ad User Data` / `Ad Personalization` columns. Present the preview's `consent_summary`, `skipped_rows` and `dedup_warnings`.
265
+ - Apply sends batches of 2,000 rows. `PARTIAL_UPLOAD` means some batches are already in the account: the plan is retired, and a new draft continues at `resume_from_line`. Don't re-upload rows that went through.
266
+
252
267
  ### Planning Tools
253
268
 
254
269
  | Tool | When to Use | Key Parameters |
@@ -330,7 +345,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
330
345
 
331
346
  **Approval cards (Claude, ChatGPT, VS Code and other apps with MCP Apps):** when the preview comes back with `approval: "card"`, the user sees it as an AdLoop approval card with Test run, Apply and Discard buttons, and approves there. Summarize the change and its warnings, then let the user decide on the card instead of asking for a "yes" in the chat. In those apps `confirm_and_apply(dry_run=false)` is refused with status `APPROVE_ON_CARD` (a dry run still works), because only the click applies; the AI cannot press the button. Do not retry. If the user says the card is not visible, tell them the workspace owner can allow approval in chat (AdLoop Cloud: Settings → Safety; self-hosted: `safety.allow_chat_approval`). After a click the card posts a message such as "Applied on the AdLoop approval card: ..."; continue from there (e.g. remind them to enable a new paused ad).
332
347
 
333
- **Confirmation dialogs (apps without cards that support elicitation):** when you call `confirm_and_apply(dry_run=false)` after the user's go-ahead, AdLoop itself asks the user in the app's confirmation dialog before anything changes; irreversible changes need an extra "cannot be undone" tick there. You do not answer that dialog. `NOT_CONFIRMED` means the user declined or closed it: nothing was changed, the preview stays pending, so ask what they want instead of retrying. Apps with neither cards nor dialogs keep the chat flow above.
348
+ **Confirmation dialogs (apps without cards that support elicitation):** when you call `confirm_and_apply(dry_run=false)` after the user's go-ahead, AdLoop itself asks the user in the app's confirmation dialog before anything changes; irreversible changes need an extra "cannot be undone" tick there. You do not answer that dialog. `NOT_CONFIRMED` means the user declined or closed it: nothing was changed, the preview stays pending, so ask what they want instead of retrying. A dry run's `next_step` says when a dialog will follow, so tell the user to look out for it. `CONFIRMATION_UNANSWERED` means the app received the dialog but never returned an answer (some headless agents and IDE setups declare dialogs they cannot show): nothing was changed; ask the user whether a dialog appeared, and if their app cannot show one, relay the message's ways out (approval in chat, or an app with the approval card). Apps with neither cards nor dialogs keep the chat flow above.
334
349
 
335
350
  **Safety behaviors:**
336
351
  - New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
@@ -335,6 +335,64 @@ def _not_confirmed(plan: ChangePlan) -> dict:
335
335
  }
336
336
 
337
337
 
338
+ def _dialog_unanswered(plan: ChangePlan) -> dict:
339
+ return {
340
+ "status": "CONFIRMATION_UNANSWERED",
341
+ "plan_id": plan.plan_id,
342
+ "operation": plan.operation,
343
+ "message": (
344
+ "AdLoop asked the user to confirm this change in a dialog of their app, "
345
+ "but the app never returned an answer, so nothing was changed and the "
346
+ "plan stays pending. Ask the user whether a confirmation dialog appeared. "
347
+ "If it did, call confirm_and_apply again and have them answer it. If their "
348
+ "app cannot show dialogs (for example a headless agent or a script), the "
349
+ "workspace owner can allow approval in chat (AdLoop Cloud: Settings -> "
350
+ "Safety -> 'Also allow approval in chat'; self-hosted: "
351
+ "safety.allow_chat_approval: true), or the change can be applied from an "
352
+ "app that shows AdLoop's approval card, such as Claude or ChatGPT. Calling "
353
+ "confirm_and_apply again sends the dialog once more."
354
+ ),
355
+ }
356
+
357
+
358
+ def _mark_dialog(plan: ChangePlan, open_: bool) -> None:
359
+ """Record (or clear) that a dialog for this plan awaits an answer."""
360
+ from datetime import datetime, timezone
361
+
362
+ from adloop.safety.preview import store_plan
363
+
364
+ plan.dialog_requested_at = datetime.now(timezone.utc).isoformat() if open_ else ""
365
+ store_plan(plan)
366
+
367
+
368
+ def dialog_expected(plan_id: str) -> bool:
369
+ """Whether a real apply of this plan will ask the user in a dialog.
370
+
371
+ Used after a successful dry run, so the AI can tell the user to look out
372
+ for it instead of letting an unanswered dialog look like a hang.
373
+ """
374
+ if _dialog_applies(plan_id) is None:
375
+ return False
376
+ try:
377
+ from fastmcp.server.dependencies import get_context
378
+
379
+ if get_context()._is_modern_protocol():
380
+ return True
381
+ except Exception:
382
+ return False
383
+ from adloop.runtime import deployment_mode
384
+
385
+ # Older protocols get the dialog only self-hosted (see confirm_in_dialog).
386
+ return deployment_mode() != "server"
387
+
388
+
389
+ DIALOG_NOTE = (
390
+ "The real apply (dry_run=false) asks the user to confirm in a dialog shown by "
391
+ "their app; tell them to look out for it and answer it. If no dialog appears, "
392
+ "the call returns CONFIRMATION_UNANSWERED on the next attempt with the ways out."
393
+ )
394
+
395
+
338
396
  def _dialog_applies(plan_id: str) -> ChangePlan | None:
339
397
  """The plan when a real apply must be confirmed in a dialog, else None."""
340
398
  from adloop.runtime import current_config
@@ -375,6 +433,14 @@ async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
375
433
  if context._is_modern_protocol():
376
434
  responses = context.input_responses
377
435
  if responses is None:
436
+ if plan.dialog_requested_at:
437
+ # The last dialog for this plan was sent and this is a fresh
438
+ # call, not its answer: the app received the dialog and never
439
+ # returned one. Say so instead of sending another dialog the
440
+ # app will sit on until its timeout. Cleared, so asking again
441
+ # sends a new dialog.
442
+ _mark_dialog(plan, False)
443
+ return ToolResult(structured_content=_dialog_unanswered(plan))
378
444
  import mcp_types
379
445
  from fastmcp.tools.base import InputRequiredToolResult
380
446
 
@@ -385,11 +451,14 @@ async def confirm_in_dialog(context: Any, plan_id: str) -> Any | None:
385
451
  )
386
452
  )
387
453
  state = json.dumps({"plan_id": plan.plan_id, "code": approval_code(plan)})
454
+ _mark_dialog(plan, True)
388
455
  return InputRequiredToolResult(
389
456
  mcp_types.InputRequiredResult(
390
457
  input_requests={DIALOG_KEY: request}, request_state=state
391
458
  )
392
459
  )
460
+ if plan.dialog_requested_at:
461
+ _mark_dialog(plan, False) # answered, whatever the answer is
393
462
  try:
394
463
  state = json.loads(context.request_state or "{}")
395
464
  except ValueError:
@@ -25,6 +25,11 @@ class ChangePlan:
25
25
  # Ad platform the plan targets: "" (Google, the default so stored plans
26
26
  # from before the field existed still load) or "reddit".
27
27
  platform: str = ""
28
+ # When a confirmation dialog for this plan was last sent and not yet
29
+ # answered (ISO timestamp, "" when none is open). An app that receives
30
+ # the dialog but never answers it would otherwise leave every retry
31
+ # waiting until its own timeout; see approval.confirm_in_dialog.
32
+ dialog_requested_at: str = ""
28
33
 
29
34
  # Payload the applier needs but a preview must never show: an upload's rows
30
35
  # (the caller id cannot be hashed, so call uploads carry raw phone numbers),
@@ -4482,6 +4482,11 @@ def confirm_and_apply(
4482
4482
  only the user's click on the card applies the change. Dry runs still
4483
4483
  work. The workspace owner can allow approval in chat as well.
4484
4484
 
4485
+ Confirmation dialogs: in apps that show dialogs instead, dry_run=false
4486
+ first asks the user in a dialog. A declined dialog returns NOT_CONFIRMED;
4487
+ an app that receives the dialog but never answers it gets
4488
+ CONFIRMATION_UNANSWERED on the next call, with the ways out.
4489
+
4485
4490
  Args:
4486
4491
  plan_id: The plan_id returned by a prior draft_*, update_*, add_*,
4487
4492
  pause/enable/remove or other preview tool call. An unknown or
@@ -4490,13 +4495,17 @@ def confirm_and_apply(
4490
4495
  applies the change for real.
4491
4496
  """
4492
4497
  from adloop.ads.write import confirm_and_apply as _impl
4493
- from adloop.safety.approval import chat_apply_refusal
4498
+ from adloop.safety.approval import DIALOG_NOTE, chat_apply_refusal, dialog_expected
4494
4499
 
4495
4500
  if not dry_run:
4496
4501
  refusal = chat_apply_refusal(plan_id)
4497
4502
  if refusal is not None:
4498
4503
  return refusal
4499
- return _impl(current_config(), plan_id=plan_id, dry_run=dry_run)
4504
+ result = _impl(current_config(), plan_id=plan_id, dry_run=dry_run)
4505
+ if (dry_run and isinstance(result, dict) and result.get("status") == "DRY_RUN_SUCCESS"
4506
+ and dialog_expected(plan_id)):
4507
+ result["next_step"] = DIALOG_NOTE
4508
+ return result
4500
4509
 
4501
4510
 
4502
4511
  @_tool(
@@ -4590,10 +4599,15 @@ def upload_images() -> dict:
4590
4599
  draft_image_assets, or as "upload:<id>" into the image fields of
4591
4600
  draft_reddit_ad. Returns the current uploads and how to add more.
4592
4601
  """
4602
+ return _upload_tool_result("image")
4603
+
4604
+
4605
+ def _upload_tool_result(kind: str):
4606
+ """The upload card (with its code) or the upload link, for ``kind``."""
4593
4607
  from adloop.uploads import upload_instructions
4594
4608
 
4595
4609
  shows_cards = client_shows_cards()
4596
- result = upload_instructions(shows_cards)
4610
+ result = upload_instructions(shows_cards, kind)
4597
4611
  if not shows_cards:
4598
4612
  return result
4599
4613
  from fastmcp.tools import ToolResult
@@ -4609,6 +4623,29 @@ def upload_images() -> dict:
4609
4623
  )
4610
4624
 
4611
4625
 
4626
+ @_tool(
4627
+ title="Upload a conversion CSV",
4628
+ annotations=_READONLY,
4629
+ tags={"ads"},
4630
+ meta={"ui": {"resourceUri": UPLOAD_CARD_URI}},
4631
+ )
4632
+ @_safe
4633
+ def upload_conversion_csv() -> dict:
4634
+ """Let the user upload a conversion CSV for an offline conversion upload.
4635
+
4636
+ For the CSV behind draft_upload_call_conversions or
4637
+ draft_upload_enhanced_conversions_for_leads when the file is not on the
4638
+ machine running AdLoop, as on AdLoop Cloud. Apps that show AdLoop's
4639
+ cards display an upload card where the user drops the .csv file; the
4640
+ card then posts the upload id into the chat. Other apps on AdLoop Cloud
4641
+ get an upload link for the user (or a coding agent, with curl). The
4642
+ file holds personal data (phone numbers, emails), so it is kept for two
4643
+ hours at most and deleted as soon as a draft has read it. Returns the
4644
+ CSV uploads waiting to be used and how to add one.
4645
+ """
4646
+ return _upload_tool_result("csv")
4647
+
4648
+
4612
4649
  @_tool(
4613
4650
  title="Store an uploaded image",
4614
4651
  annotations=_WRITE,
@@ -4616,16 +4653,19 @@ def upload_images() -> dict:
4616
4653
  meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
4617
4654
  )
4618
4655
  @_safe
4619
- def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
4620
- """Store an image dropped on the AdLoop upload card.
4656
+ def store_image_upload(name: str, data_base64: str, code: str = "", kind: str = "image") -> dict:
4657
+ """Store a file dropped on the AdLoop upload card.
4621
4658
 
4622
4659
  Called only by the upload card; AI apps keep this tool out of the
4623
- model's tool list. Returns the upload's id, type and dimensions.
4660
+ model's tool list. Returns the upload's id, type and size (dimensions
4661
+ for images, lines for CSVs).
4624
4662
 
4625
4663
  Args:
4626
4664
  name: The file name, used for the asset name.
4627
4665
  data_base64: The file content, base64-encoded.
4628
- code: The upload card's code from upload_images.
4666
+ code: The upload card's code from upload_images or
4667
+ upload_conversion_csv.
4668
+ kind: "image" (default) or "csv" for a conversion CSV.
4629
4669
  """
4630
4670
  import base64
4631
4671
  import binascii
@@ -4637,20 +4677,21 @@ def store_image_upload(name: str, data_base64: str, code: str = "") -> dict:
4637
4677
  except (binascii.Error, ValueError):
4638
4678
  return {"error": "The file did not arrive intact; upload it again."}
4639
4679
  try:
4640
- upload = get_upload_store().put(current_tenant(), name, data)
4680
+ upload = get_upload_store().put(current_tenant(), name, data, kind=kind)
4641
4681
  except UploadError as exc:
4642
4682
  return {"error": f"{name}: {exc}"}
4643
4683
  return {"status": "UPLOADED", **upload.summary()}
4644
4684
 
4645
4685
 
4646
- @_tool(title="List uploaded images", annotations=_READONLY, tags={"core"})
4686
+ @_tool(title="List uploads", annotations=_READONLY, tags={"core"})
4647
4687
  @_safe
4648
4688
  def list_uploads() -> dict:
4649
- """List the images the user uploaded to AdLoop that have not expired.
4689
+ """List the files the user uploaded to AdLoop that have not expired.
4650
4690
 
4651
- Returns each upload's upload_id, name, type, dimensions, size and hours
4652
- until it expires (uploads last a day): the ids of files the user sent
4653
- through the upload link, or of earlier uploads.
4691
+ Returns each upload's upload_id, kind (image or csv), name, type, size,
4692
+ dimensions (images) or lines (CSVs) and hours until it expires: images
4693
+ last a day, conversion CSVs two hours or until a draft reads them. The
4694
+ ids of files the user sent through an upload link show up here.
4654
4695
  """
4655
4696
  from adloop.uploads import current_tenant, get_upload_store
4656
4697
 
@@ -4660,7 +4701,8 @@ def list_uploads() -> dict:
4660
4701
  "usage": (
4661
4702
  "draft_image_assets(upload_ids=[...]) for Google Ads; "
4662
4703
  "\"upload:<upload_id>\" as image_url, thumbnail_url or a carousel card's "
4663
- "image_url in draft_reddit_ad (AdLoop Cloud only)."
4704
+ "image_url in draft_reddit_ad (AdLoop Cloud only). CSV uploads: upload_id in "
4705
+ "draft_upload_call_conversions or draft_upload_enhanced_conversions_for_leads."
4664
4706
  ),
4665
4707
  }
4666
4708
 
@@ -6141,7 +6183,8 @@ def draft_remove_conversion_action(
6141
6183
  @_tool(title="Draft call conversion upload", annotations=_WRITE, tags={"ads"})
6142
6184
  @_safe
6143
6185
  def draft_upload_call_conversions(
6144
- csv_path: str,
6186
+ csv_path: str = "",
6187
+ upload_id: str = "",
6145
6188
  default_region: str = "",
6146
6189
  consent: dict | None = None,
6147
6190
  customer_id: str = "",
@@ -6157,8 +6200,9 @@ def draft_upload_call_conversions(
6157
6200
  drafting, so a typo fails here rather than after the upload. Optional
6158
6201
  `Ad User Data` and `Ad Personalization` columns carry consent per row.
6159
6202
 
6160
- Local file only: on the hosted server this tool refuses, because it would
6161
- read a path on the server rather than the caller's machine.
6203
+ The CSV comes from csv_path (a file on the machine running AdLoop) or
6204
+ from upload_id (a file uploaded with upload_conversion_csv, the only way
6205
+ on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
6162
6206
 
6163
6207
  PII: the caller phone number is required RAW by Google (it cannot be
6164
6208
  hashed), so it lives in the plan's apply-only payload — the preview shows
@@ -6181,8 +6225,10 @@ def draft_upload_call_conversions(
6181
6225
 
6182
6226
  Args:
6183
6227
  csv_path: Path of the CSV file holding the upload rows, read on the
6184
- machine that runs AdLoop. The hosted server refuses this tool
6185
- rather than reading a path of its own.
6228
+ machine that runs AdLoop. Refused on the hosted server; use
6229
+ upload_id there.
6230
+ upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
6231
+ instead of csv_path.
6186
6232
  default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
6187
6233
  that carry no country code, e.g. "DE" for "0151 12345678". Empty
6188
6234
  leaves such numbers unusable and the row is skipped.
@@ -6202,6 +6248,7 @@ def draft_upload_call_conversions(
6202
6248
  current_config(),
6203
6249
  customer_id=customer_id or current_config().ads.customer_id,
6204
6250
  csv_path=csv_path,
6251
+ upload_id=upload_id,
6205
6252
  default_region=default_region,
6206
6253
  consent=consent,
6207
6254
  )
@@ -6210,7 +6257,8 @@ def draft_upload_call_conversions(
6210
6257
  @_tool(title="Draft enhanced conversions upload", annotations=_WRITE, tags={"ads"})
6211
6258
  @_safe
6212
6259
  def draft_upload_enhanced_conversions_for_leads(
6213
- csv_path: str,
6260
+ csv_path: str = "",
6261
+ upload_id: str = "",
6214
6262
  default_region: str = "",
6215
6263
  consent: dict | None = None,
6216
6264
  customer_id: str = "",
@@ -6235,8 +6283,9 @@ def draft_upload_enhanced_conversions_for_leads(
6235
6283
  sharing an Order ID count once at Google; two rows with different Order
6236
6284
  IDs are two real orders and are not reported.
6237
6285
 
6238
- Local file only: on the hosted server this tool refuses, because it would
6239
- read a path on the server rather than the caller's machine.
6286
+ The CSV comes from csv_path (a file on the machine running AdLoop) or
6287
+ from upload_id (a file uploaded with upload_conversion_csv, the only way
6288
+ on AdLoop Cloud). An uploaded file is deleted once the draft has read it.
6240
6289
 
6241
6290
  Returns a preview with a plan_id, the row counts, the consent the rows
6242
6291
  actually carry (`consent_summary`) and the skipped rows; nothing is
@@ -6244,8 +6293,10 @@ def draft_upload_enhanced_conversions_for_leads(
6244
6293
 
6245
6294
  Args:
6246
6295
  csv_path: Path of the CSV file holding the upload rows, read on the
6247
- machine that runs AdLoop. The hosted server refuses this tool
6248
- rather than reading a path of its own.
6296
+ machine that runs AdLoop. Refused on the hosted server; use
6297
+ upload_id there.
6298
+ upload_id: Id of a CSV uploaded with upload_conversion_csv (up_...),
6299
+ instead of csv_path.
6249
6300
  default_region: ISO 3166-1 alpha-2 country assumed for phone numbers
6250
6301
  that carry no country code, e.g. "DE" for "0151 12345678". Empty
6251
6302
  leaves such numbers unusable; the row still uploads when its email
@@ -6266,6 +6317,7 @@ def draft_upload_enhanced_conversions_for_leads(
6266
6317
  current_config(),
6267
6318
  customer_id=customer_id or current_config().ads.customer_id,
6268
6319
  csv_path=csv_path,
6320
+ upload_id=upload_id,
6269
6321
  default_region=default_region,
6270
6322
  consent=consent,
6271
6323
  )