adloop 0.25.0__tar.gz → 0.26.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 (70) hide show
  1. {adloop-0.25.0 → adloop-0.26.1}/PKG-INFO +19 -2
  2. {adloop-0.25.0 → adloop-0.26.1}/README.md +18 -1
  3. {adloop-0.25.0 → adloop-0.26.1}/pyproject.toml +1 -1
  4. {adloop-0.25.0 → adloop-0.26.1}/pyproject.toml.orig +1 -1
  5. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/write.py +38 -7
  6. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/write.py +43 -3
  7. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/adloop.md +15 -1
  8. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/server.py +129 -6
  9. adloop-0.26.1/src/adloop/upload_card.html +247 -0
  10. adloop-0.26.1/src/adloop/uploads.py +286 -0
  11. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/__init__.py +0 -0
  12. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/__main__.py +0 -0
  13. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/_mcp_patches.py +0 -0
  14. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/__init__.py +0 -0
  15. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/ai_max.py +0 -0
  16. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/brands.py +0 -0
  17. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/client.py +0 -0
  18. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/conversion_actions.py +0 -0
  19. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/currency.py +0 -0
  20. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/custom_conversion_goals.py +0 -0
  21. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/enums.py +0 -0
  22. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/forecast.py +0 -0
  23. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/gaql.py +0 -0
  24. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/image_fetch.py +0 -0
  25. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/pmax.py +0 -0
  26. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/read.py +0 -0
  27. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ads/validate_only.py +0 -0
  28. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/auth.py +0 -0
  29. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/cli.py +0 -0
  30. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/config.py +0 -0
  31. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/crossref.py +0 -0
  32. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/diagnostics.py +0 -0
  33. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/__init__.py +0 -0
  34. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/client.py +0 -0
  35. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/reports.py +0 -0
  36. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/tracking.py +0 -0
  37. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/ga4/write.py +0 -0
  38. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gsc/__init__.py +0 -0
  39. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gsc/client.py +0 -0
  40. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gsc/reports.py +0 -0
  41. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/__init__.py +0 -0
  42. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/client.py +0 -0
  43. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/entities.py +0 -0
  44. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/read.py +0 -0
  45. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/gtm/write.py +0 -0
  46. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/merchant/__init__.py +0 -0
  47. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/merchant/client.py +0 -0
  48. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/merchant/read.py +0 -0
  49. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/pagespeed.py +0 -0
  50. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/__init__.py +0 -0
  51. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/auth.py +0 -0
  52. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/client.py +0 -0
  53. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/read.py +0 -0
  54. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/reddit/schedule.py +0 -0
  55. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/__init__.py +0 -0
  56. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
  57. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/budget-plan.md +0 -0
  58. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/create-ad.md +0 -0
  59. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/create-campaign.md +0 -0
  60. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  61. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  62. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/rules_install.py +0 -0
  63. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/runtime.py +0 -0
  64. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/__init__.py +0 -0
  65. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/approval.py +0 -0
  66. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/approval_card.html +0 -0
  67. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/audit.py +0 -0
  68. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/guards.py +0 -0
  69. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/safety/preview.py +0 -0
  70. {adloop-0.25.0 → adloop-0.26.1}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.25.0
3
+ Version: 0.26.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
@@ -262,6 +262,19 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
262
262
 
263
263
  > **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
264
264
 
265
+ ### Image Uploads
266
+
267
+ An image on your computer, or one you attached in the chat, has no public URL, and an AI app cannot hand the file to a tool. `upload_images` gives it a way in.
268
+
269
+ <p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/upload-card.gif" alt="An AdLoop upload card in an AI chat: the user drags a banner onto it, the card shows the upload id, posts it into the chat, and the AI drafts the image asset" width="760"></p>
270
+
271
+ | Tool | What It Does |
272
+ |------|-------------|
273
+ | `upload_images` | Shows an upload card in apps with MCP Apps (drop PNG, JPEG or GIF up to 5 MB); on AdLoop Cloud in other apps it returns a one-time upload link, which a coding agent can also use with `curl -T` |
274
+ | `list_uploads` | The uploads that have not expired yet (kept 24 hours), with id, dimensions and size |
275
+
276
+ The upload ids go into `draft_image_assets(upload_ids=[...])`, or as `upload:<id>` into the image fields of `draft_reddit_ad` on AdLoop Cloud.
277
+
265
278
  ### Planning Tools
266
279
 
267
280
  | Tool | What It Does |
@@ -282,7 +295,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
282
295
  | `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
283
296
  | `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
284
297
  | `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
285
- | `draft_image_assets` | Create campaign image assets from local files or public image URLs (PNG, JPEG, or GIF). |
298
+ | `draft_image_assets` | Create campaign image assets from local files, public image URLs or uploads (PNG, JPEG, or GIF). |
286
299
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — flags BROAD match on campaigns without Smart Bidding as dangerous in the preview. |
287
300
  | `add_negative_keywords` | Propose negative keywords directly on a campaign |
288
301
  | `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
@@ -337,6 +350,10 @@ AdLoop includes pre-built slash commands in `.claude/commands/` for common workf
337
350
 
338
351
  AdLoop manages real ad spend, so safety is not optional.
339
352
 
353
+ <p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/approval-card.gif" alt="An AdLoop approval card in an AI chat: the user clicks Test run, Google validates the change, then Apply makes it live and the card reports back to the chat" width="760"></p>
354
+
355
+ - **Approval cards.** In apps that show MCP Apps (Claude, ChatGPT, VS Code and others), every change appears as a card with Test run and Apply. Only the click applies it: the AI's own `confirm_and_apply(dry_run=false)` is refused with `APPROVE_ON_CARD`, whatever it was told in the chat.
356
+ - **Confirmation dialogs.** Apps without cards that support elicitation (Claude Code, for example) get a dialog from AdLoop itself before every real change. The AI cannot answer it; declining changes nothing.
340
357
  - **Two-step writes.** Every mutation returns a preview first. A separate `confirm_and_apply` call is required to execute.
341
358
  - **Dry-run by default.** Even `confirm_and_apply` defaults to `dry_run=true`. Real changes require explicit `dry_run=false`.
342
359
  - **Two-phase apply (optional).** With `safety.two_phase_apply: true`, `confirm_and_apply` refuses `dry_run=false` until the plan has completed one dry-run pass — preview-then-apply becomes server-enforced instead of a convention.
@@ -234,6 +234,19 @@ A second ad platform, same safety model. Reddit is a separate connection: its ow
234
234
 
235
235
  > **Setup for Reddit Ads tools** — In Reddit Ads Manager open **Business Manager → Developer Application → Create app** (business admins only; no approval wait). Register the redirect URL exactly as `http://localhost:8765/callback`, then run `adloop init` and complete the Reddit Ads step: it opens Reddit's consent page (scopes `adsread` + `adsedit`, permanent grant), stores the refresh token at `~/.adloop/reddit_token.json`, and lets you pick the default ad account. Reddit rate-limits per user (reporting: 60 requests/min) and requires a descriptive User-Agent, which AdLoop builds from your app id and Reddit username. Reddit has no validate-only mode, so `confirm_and_apply(dry_run=true)` re-reads the target and re-checks the safety caps instead.
236
236
 
237
+ ### Image Uploads
238
+
239
+ An image on your computer, or one you attached in the chat, has no public URL, and an AI app cannot hand the file to a tool. `upload_images` gives it a way in.
240
+
241
+ <p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/upload-card.gif" alt="An AdLoop upload card in an AI chat: the user drags a banner onto it, the card shows the upload id, posts it into the chat, and the AI drafts the image asset" width="760"></p>
242
+
243
+ | Tool | What It Does |
244
+ |------|-------------|
245
+ | `upload_images` | Shows an upload card in apps with MCP Apps (drop PNG, JPEG or GIF up to 5 MB); on AdLoop Cloud in other apps it returns a one-time upload link, which a coding agent can also use with `curl -T` |
246
+ | `list_uploads` | The uploads that have not expired yet (kept 24 hours), with id, dimensions and size |
247
+
248
+ The upload ids go into `draft_image_assets(upload_ids=[...])`, or as `upload:<id>` into the image fields of `draft_reddit_ad` on AdLoop Cloud.
249
+
237
250
  ### Planning Tools
238
251
 
239
252
  | Tool | What It Does |
@@ -254,7 +267,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
254
267
  | `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
255
268
  | `draft_callouts` | Create campaign callout assets from 1-25 character text snippets. |
256
269
  | `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
257
- | `draft_image_assets` | Create campaign image assets from local files or public image URLs (PNG, JPEG, or GIF). |
270
+ | `draft_image_assets` | Create campaign image assets from local files, public image URLs or uploads (PNG, JPEG, or GIF). |
258
271
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — flags BROAD match on campaigns without Smart Bidding as dangerous in the preview. |
259
272
  | `add_negative_keywords` | Propose negative keywords directly on a campaign |
260
273
  | `add_negative_locations` | Propose negative geo exclusions on a campaign — exclude cities/regions while keeping broader positive targets |
@@ -309,6 +322,10 @@ AdLoop includes pre-built slash commands in `.claude/commands/` for common workf
309
322
 
310
323
  AdLoop manages real ad spend, so safety is not optional.
311
324
 
325
+ <p align="center"><img src="https://raw.githubusercontent.com/kLOsk/adloop/main/assets/approval-card.gif" alt="An AdLoop approval card in an AI chat: the user clicks Test run, Google validates the change, then Apply makes it live and the card reports back to the chat" width="760"></p>
326
+
327
+ - **Approval cards.** In apps that show MCP Apps (Claude, ChatGPT, VS Code and others), every change appears as a card with Test run and Apply. Only the click applies it: the AI's own `confirm_and_apply(dry_run=false)` is refused with `APPROVE_ON_CARD`, whatever it was told in the chat.
328
+ - **Confirmation dialogs.** Apps without cards that support elicitation (Claude Code, for example) get a dialog from AdLoop itself before every real change. The AI cannot answer it; declining changes nothing.
312
329
  - **Two-step writes.** Every mutation returns a preview first. A separate `confirm_and_apply` call is required to execute.
313
330
  - **Dry-run by default.** Even `confirm_and_apply` defaults to `dry_run=true`. Real changes require explicit `dry_run=false`.
314
331
  - **Two-phase apply (optional).** With `safety.two_phase_apply: true`, `confirm_and_apply` refuses `dry_run=false` until the plan has completed one dry-run pass — preview-then-apply becomes server-enforced instead of a convention.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.25.0"
3
+ version = "0.26.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.25.0"
3
+ version = "0.26.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 = [
@@ -432,7 +432,7 @@ def _size_kb(data: bytes) -> float:
432
432
 
433
433
  def _describe_image(image: dict[str, object]) -> str:
434
434
  """One preview line: source, type, dimensions and size."""
435
- source = image.get("url") or image.get("path")
435
+ source = image.get("url") or image.get("path") or f"upload {image.get('upload_id')}"
436
436
  kind = str(image.get("mime_type", "")).removeprefix("image/").upper()
437
437
  return (
438
438
  f"{source} — {kind} {image.get('width')}x{image.get('height')}, "
@@ -2457,12 +2457,14 @@ def draft_image_assets(
2457
2457
  campaign_id: str = "",
2458
2458
  image_paths: list[str] | None = None,
2459
2459
  image_urls: list[str] | None = None,
2460
+ upload_ids: list[str] | None = None,
2460
2461
  ) -> dict:
2461
- """Draft campaign image assets from local files and/or public image URLs.
2462
+ """Draft campaign image assets from local files, public image URLs and/or uploads.
2462
2463
 
2463
2464
  URL images are fetched now (public addresses only, pinned against DNS
2464
2465
  rebinding, capped at Google's 5120 KB limit) and their sha256 is kept
2465
- in the plan; apply re-fetches and refuses if the bytes changed.
2466
+ in the plan; apply re-fetches and refuses if the bytes changed. Uploads
2467
+ (``up_...`` ids from the upload card or link) are pinned the same way.
2466
2468
  """
2467
2469
  from adloop.runtime import deployment_mode
2468
2470
  from adloop.safety.guards import SafetyViolation, check_blocked_operation
@@ -2486,7 +2488,7 @@ def draft_image_assets(
2486
2488
  return {"error": str(e)}
2487
2489
 
2488
2490
  validated_images, errors = _validate_image_assets(
2489
- campaign_id, image_paths, image_urls
2491
+ campaign_id, image_paths, image_urls, list(upload_ids or [])
2490
2492
  )
2491
2493
  if errors:
2492
2494
  return {"error": "Validation failed", "details": errors}
@@ -3300,16 +3302,38 @@ def _validate_structured_snippets(
3300
3302
 
3301
3303
 
3302
3304
  def _validate_image_assets(
3303
- campaign_id: str, image_paths: list[str], image_urls: list[str] | None = None
3305
+ campaign_id: str,
3306
+ image_paths: list[str],
3307
+ image_urls: list[str] | None = None,
3308
+ upload_ids: list[str] | None = None,
3304
3309
  ) -> tuple[list[dict[str, object]], list[str]]:
3305
3310
  errors = []
3306
3311
  validated = []
3307
3312
  image_urls = image_urls or []
3313
+ upload_ids = upload_ids or []
3308
3314
 
3309
3315
  if not campaign_id:
3310
3316
  errors.append("campaign_id is required")
3311
- if not image_paths and not image_urls:
3312
- errors.append("At least one image (image_paths or image_urls) is required")
3317
+ if not image_paths and not image_urls and not upload_ids:
3318
+ errors.append("At least one image (image_paths, image_urls or upload_ids) is required")
3319
+
3320
+ for index, upload_id in enumerate(upload_ids):
3321
+ from adloop.uploads import UploadError, current_tenant, get_upload_store
3322
+
3323
+ try:
3324
+ upload, _data = get_upload_store().get(current_tenant(), upload_id)
3325
+ except UploadError as exc:
3326
+ errors.append(f"Upload {index + 1}: {exc}")
3327
+ continue
3328
+ validated.append({
3329
+ "upload_id": upload.upload_id,
3330
+ "sha256": upload.sha256,
3331
+ "name": _build_image_asset_name(PurePosixPath(upload.name), _data),
3332
+ "mime_type": upload.mime_type,
3333
+ "width": upload.width,
3334
+ "height": upload.height,
3335
+ "size_kb": round(upload.size_bytes / 1024, 1),
3336
+ })
3313
3337
 
3314
3338
  for index, image_path in enumerate(image_paths):
3315
3339
  try:
@@ -4916,6 +4940,13 @@ def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict:
4916
4940
  if payload.get("url"):
4917
4941
  image_bytes = _fetch_approved_image_url(payload)
4918
4942
  name_source = _url_path(str(payload["url"]))
4943
+ elif payload.get("upload_id"):
4944
+ from adloop.uploads import current_tenant, get_upload_store
4945
+
4946
+ upload, image_bytes = get_upload_store().get(current_tenant(), str(payload["upload_id"]))
4947
+ if upload.sha256 != payload.get("sha256"):
4948
+ raise ValueError(f"upload {upload.upload_id} changed since the preview; draft again.")
4949
+ name_source = PurePosixPath(upload.name)
4919
4950
  elif deployment_mode() == "server":
4920
4951
  # Drafts refuse paths on a server; never read server files even
4921
4952
  # if a path-based plan reached apply some other way.
@@ -1595,6 +1595,40 @@ def _check_media(url: str, kind: str, timeout: int = 10) -> tuple[str | None, st
1595
1595
  return None, None
1596
1596
 
1597
1597
 
1598
+ def _media_problem(url: str, kind: str) -> tuple[str | None, str | None]:
1599
+ """Check a media field: an ``upload:<id>`` reference, or a URL Reddit downloads."""
1600
+ from adloop.uploads import UploadError, public_url_for, upload_ref
1601
+
1602
+ upload_id = upload_ref(url)
1603
+ if upload_id is None:
1604
+ return _check_media(url, kind)
1605
+ if kind == "video":
1606
+ return "is an upload; videos cannot be uploaded to AdLoop, pass a public video URL", None
1607
+ try:
1608
+ public_url_for(upload_id)
1609
+ except UploadError as exc:
1610
+ return f"cannot be used: {exc}", None
1611
+ return None, None
1612
+
1613
+
1614
+ def _resolve_uploads(post: dict) -> dict:
1615
+ """Swap ``upload:<id>`` media references for links Reddit can download now."""
1616
+ from adloop.uploads import public_url_for, upload_ref
1617
+
1618
+ def swap(value: Any) -> Any:
1619
+ upload_id = upload_ref(value) if isinstance(value, str) else None
1620
+ return public_url_for(upload_id) if upload_id else value
1621
+
1622
+ resolved = dict(post)
1623
+ if "thumbnail_url" in resolved:
1624
+ resolved["thumbnail_url"] = swap(resolved["thumbnail_url"])
1625
+ resolved["content"] = [
1626
+ {**item, "media_url": swap(item.get("media_url"))} if isinstance(item, dict) else item
1627
+ for item in resolved.get("content") or []
1628
+ ]
1629
+ return resolved
1630
+
1631
+
1598
1632
  def _carousel_content(
1599
1633
  cards: list | None, *, click_url: str, call_to_action: str, display_url: str, errors: list[str]
1600
1634
  ) -> list[dict]:
@@ -1757,15 +1791,20 @@ def draft_reddit_ad(
1757
1791
  from adloop.ads.write import _validate_urls
1758
1792
 
1759
1793
  landing = list(dict.fromkeys([click_url] + [c["destination_url"] for c in cards]))
1760
- url_errors, url_warnings = _validate_urls(landing + ([image_url] if image_url else []))
1794
+ from adloop.uploads import upload_ref
1795
+
1796
+ image_is_upload = upload_ref(image_url) is not None
1797
+ url_errors, url_warnings = _validate_urls(landing + ([image_url] if image_url and not image_is_upload else []))
1761
1798
  for url, problem in url_errors.items():
1762
1799
  if problem:
1763
1800
  errors.append(f"'{url}' is not reachable: {problem}")
1764
1801
  warnings.extend(url_warnings.values())
1765
1802
  media = [(video_url, "video"), (thumbnail_url, "image")] if post_type == "VIDEO" else []
1766
1803
  media += [(c["media_url"], "image") for c in cards]
1804
+ if image_is_upload:
1805
+ media.append((image_url, "image"))
1767
1806
  for url, kind in media:
1768
- problem, note = _check_media(url, kind)
1807
+ problem, note = _media_problem(url, kind)
1769
1808
  if problem:
1770
1809
  errors.append(f"'{url}' {problem}")
1771
1810
  if note:
@@ -2185,7 +2224,8 @@ def apply_plan(config: AdLoopConfig, plan: ChangePlan) -> dict:
2185
2224
  post_id = str(changes["existing_post_id"])
2186
2225
  post = get_reddit_post(config, post_id) or {"id": post_id}
2187
2226
  else:
2188
- post = data_of(reddit_post(config, f"profiles/{profile_id}/posts", {"data": changes["post"]}))
2227
+ post_payload = _resolve_uploads(changes["post"])
2228
+ post = data_of(reddit_post(config, f"profiles/{profile_id}/posts", {"data": post_payload}))
2189
2229
  post_id = post.get("id")
2190
2230
  if not post_id:
2191
2231
  raise ValueError("Reddit created the post but returned no post id; check Ads Manager.")
@@ -225,6 +225,20 @@ Reddit is a second ad platform with its own connection (own OAuth app, no develo
225
225
  5. `gtm_paused_but_firing` means the event reaches GA4 from somewhere other than the paused tag — hardcoded gtag, another container, or a mobile SDK; don't advise unpausing without finding the real source first
226
226
  6. Remember dynamic-event tags (`{{Event}}`) and Custom HTML tags are opaque to the audit — the tool lists them separately for manual review
227
227
 
228
+ ### Image Uploads
229
+
230
+ For images that have no public URL: a file on the user's computer, or an image they attached in the chat. **Attaching an image to the chat does not get it into an ad:** you can see it, but no tool can receive it. Never suggest attaching as the way in; offer `upload_images` (card or link) or a public image URL, and when the user already attached one, ask them to drop the same file on the upload card.
231
+
232
+ | Tool | When to Use | Key Parameters |
233
+ |------|-------------|----------------|
234
+ | `upload_images` | The user wants such an image in an ad. Apps with AdLoop cards show an upload card; on AdLoop Cloud in other apps the result has an `upload_url` | (none) |
235
+ | `list_uploads` | Find upload ids after the user used the upload link, or to reuse an earlier upload | (none) |
236
+
237
+ - **With the card** (`upload: "card"` in the result): the user drops PNG, JPEG or GIF files (up to 5 MB) on the card; it then posts the upload ids into the chat. Wait for that message instead of asking for a URL.
238
+ - **With an upload link** (`upload_url`): give the user the link (valid 30 minutes) to open in the browser, then call `list_uploads` once they say they are done. A coding agent that has the file on disk can upload it itself: `curl -T "logo.png" "<upload_url>/"`, one call per file.
239
+ - **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.
240
+ - Uploads expire after 24 hours. Self-hosted without a card, pass local files directly (`image_paths`).
241
+
228
242
  ### Planning Tools
229
243
 
230
244
  | Tool | When to Use | Key Parameters |
@@ -245,7 +259,7 @@ Reddit is a second ad platform with its own connection (own OAuth app, no develo
245
259
  | `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. |
246
260
  | `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each |
247
261
  | `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 |
248
- | `draft_image_assets` | Create image assets for a campaign from local files or public image URLs (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server) and/or `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) |
262
+ | `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`) |
249
263
  | `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} |
250
264
  | `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) |
251
265
  | `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` |
@@ -15,6 +15,7 @@ from pydantic import BeforeValidator
15
15
 
16
16
  from adloop import diagnostics
17
17
  from adloop.runtime import current_config
18
+ from adloop.uploads import STORE_UPLOAD_TOOL, UPLOAD_CARD_URI
18
19
  from adloop.safety.approval import (
19
20
  APPROVE_TOOL,
20
21
  CARD_MIME_TYPE,
@@ -144,7 +145,13 @@ def _build_orchestration_instructions() -> str:
144
145
  "rejected too. Per-row problems in an upload are not a rejection: they "
145
146
  "come back as `row_errors` with DRY_RUN_SUCCESS, and the apply would "
146
147
  "carry out the other rows.\n"
147
- "- One change at a time — don't batch unrelated writes.\n\n"
148
+ "- One change at a time — don't batch unrelated writes.\n"
149
+ "- Images without a public URL (a file on the user's computer, or one "
150
+ "attached in the chat): call `upload_images`, which shows an upload card "
151
+ "or returns an upload link, then use the upload ids it yields. An image "
152
+ "attached to the chat cannot be passed to any tool, so don't suggest "
153
+ "attaching as the way to get it into an ad; offer the upload or a public "
154
+ "image URL.\n\n"
148
155
  "PRE-WRITE CHECKS (before any `draft_*`):\n"
149
156
  "- BROAD match keywords require Smart Bidding (MAXIMIZE_CONVERSIONS, "
150
157
  "tCPA, tROAS). Refuse BROAD on MANUAL_CPC. This is the #1 cause of "
@@ -211,6 +218,27 @@ def approval_card() -> str:
211
218
  return card_html()
212
219
 
213
220
 
221
+ @mcp.resource(
222
+ UPLOAD_CARD_URI,
223
+ name="upload_card",
224
+ title="AdLoop image upload",
225
+ description="A drop zone that uploads images to AdLoop for use as ad assets.",
226
+ mime_type=CARD_MIME_TYPE,
227
+ meta={"ui": {"prefersBorder": False}},
228
+ )
229
+ def upload_card() -> str:
230
+ from importlib import resources
231
+
232
+ return resources.files("adloop").joinpath("upload_card.html").read_text(encoding="utf-8")
233
+
234
+
235
+ # App-only tools and the card that calls each one.
236
+ _APP_ONLY_TOOLS = {
237
+ APPROVE_TOOL: "AdLoop approval card",
238
+ STORE_UPLOAD_TOOL: "AdLoop upload card",
239
+ }
240
+
241
+
214
242
  class _AppOnlyToolsForCardClients(Middleware):
215
243
  """List and run app-only tools only for apps that render cards.
216
244
 
@@ -226,10 +254,11 @@ class _AppOnlyToolsForCardClients(Middleware):
226
254
  return [tool for tool in tools if not is_app_only(tool.meta)]
227
255
 
228
256
  async def on_call_tool(self, context, call_next):
229
- if context.message.name == APPROVE_TOOL and not client_shows_cards():
257
+ card = _APP_ONLY_TOOLS.get(context.message.name)
258
+ if card and not client_shows_cards():
230
259
  from fastmcp.exceptions import ToolError
231
260
 
232
- raise ToolError(f"{APPROVE_TOOL} can only be called from the AdLoop approval card.")
261
+ raise ToolError(f"{context.message.name} can only be called from the {card}.")
233
262
  return await call_next(context)
234
263
 
235
264
 
@@ -324,7 +353,7 @@ _API_DOCS: dict[str, str] = {
324
353
 
325
354
 
326
355
  # Write tools that do not produce a preview of their own.
327
- _NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL})
356
+ _NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL, STORE_UPLOAD_TOOL})
328
357
 
329
358
 
330
359
  def _with_approval_card(fn: Callable) -> Callable:
@@ -3684,10 +3713,12 @@ def draft_image_assets(
3684
3713
  image_paths: _StrList = [], # noqa: B006 — mutable default required for MCP JSON schema
3685
3714
  image_urls: _StrList = [], # noqa: B006
3686
3715
  customer_id: str = "",
3716
+ upload_ids: _StrList = [], # noqa: B006
3687
3717
  ) -> dict:
3688
- """Draft campaign image assets from local files or public image URLs — returns a PREVIEW.
3718
+ """Draft campaign image assets from local files, public image URLs or uploads — returns a PREVIEW.
3689
3719
 
3690
- At least one image in total is required across image_paths and image_urls.
3720
+ At least one image in total is required across image_paths, image_urls
3721
+ and upload_ids.
3691
3722
  Images must be PNG, JPEG or GIF and at most 5120 KB (Google's limit).
3692
3723
  URL images are downloaded now and checked; applying re-downloads them
3693
3724
  and refuses if the image changed since the preview. Returns a preview with
@@ -3703,6 +3734,9 @@ def draft_image_assets(
3703
3734
  private, local or internal addresses are refused.
3704
3735
  customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
3705
3736
  the configured account.
3737
+ upload_ids: Ids of images the user uploaded (up_..., from
3738
+ upload_images or list_uploads). The way to use an image that has
3739
+ no public URL.
3706
3740
  """
3707
3741
  from adloop.ads.write import draft_image_assets as _impl
3708
3742
 
@@ -3712,6 +3746,7 @@ def draft_image_assets(
3712
3746
  campaign_id=campaign_id,
3713
3747
  image_paths=image_paths,
3714
3748
  image_urls=image_urls,
3749
+ upload_ids=upload_ids,
3715
3750
  )
3716
3751
 
3717
3752
 
@@ -3988,6 +4023,94 @@ def approve_change(
3988
4023
  return _impl(config, plan_id=plan_id, dry_run=action == "test")
3989
4024
 
3990
4025
 
4026
+ # ---------------------------------------------------------------------------
4027
+ # Image uploads
4028
+ # ---------------------------------------------------------------------------
4029
+ # Images without a public URL reach AdLoop through the upload card (apps with
4030
+ # MCP Apps) or an upload link (hosted, every other app). Tools then take the
4031
+ # upload id: draft_image_assets(upload_ids=...), upload:<id> for Reddit.
4032
+
4033
+
4034
+ @_tool(
4035
+ title="Upload images",
4036
+ annotations=_READONLY,
4037
+ tags={"core"},
4038
+ meta={"ui": {"resourceUri": UPLOAD_CARD_URI}},
4039
+ )
4040
+ @_safe
4041
+ def upload_images() -> dict:
4042
+ """Let the user upload images (PNG, JPEG, GIF up to 5 MB) for ad assets.
4043
+
4044
+ For images without a public URL, such as a file on the user's computer
4045
+ or an image attached in the chat. An attached image cannot reach a tool,
4046
+ so the user drops the same file on the card. Apps that show AdLoop's
4047
+ cards display an upload card where the user drops the files; the card
4048
+ then posts the upload ids into the chat. On AdLoop Cloud in other apps the result
4049
+ carries an upload link for the user (or a coding agent, with curl).
4050
+ Uploads expire after a day. The ids go into upload_ids of
4051
+ draft_image_assets, or as "upload:<id>" into the image fields of
4052
+ draft_reddit_ad. Returns the current uploads and how to add more.
4053
+ """
4054
+ from adloop.uploads import upload_instructions
4055
+
4056
+ return upload_instructions(client_shows_cards())
4057
+
4058
+
4059
+ @_tool(
4060
+ title="Store an uploaded image",
4061
+ annotations=_WRITE,
4062
+ tags={"core"},
4063
+ meta={"ui": {"resourceUri": UPLOAD_CARD_URI, "visibility": ["app"]}},
4064
+ )
4065
+ @_safe
4066
+ def store_image_upload(name: str, data_base64: str) -> dict:
4067
+ """Store an image dropped on the AdLoop upload card.
4068
+
4069
+ Called only by the upload card; AI apps keep this tool out of the
4070
+ model's tool list. Returns the upload's id, type and dimensions.
4071
+
4072
+ Args:
4073
+ name: The file name, used for the asset name.
4074
+ data_base64: The file content, base64-encoded.
4075
+ """
4076
+ import base64
4077
+ import binascii
4078
+
4079
+ from adloop.uploads import UploadError, current_tenant, get_upload_store
4080
+
4081
+ try:
4082
+ data = base64.b64decode(data_base64, validate=True)
4083
+ except (binascii.Error, ValueError):
4084
+ return {"error": "The file did not arrive intact; upload it again."}
4085
+ try:
4086
+ upload = get_upload_store().put(current_tenant(), name, data)
4087
+ except UploadError as exc:
4088
+ return {"error": f"{name}: {exc}"}
4089
+ return {"status": "UPLOADED", **upload.summary()}
4090
+
4091
+
4092
+ @_tool(title="List uploaded images", annotations=_READONLY, tags={"core"})
4093
+ @_safe
4094
+ def list_uploads() -> dict:
4095
+ """List the images the user uploaded to AdLoop that have not expired.
4096
+
4097
+ Returns each upload's upload_id, name, type, dimensions, size and hours
4098
+ until it expires (uploads last a day): the ids of files the user sent
4099
+ through the upload link, or of earlier uploads.
4100
+ """
4101
+ from adloop.uploads import current_tenant, get_upload_store
4102
+
4103
+ uploads = [u.summary() for u in get_upload_store().list(current_tenant())]
4104
+ return {
4105
+ "uploads": uploads,
4106
+ "usage": (
4107
+ "draft_image_assets(upload_ids=[...]) for Google Ads; "
4108
+ "\"upload:<upload_id>\" as image_url, thumbnail_url or a carousel card's "
4109
+ "image_url in draft_reddit_ad (AdLoop Cloud only)."
4110
+ ),
4111
+ }
4112
+
4113
+
3991
4114
  # ---------------------------------------------------------------------------
3992
4115
  # Reddit Ads Tools
3993
4116
  # ---------------------------------------------------------------------------
@@ -0,0 +1,247 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>AdLoop upload</title>
7
+ <style>
8
+ :root { color-scheme: light dark; }
9
+ :root[data-theme="light"] { color-scheme: light; }
10
+ :root[data-theme="dark"] { color-scheme: dark; }
11
+ :root {
12
+ --bg: var(--color-background-primary, #ffffff);
13
+ --bg-2: var(--color-background-secondary, #f4f4f5);
14
+ --fg: var(--color-text-primary, #18181b);
15
+ --fg-2: var(--color-text-secondary, #52525b);
16
+ --border: var(--color-border-primary, #e4e4e7);
17
+ --accent: #2563eb;
18
+ --ok: var(--color-text-success, #15803d);
19
+ --ok-bg: var(--color-background-success, #f0fdf4);
20
+ --bad: var(--color-text-danger, #b91c1c);
21
+ --bad-bg: var(--color-background-danger, #fef2f2);
22
+ --radius: var(--border-radius-lg, 12px);
23
+ --font: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
24
+ --mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
25
+ }
26
+ :root[data-theme="dark"] {
27
+ --bg: var(--color-background-primary, #18181b);
28
+ --bg-2: var(--color-background-secondary, #27272a);
29
+ --fg: var(--color-text-primary, #fafafa);
30
+ --fg-2: var(--color-text-secondary, #a1a1aa);
31
+ --border: var(--color-border-primary, #3f3f46);
32
+ --accent: #3b82f6;
33
+ --ok: var(--color-text-success, #4ade80);
34
+ --ok-bg: var(--color-background-success, #052e16);
35
+ --bad: var(--color-text-danger, #f87171);
36
+ --bad-bg: var(--color-background-danger, #2a0b0b);
37
+ }
38
+ * { box-sizing: border-box; }
39
+ html, body { margin: 0; background: transparent; }
40
+ body { font: 14px/1.45 var(--font); color: var(--fg); }
41
+ .card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
42
+ .head { display: flex; align-items: center; gap: 8px; color: var(--fg-2); font-size: 12px; }
43
+ h1 { font-size: 16px; font-weight: 600; margin: 10px 0 2px; }
44
+ .sub { color: var(--fg-2); font-size: 12px; margin: 0 0 12px; }
45
+ .drop { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 22px 12px; border: 1.5px dashed var(--border); border-radius: calc(var(--radius) - 4px); background: var(--bg-2); text-align: center; cursor: pointer; }
46
+ .drop.over { border-color: var(--accent); }
47
+ .drop small { color: var(--fg-2); font-size: 12px; }
48
+ .drop button { font: inherit; font-size: 13px; font-weight: 500; border-radius: 8px; padding: 6px 12px; border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer; }
49
+ ul { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 6px; }
50
+ li { display: grid; grid-template-columns: 40px 1fr auto; gap: 10px; align-items: center; padding: 6px 8px; border-radius: 8px; background: var(--bg-2); }
51
+ li img, li .ph { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; background: var(--border); }
52
+ li .name { overflow-wrap: anywhere; font-size: 13px; }
53
+ li .meta { color: var(--fg-2); font-size: 12px; font-family: var(--mono); }
54
+ li .state { font-size: 12px; }
55
+ li .state.ok { color: var(--ok); }
56
+ li .state.bad { color: var(--bad); }
57
+ .hidden { display: none !important; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <div class="card">
62
+ <div class="head">
63
+ <svg width="18" height="18" viewBox="0 0 32 32" fill="none" aria-hidden="true"><rect width="32" height="32" rx="7" fill="#09090b"/><path d="M16 16 C 13.5 11.5, 6.5 11.5, 6.5 16 C 6.5 20.5, 13.5 20.5, 16 16 C 18.5 11.5, 25.5 11.5, 25.5 16 C 25.5 20.5, 18.5 20.5, 16 16 Z" stroke="#60a5fa" stroke-width="2.6" stroke-linejoin="round" stroke-linecap="round"/></svg>
64
+ <span data-t="brand">AdLoop · image upload</span>
65
+ </div>
66
+ <h1 data-t="title">Upload images for your ads</h1>
67
+ <p class="sub" data-t="sub">PNG, JPEG or GIF, up to 5 MB each. Uploads are kept for a day.</p>
68
+ <label class="drop" id="drop">
69
+ <span data-t="drop">Drop images here</span>
70
+ <small data-t="or">or</small>
71
+ <button type="button" id="pick" data-t="pick">Choose files</button>
72
+ <input type="file" id="file" accept="image/png,image/jpeg,image/gif" multiple class="hidden">
73
+ </label>
74
+ <ul id="list"></ul>
75
+ </div>
76
+ <script>
77
+ (() => {
78
+ "use strict";
79
+ const TOOL = "store_image_upload";
80
+ const MAX_BYTES = 5120 * 1024;
81
+ const TYPES = ["image/png", "image/jpeg", "image/gif"];
82
+ const STRINGS = {
83
+ en: {
84
+ brand: "AdLoop · image upload", title: "Upload images for your ads",
85
+ sub: "PNG, JPEG or GIF, up to 5 MB each. Uploads are kept for a day.",
86
+ drop: "Drop images here", or: "or", pick: "Choose files",
87
+ uploading: "Uploading…", done: "Uploaded", tooBig: "Over 5 MB", wrongType: "Not PNG, JPEG or GIF",
88
+ message: "I uploaded images to AdLoop:\n{list}\nUse these upload ids for the ad.",
89
+ },
90
+ de: {
91
+ brand: "AdLoop · Bilder hochladen", title: "Bilder für deine Anzeigen hochladen",
92
+ sub: "PNG, JPEG oder GIF, bis 5 MB pro Bild. Uploads werden einen Tag aufbewahrt.",
93
+ drop: "Bilder hierher ziehen", or: "oder", pick: "Dateien auswählen",
94
+ uploading: "Wird hochgeladen…", done: "Hochgeladen", tooBig: "Größer als 5 MB", wrongType: "Kein PNG, JPEG oder GIF",
95
+ message: "Ich habe Bilder bei AdLoop hochgeladen:\n{list}\nNutze diese Upload-IDs für die Anzeige.",
96
+ },
97
+ };
98
+
99
+ let lang = "en";
100
+ document.documentElement.dataset.theme =
101
+ window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
102
+ let nextId = 1;
103
+ const pending = new Map();
104
+ const $ = (id) => document.getElementById(id);
105
+ const t = (key) => (STRINGS[lang] || STRINGS.en)[key] || STRINGS.en[key] || key;
106
+
107
+ function post(message) { window.parent.postMessage(message, "*"); }
108
+ function request(method, params) {
109
+ const id = nextId++;
110
+ post({ jsonrpc: "2.0", id, method, params });
111
+ return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
112
+ }
113
+ function notify(method, params) { post({ jsonrpc: "2.0", method, params }); }
114
+ function reportSize() {
115
+ const el = document.documentElement;
116
+ notify("ui/notifications/size-changed", { width: el.scrollWidth, height: el.scrollHeight });
117
+ }
118
+
119
+ function applyContext(ctx) {
120
+ if (!ctx) return;
121
+ if (ctx.theme === "dark" || ctx.theme === "light") document.documentElement.dataset.theme = ctx.theme;
122
+ const vars = ctx.styles && ctx.styles.variables;
123
+ if (vars) for (const [k, v] of Object.entries(vars)) if (v) document.documentElement.style.setProperty(k, v);
124
+ if (typeof ctx.locale === "string" && ctx.locale.toLowerCase().startsWith("de")) lang = "de";
125
+ for (const el of document.querySelectorAll("[data-t]")) el.textContent = t(el.dataset.t);
126
+ }
127
+
128
+ function payloadOf(result) {
129
+ if (!result) return {};
130
+ if (result.structuredContent && typeof result.structuredContent === "object") return result.structuredContent;
131
+ const text = (result.content || []).filter((c) => c && c.type === "text").map((c) => c.text).join("");
132
+ try { return JSON.parse(text); } catch (_) { return { error: text }; }
133
+ }
134
+
135
+ function addRow(name, src) {
136
+ const li = document.createElement("li");
137
+ const img = document.createElement(src ? "img" : "div");
138
+ if (src) { img.src = src; img.alt = ""; } else { img.className = "ph"; }
139
+ const info = document.createElement("div");
140
+ const n = document.createElement("div"); n.className = "name"; n.textContent = name;
141
+ const m = document.createElement("div"); m.className = "meta";
142
+ info.append(n, m);
143
+ const state = document.createElement("span"); state.className = "state";
144
+ li.append(img, info, state);
145
+ $("list").append(li);
146
+ reportSize();
147
+ return { meta: m, state };
148
+ }
149
+
150
+ function describe(u) {
151
+ return u.upload_id + " · " + u.width + "×" + u.height + " · " + u.size_kb + " KB";
152
+ }
153
+
154
+ function readBase64(file) {
155
+ return new Promise((resolve, reject) => {
156
+ const reader = new FileReader();
157
+ reader.onload = () => resolve(String(reader.result).split(",", 2)[1] || "");
158
+ reader.onerror = () => reject(reader.error || new Error("read failed"));
159
+ reader.readAsDataURL(file);
160
+ });
161
+ }
162
+
163
+ async function upload(files) {
164
+ const stored = [];
165
+ for (const file of files) {
166
+ const row = addRow(file.name, TYPES.includes(file.type) ? URL.createObjectURL(file) : "");
167
+ if (!TYPES.includes(file.type)) { row.state.textContent = t("wrongType"); row.state.classList.add("bad"); continue; }
168
+ if (file.size > MAX_BYTES) { row.state.textContent = t("tooBig"); row.state.classList.add("bad"); continue; }
169
+ row.state.textContent = t("uploading");
170
+ try {
171
+ const result = await request("tools/call", { name: TOOL, arguments: { name: file.name, data_base64: await readBase64(file) } });
172
+ const r = payloadOf(result);
173
+ if ((result && result.isError) || r.error || !r.upload_id) throw new Error(r.error || "upload failed");
174
+ row.meta.textContent = describe(r);
175
+ row.state.textContent = t("done");
176
+ row.state.classList.add("ok");
177
+ stored.push(r);
178
+ } catch (err) {
179
+ row.state.textContent = err && err.message ? err.message : String(err);
180
+ row.state.classList.add("bad");
181
+ }
182
+ reportSize();
183
+ }
184
+ if (stored.length) {
185
+ const list = stored.map((u) => "- " + u.name + ": " + describe(u)).join("\n");
186
+ request("ui/message", { role: "user", content: { type: "text", text: t("message").replace("{list}", list) } }).catch(() => {});
187
+ }
188
+ }
189
+
190
+ const drop = $("drop");
191
+ $("pick").addEventListener("click", (e) => { e.preventDefault(); $("file").click(); });
192
+ $("file").addEventListener("change", () => { upload(Array.from($("file").files || [])); $("file").value = ""; });
193
+ drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("over"); });
194
+ drop.addEventListener("dragleave", () => drop.classList.remove("over"));
195
+ drop.addEventListener("drop", (e) => {
196
+ e.preventDefault();
197
+ drop.classList.remove("over");
198
+ upload(Array.from((e.dataTransfer && e.dataTransfer.files) || []));
199
+ });
200
+
201
+ let shownExisting = false;
202
+ function onToolResult(result) {
203
+ if (shownExisting || !result) return;
204
+ shownExisting = true;
205
+ const r = payloadOf(result);
206
+ for (const u of r.uploads || []) {
207
+ const row = addRow(u.name, "");
208
+ row.meta.textContent = describe(u);
209
+ row.state.textContent = t("done");
210
+ row.state.classList.add("ok");
211
+ }
212
+ }
213
+
214
+ window.addEventListener("message", (event) => {
215
+ if (event.source !== window.parent) return;
216
+ const msg = event.data;
217
+ if (!msg || msg.jsonrpc !== "2.0") return;
218
+ if (msg.id !== undefined && pending.has(msg.id) && !msg.method) {
219
+ const { resolve, reject } = pending.get(msg.id);
220
+ pending.delete(msg.id);
221
+ if (msg.error) reject(new Error(msg.error.message || "error")); else resolve(msg.result);
222
+ return;
223
+ }
224
+ switch (msg.method) {
225
+ case "ui/notifications/tool-result": onToolResult(msg.params); break;
226
+ case "ui/notifications/host-context-changed": applyContext(msg.params); break;
227
+ case "ui/resource-teardown": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
228
+ case "ping": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
229
+ default:
230
+ if (msg.id !== undefined && msg.method) post({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found" } });
231
+ }
232
+ });
233
+
234
+ new ResizeObserver(reportSize).observe(document.documentElement);
235
+ request("ui/initialize", {
236
+ protocolVersion: "2026-01-26",
237
+ appCapabilities: { availableDisplayModes: ["inline"] },
238
+ clientInfo: { name: "AdLoop upload card", version: "1" },
239
+ }).then((result) => {
240
+ applyContext(result && result.hostContext);
241
+ notify("ui/notifications/initialized", {});
242
+ reportSize();
243
+ }).catch(() => notify("ui/notifications/initialized", {}));
244
+ })();
245
+ </script>
246
+ </body>
247
+ </html>
@@ -0,0 +1,286 @@
1
+ """Image uploads: files a user hands to AdLoop without a public URL.
2
+
3
+ AI apps cannot pass a file from the chat to a tool, so an upload reaches
4
+ AdLoop on its own path: the upload card (MCP Apps) calls the app-only
5
+ ``store_image_upload`` tool with the file, or, on a hosted server, the user
6
+ opens a one-time upload link. Either way the image lands in an
7
+ :class:`UploadStore` under an ``up_...`` id, and the tools take that id:
8
+ ``draft_image_assets(upload_ids=[...])`` for Google Ads, ``upload:<id>`` in
9
+ the media fields of ``draft_reddit_ad``.
10
+
11
+ Uploads are short-lived: they expire after a day and exist to be turned
12
+ into ad assets, not to be kept. Self-hosted, the store is a folder under
13
+ ``~/.adloop/uploads``. A hosted runtime sets its own store and the links
14
+ (:func:`set_upload_links`): the upload page and the short-lived public file
15
+ link Reddit downloads from.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import re
24
+ import secrets
25
+ import time
26
+ from dataclasses import asdict, dataclass
27
+ from pathlib import Path
28
+ from typing import Protocol
29
+
30
+ UPLOAD_PREFIX = "upload:"
31
+ UPLOAD_CARD_URI = "ui://adloop/upload-card.html"
32
+ STORE_UPLOAD_TOOL = "store_image_upload"
33
+ MAX_UPLOAD_BYTES = 5120 * 1024 # Google's image asset limit; Reddit takes more
34
+ DEFAULT_TTL_SECONDS = 24 * 3600
35
+ MAX_FILES_PER_TENANT = 50
36
+ MAX_BYTES_PER_TENANT = 100 * 1024 * 1024
37
+ _ID_PATTERN = re.compile(r"^up_[A-Za-z0-9_-]{8,40}$")
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Upload:
42
+ upload_id: str
43
+ name: str
44
+ mime_type: str
45
+ width: int
46
+ height: int
47
+ size_bytes: int
48
+ sha256: str
49
+ created_at: float
50
+ expires_at: float
51
+
52
+ def summary(self) -> dict:
53
+ return {
54
+ "upload_id": self.upload_id,
55
+ "name": self.name,
56
+ "mime_type": self.mime_type,
57
+ "width": self.width,
58
+ "height": self.height,
59
+ "size_kb": round(self.size_bytes / 1024, 1),
60
+ "expires_in_hours": max(0, round((self.expires_at - time.time()) / 3600, 1)),
61
+ }
62
+
63
+
64
+ class UploadError(ValueError):
65
+ """An upload was refused or cannot be found; the message says why."""
66
+
67
+
68
+ class UploadLinks(Protocol):
69
+ def upload_page(self, tenant: str) -> tuple[str, int]:
70
+ """A URL where the user uploads files for ``tenant``, and its lifetime in seconds."""
71
+
72
+ def public_file(self, tenant: str, upload_id: str) -> str:
73
+ """A short-lived public URL serving the upload (for Reddit to download)."""
74
+
75
+
76
+ def _clean_name(name: str) -> str:
77
+ base = os.path.basename(str(name or "").replace("\\", "/")).strip()
78
+ base = re.sub(r"[\x00-\x1f]", "", base)
79
+ return base[:120] or "image"
80
+
81
+
82
+ def inspect_image(data: bytes) -> tuple[str, int, int]:
83
+ """(mime_type, width, height) of a PNG, JPEG or GIF; raises UploadError otherwise."""
84
+ from adloop.ads.write import _detect_image_type_and_size
85
+
86
+ if not data:
87
+ raise UploadError("the file is empty")
88
+ if len(data) > MAX_UPLOAD_BYTES:
89
+ raise UploadError(
90
+ f"the file is {len(data) / 1024:.0f} KB; images can be at most {MAX_UPLOAD_BYTES // 1024} KB"
91
+ )
92
+ try:
93
+ return _detect_image_type_and_size(data)
94
+ except ValueError as exc:
95
+ raise UploadError(f"not a usable image: {exc} (PNG, JPEG or GIF)") from exc
96
+
97
+
98
+ class LocalUploadStore:
99
+ """Uploads as files in a folder, one subfolder per tenant.
100
+
101
+ Expired files are removed whenever the store is used; there is no timer.
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ root: str | Path,
107
+ *,
108
+ ttl_seconds: int = DEFAULT_TTL_SECONDS,
109
+ max_files: int = MAX_FILES_PER_TENANT,
110
+ max_bytes: int = MAX_BYTES_PER_TENANT,
111
+ ) -> None:
112
+ self.root = Path(root)
113
+ self.ttl_seconds = ttl_seconds
114
+ self.max_files = max_files
115
+ self.max_bytes = max_bytes
116
+
117
+ def _dir(self, tenant: str) -> Path:
118
+ key = hashlib.sha256(str(tenant or "local").encode()).hexdigest()[:24]
119
+ return self.root / key
120
+
121
+ def _entries(self, tenant: str) -> list[Upload]:
122
+ folder = self._dir(tenant)
123
+ if not folder.is_dir():
124
+ return []
125
+ now = time.time()
126
+ uploads: list[Upload] = []
127
+ for meta_path in folder.glob("up_*.json"):
128
+ try:
129
+ upload = Upload(**json.loads(meta_path.read_text()))
130
+ except (OSError, ValueError, TypeError):
131
+ continue
132
+ if upload.expires_at <= now:
133
+ self._remove(folder, upload.upload_id)
134
+ continue
135
+ uploads.append(upload)
136
+ return sorted(uploads, key=lambda u: u.created_at)
137
+
138
+ @staticmethod
139
+ def _remove(folder: Path, upload_id: str) -> None:
140
+ for suffix in (".bin", ".json"):
141
+ try:
142
+ (folder / f"{upload_id}{suffix}").unlink()
143
+ except FileNotFoundError:
144
+ pass
145
+
146
+ def put(self, tenant: str, name: str, data: bytes) -> Upload:
147
+ mime_type, width, height = inspect_image(data)
148
+ existing = self._entries(tenant)
149
+ digest = hashlib.sha256(data).hexdigest()
150
+ for upload in existing:
151
+ if upload.sha256 == digest:
152
+ return upload # the same image twice is one upload
153
+ if len(existing) >= self.max_files:
154
+ raise UploadError(
155
+ f"this workspace already holds {len(existing)} uploads; they expire after a day"
156
+ )
157
+ if sum(u.size_bytes for u in existing) + len(data) > self.max_bytes:
158
+ raise UploadError(
159
+ f"uploads are limited to {self.max_bytes // (1024 * 1024)} MB per workspace; "
160
+ "they expire after a day"
161
+ )
162
+ now = time.time()
163
+ upload = Upload(
164
+ upload_id="up_" + secrets.token_urlsafe(12),
165
+ name=_clean_name(name),
166
+ mime_type=mime_type,
167
+ width=width,
168
+ height=height,
169
+ size_bytes=len(data),
170
+ sha256=digest,
171
+ created_at=now,
172
+ expires_at=now + self.ttl_seconds,
173
+ )
174
+ folder = self._dir(tenant)
175
+ folder.mkdir(parents=True, exist_ok=True)
176
+ for suffix, payload in ((".bin", data), (".json", json.dumps(asdict(upload)).encode())):
177
+ tmp = folder / f".{upload.upload_id}{suffix}.tmp"
178
+ tmp.write_bytes(payload)
179
+ os.replace(tmp, folder / f"{upload.upload_id}{suffix}")
180
+ return upload
181
+
182
+ def get(self, tenant: str, upload_id: str) -> tuple[Upload, bytes]:
183
+ upload_id = str(upload_id or "").strip()
184
+ if not _ID_PATTERN.match(upload_id):
185
+ raise UploadError(f"'{upload_id}' is not an upload id (they look like up_...)")
186
+ for upload in self._entries(tenant):
187
+ if upload.upload_id == upload_id:
188
+ data = (self._dir(tenant) / f"{upload_id}.bin").read_bytes()
189
+ return upload, data
190
+ raise UploadError(
191
+ f"upload {upload_id} was not found; uploads expire after a day (see list_uploads)"
192
+ )
193
+
194
+ def list(self, tenant: str) -> list[Upload]:
195
+ return self._entries(tenant)
196
+
197
+
198
+ _store: LocalUploadStore | None = None
199
+ _links: UploadLinks | None = None
200
+
201
+
202
+ def set_upload_store(store: LocalUploadStore | None) -> None:
203
+ global _store
204
+ _store = store
205
+
206
+
207
+ def get_upload_store() -> LocalUploadStore:
208
+ global _store
209
+ if _store is None:
210
+ _store = LocalUploadStore(Path.home() / ".adloop" / "uploads")
211
+ return _store
212
+
213
+
214
+ def set_upload_links(links: UploadLinks | None) -> None:
215
+ """Hosted runtimes: where users upload, and how Reddit downloads uploads."""
216
+ global _links
217
+ _links = links
218
+
219
+
220
+ def get_upload_links() -> UploadLinks | None:
221
+ return _links
222
+
223
+
224
+ def current_tenant() -> str:
225
+ from adloop.runtime import current_tenant as _tenant
226
+
227
+ return _tenant() or ""
228
+
229
+
230
+ def upload_ref(value: str) -> str | None:
231
+ """The upload id in an ``upload:<id>`` reference, else None."""
232
+ text = str(value or "").strip()
233
+ if text.lower().startswith(UPLOAD_PREFIX):
234
+ return text[len(UPLOAD_PREFIX):].strip()
235
+ return None
236
+
237
+
238
+ def public_url_for(upload_id: str) -> str:
239
+ """A URL Reddit can download the upload from; raises UploadError without one."""
240
+ links = get_upload_links()
241
+ if links is None:
242
+ raise UploadError(
243
+ "upload: references need a public link Reddit can download, which only AdLoop "
244
+ "Cloud provides. Self-hosted, pass a public image URL instead."
245
+ )
246
+ get_upload_store().get(current_tenant(), upload_id) # exists and not expired
247
+ return links.public_file(current_tenant(), upload_id)
248
+
249
+
250
+ def upload_instructions(shows_cards: bool) -> dict:
251
+ """What ``upload_images`` returns: current uploads plus the way to add more."""
252
+ tenant = current_tenant()
253
+ result: dict = {"uploads": [u.summary() for u in get_upload_store().list(tenant)]}
254
+ use = (
255
+ "Use the ids as upload_ids in draft_image_assets, or as \"upload:<upload_id>\" in "
256
+ "the image fields of draft_reddit_ad."
257
+ )
258
+ if shows_cards:
259
+ result["upload"] = "card"
260
+ result["instructions"] = (
261
+ "The user sees an AdLoop upload card and drops the images there. The card posts "
262
+ "the upload ids into the chat when it is done; wait for that. " + use
263
+ )
264
+ return result
265
+ links = get_upload_links()
266
+ if links is None:
267
+ result["instructions"] = (
268
+ "This app shows no upload card. Self-hosted, pass the files directly: "
269
+ "draft_image_assets(image_paths=[...]) takes local files; Reddit ads need a "
270
+ "public image URL."
271
+ )
272
+ return result
273
+ url, lifetime = links.upload_page(tenant)
274
+ minutes = max(1, lifetime // 60)
275
+ result.update({
276
+ "upload_url": url,
277
+ "expires_in_minutes": minutes,
278
+ "curl_example": f'curl -T "logo.png" "{url}/"',
279
+ "instructions": (
280
+ f"Give the user this upload link (valid {minutes} minutes): they open it in the "
281
+ "browser and drop the images, then tell you they are done; call list_uploads for "
282
+ "the ids. A coding agent with the files on disk can upload them itself with curl "
283
+ "(curl_example; one call per file). " + use
284
+ ),
285
+ })
286
+ return result
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