adloop 0.22.2__tar.gz → 0.23.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. {adloop-0.22.2 → adloop-0.23.0}/PKG-INFO +2 -2
  2. {adloop-0.22.2 → adloop-0.23.0}/README.md +1 -1
  3. {adloop-0.22.2 → adloop-0.23.0}/pyproject.toml +1 -1
  4. {adloop-0.22.2 → adloop-0.23.0}/pyproject.toml.orig +1 -1
  5. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/config.py +7 -0
  6. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/adloop.md +2 -0
  7. adloop-0.23.0/src/adloop/safety/approval.py +191 -0
  8. adloop-0.23.0/src/adloop/safety/approval_card.html +360 -0
  9. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/server.py +150 -2
  10. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/__init__.py +0 -0
  11. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/__main__.py +0 -0
  12. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/_mcp_patches.py +0 -0
  13. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/__init__.py +0 -0
  14. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/ai_max.py +0 -0
  15. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/brands.py +0 -0
  16. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/client.py +0 -0
  17. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/conversion_actions.py +0 -0
  18. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/currency.py +0 -0
  19. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  20. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/enums.py +0 -0
  21. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/forecast.py +0 -0
  22. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/gaql.py +0 -0
  23. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/image_fetch.py +0 -0
  24. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/pmax.py +0 -0
  25. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/read.py +0 -0
  26. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/validate_only.py +0 -0
  27. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ads/write.py +0 -0
  28. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/auth.py +0 -0
  29. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/cli.py +0 -0
  30. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/crossref.py +0 -0
  31. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/diagnostics.py +0 -0
  32. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/__init__.py +0 -0
  33. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/client.py +0 -0
  34. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/reports.py +0 -0
  35. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/tracking.py +0 -0
  36. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/ga4/write.py +0 -0
  37. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gsc/__init__.py +0 -0
  38. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gsc/client.py +0 -0
  39. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gsc/reports.py +0 -0
  40. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/__init__.py +0 -0
  41. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/client.py +0 -0
  42. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/entities.py +0 -0
  43. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/read.py +0 -0
  44. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/gtm/write.py +0 -0
  45. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/merchant/__init__.py +0 -0
  46. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/merchant/client.py +0 -0
  47. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/merchant/read.py +0 -0
  48. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/pagespeed.py +0 -0
  49. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/__init__.py +0 -0
  50. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/auth.py +0 -0
  51. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/client.py +0 -0
  52. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/read.py +0 -0
  53. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/schedule.py +0 -0
  54. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/reddit/write.py +0 -0
  55. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/__init__.py +0 -0
  56. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  57. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  58. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/create-ad.md +0 -0
  59. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  60. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  61. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  62. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/rules_install.py +0 -0
  63. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/runtime.py +0 -0
  64. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/__init__.py +0 -0
  65. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/audit.py +0 -0
  66. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/guards.py +0 -0
  67. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/safety/preview.py +0 -0
  68. {adloop-0.22.2 → adloop-0.23.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.22.2
3
+ Version: 0.23.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -81,7 +81,7 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
81
81
 
82
82
  - **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
83
83
 
84
- - **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
84
+ - **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation: in Claude, ChatGPT and VS Code the preview is an approval card, and only your click on Apply changes the account (the AI cannot press it). New ads and campaigns start paused.
85
85
 
86
86
  - **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
87
87
 
@@ -53,7 +53,7 @@ AdLoop exists because managing Google Ads alongside your code is a mess. These a
53
53
 
54
54
  - **"Is my tracking even working?"** Compare the event names in your actual codebase against what GA4 is receiving. Find the mismatches: events you fire that GA4 never sees, events GA4 records that you didn't know about.
55
55
 
56
- - **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation. New ads and campaigns start paused.
56
+ - **"I need to create ads but the Google Ads UI is hostile."** Draft responsive search ads, create campaigns, add keywords — all through natural language. Every change shows a preview first. Nothing goes live without your explicit confirmation: in Claude, ChatGPT and VS Code the preview is an approval card, and only your click on Apply changes the account (the AI cannot press it). New ads and campaigns start paused.
57
57
 
58
58
  - **"My landing page gets paid traffic but nobody converts."** AdLoop joins your ad final URLs with GA4 page-level data. See which pages get clicks but no conversions, which have high bounce rates, and which ones are orphaned from any ad campaign.
59
59
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.22.2"
3
+ version = "0.23.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.22.2"
3
+ version = "0.23.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -87,6 +87,12 @@ class SafetyConfig:
87
87
  # (which blocks real writes entirely), this only enforces the order:
88
88
  # preview first, apply second.
89
89
  two_phase_apply: bool = False
90
+ # Approval cards: when the AI app shows AdLoop's approval card (MCP
91
+ # Apps), only a click on the card applies a change, and
92
+ # confirm_and_apply(dry_run=false) from the model is refused. True also
93
+ # accepts the approval in chat, for app surfaces that claim card support
94
+ # but do not render it. Apps without cards always use the chat flow.
95
+ allow_chat_approval: bool = False
90
96
  log_file: str = "~/.adloop/audit.log"
91
97
  blocked_operations: list[str] = field(default_factory=list)
92
98
 
@@ -217,6 +223,7 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
217
223
  max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
218
224
  require_dry_run=safety_raw.get("require_dry_run", True),
219
225
  two_phase_apply=safety_raw.get("two_phase_apply", False),
226
+ allow_chat_approval=safety_raw.get("allow_chat_approval", False),
220
227
  log_file=_text(safety_raw, "log_file", "~/.adloop/audit.log"),
221
228
  blocked_operations=safety_raw.get("blocked_operations", []),
222
229
  ),
@@ -296,6 +296,8 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
296
296
  3. Call `confirm_and_apply(plan_id=..., dry_run=true)` first to test
297
297
  4. Only call with `dry_run=false` after explicit user confirmation
298
298
 
299
+ **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). Apps without cards keep the chat flow above.
300
+
299
301
  **Safety behaviors:**
300
302
  - New campaigns and RSAs (and every Reddit campaign, ad group and ad) are created as PAUSED — user must explicitly enable them after review.
301
303
  - `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting.
@@ -0,0 +1,191 @@
1
+ """Approval cards: a change preview that only a human click applies.
2
+
3
+ AI apps that support MCP Apps (the ``io.modelcontextprotocol/ui``
4
+ extension) render every write tool's preview as AdLoop's approval card,
5
+ with "Test run" and "Apply" buttons. The buttons call ``approve_change``,
6
+ an app-only tool: the MCP Apps spec requires the app to keep it out of the
7
+ model's tool list, AdLoop lists and runs it only for card-capable clients,
8
+ and every call needs the plan's approval code. That code travels in the
9
+ preview result's ``_meta``, which goes to the card and is not meant for the
10
+ model's context, and it is an HMAC the model cannot compute.
11
+
12
+ While a card can be shown, ``confirm_and_apply(dry_run=false)`` from the
13
+ model is refused unless ``safety.allow_chat_approval`` is on, so "the user
14
+ said yes" in the chat is not enough to change an account. Clients without
15
+ the extension keep the chat flow unchanged.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import contextvars
21
+ import hashlib
22
+ import hmac
23
+ import json
24
+ import secrets
25
+ from collections.abc import Iterator
26
+ from contextlib import contextmanager
27
+ from functools import lru_cache
28
+ from importlib import resources
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ if TYPE_CHECKING:
32
+ from adloop.safety.preview import ChangePlan
33
+
34
+ UI_EXTENSION_ID = "io.modelcontextprotocol/ui"
35
+ CARD_URI = "ui://adloop/approval-card.html"
36
+ CARD_MIME_TYPE = "text/html;profile=mcp-app"
37
+ META_KEY = "adloop/approval"
38
+ APPROVE_TOOL = "approve_change"
39
+
40
+ _secret: bytes = secrets.token_bytes(32)
41
+ _card_click: contextvars.ContextVar[bool] = contextvars.ContextVar(
42
+ "adloop_card_click", default=False
43
+ )
44
+
45
+
46
+ def set_approval_secret(secret: bytes) -> None:
47
+ """Key the approval codes.
48
+
49
+ Self-hosted, plans live in memory, so a random key per process is
50
+ enough. A hosted runtime keeps plans across restarts and workers and
51
+ sets a stable key derived from its own secrets.
52
+ """
53
+ global _secret
54
+ if len(secret) < 32:
55
+ raise ValueError("approval secret must be at least 32 bytes")
56
+ _secret = bytes(secret)
57
+
58
+
59
+ def client_shows_cards() -> bool:
60
+ """Whether the calling AI app declared MCP Apps support.
61
+
62
+ Read per request: on the 2026-07-28 protocol the capabilities ride every
63
+ request, so this also works on a stateless server. Anything that cannot
64
+ be read counts as "no cards", which keeps the chat flow.
65
+ """
66
+ try:
67
+ from fastmcp.server.dependencies import get_context
68
+
69
+ return bool(get_context().client_supports_extension(UI_EXTENSION_ID))
70
+ except Exception:
71
+ return False
72
+
73
+
74
+ def approval_code(plan: ChangePlan) -> str:
75
+ """The code the card must present to run or apply ``plan``."""
76
+ from adloop.runtime import current_tenant
77
+
78
+ message = "\x00".join((current_tenant(), plan.plan_id, plan.created_at))
79
+ return hmac.new(_secret, message.encode(), hashlib.sha256).hexdigest()[:32]
80
+
81
+
82
+ def code_matches(plan: ChangePlan, code: str) -> bool:
83
+ return hmac.compare_digest(approval_code(plan), str(code or ""))
84
+
85
+
86
+ def card_click_active() -> bool:
87
+ """True while ``approve_change`` runs a click from the card."""
88
+ return _card_click.get()
89
+
90
+
91
+ @contextmanager
92
+ def card_click() -> Iterator[None]:
93
+ token = _card_click.set(True)
94
+ try:
95
+ yield
96
+ finally:
97
+ _card_click.reset(token)
98
+
99
+
100
+ def chat_apply_refusal(plan_id: str) -> dict | None:
101
+ """The refusal for a model-issued real apply, or None when it may run."""
102
+ from adloop.runtime import current_config
103
+
104
+ if card_click_active() or not client_shows_cards():
105
+ return None
106
+ if current_config().safety.allow_chat_approval:
107
+ return None
108
+ return {
109
+ "status": "APPROVE_ON_CARD",
110
+ "plan_id": plan_id,
111
+ "message": (
112
+ "Nothing was changed. In this app, changes are applied only by the "
113
+ "user's click on the AdLoop approval card shown with the preview "
114
+ "(Test run, then Apply). Ask the user to approve it there; do not "
115
+ "retry confirm_and_apply with dry_run=false. If the card is not "
116
+ "visible in their app, the workspace owner can allow approval in "
117
+ "chat (AdLoop Cloud: Settings → Safety; self-hosted: "
118
+ "safety.allow_chat_approval in the config)."
119
+ ),
120
+ }
121
+
122
+
123
+ def _instructions(plan_id: str) -> str:
124
+ from adloop.runtime import current_config
125
+
126
+ text = (
127
+ "The user sees this preview as an AdLoop approval card with Test run "
128
+ "and Apply buttons, and approves it there. Summarize the change and "
129
+ "any warnings, then let the user decide on the card."
130
+ )
131
+ if current_config().safety.allow_chat_approval:
132
+ return text + (
133
+ " Approval in chat is also allowed: only if the user explicitly "
134
+ f"says so, call confirm_and_apply with plan_id='{plan_id}'."
135
+ )
136
+ return text + (
137
+ " Do not call confirm_and_apply with dry_run=false: in this app it "
138
+ "is refused, only the click applies. A dry run "
139
+ f"(confirm_and_apply plan_id='{plan_id}', dry_run=true) is allowed."
140
+ )
141
+
142
+
143
+ def with_card(result: Any) -> Any:
144
+ """Return a write tool's preview as a card result for card-capable apps.
145
+
146
+ Anything that is not a pending preview (errors, refusals, notes) and
147
+ every result for an app without cards passes through unchanged.
148
+ """
149
+ if not isinstance(result, dict) or result.get("status") != "PENDING_CONFIRMATION":
150
+ return result
151
+ if not client_shows_cards():
152
+ return result
153
+ from adloop.safety.preview import get_plan
154
+
155
+ plan = get_plan(str(result.get("plan_id") or ""))
156
+ if plan is None:
157
+ return result
158
+
159
+ from fastmcp.tools import ToolResult
160
+ from mcp.types import TextContent
161
+
162
+ preview = {**result, "approval": "card", "instructions": _instructions(plan.plan_id)}
163
+ return ToolResult(
164
+ content=[TextContent(type="text", text=json.dumps(preview, default=str))],
165
+ structured_content=preview,
166
+ meta={
167
+ META_KEY: {
168
+ "plan_id": plan.plan_id,
169
+ "code": approval_code(plan),
170
+ "tool": APPROVE_TOOL,
171
+ }
172
+ },
173
+ )
174
+
175
+
176
+ @lru_cache(maxsize=1)
177
+ def card_html() -> str:
178
+ return (
179
+ resources.files("adloop.safety")
180
+ .joinpath("approval_card.html")
181
+ .read_text(encoding="utf-8")
182
+ )
183
+
184
+
185
+ def is_app_only(meta: dict | None) -> bool:
186
+ """True for tools the MCP Apps spec keeps out of the model's tool list."""
187
+ ui = (meta or {}).get("ui")
188
+ if not isinstance(ui, dict):
189
+ return False
190
+ visibility = ui.get("visibility")
191
+ return isinstance(visibility, list) and "model" not in visibility
@@ -0,0 +1,360 @@
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 approval</title>
7
+ <style>
8
+ /* Hosts may send their colours as light-dark() values, which resolve by
9
+ color-scheme; the script sets it from the host theme. */
10
+ :root { color-scheme: light dark; }
11
+ :root[data-theme="light"] { color-scheme: light; }
12
+ :root[data-theme="dark"] { color-scheme: dark; }
13
+ :root {
14
+ --bg: var(--color-background-primary, #ffffff);
15
+ --bg-2: var(--color-background-secondary, #f4f4f5);
16
+ --fg: var(--color-text-primary, #18181b);
17
+ --fg-2: var(--color-text-secondary, #52525b);
18
+ --border: var(--color-border-primary, #e4e4e7);
19
+ --accent: #2563eb;
20
+ --accent-fg: #ffffff;
21
+ --ok: var(--color-text-success, #15803d);
22
+ --ok-bg: var(--color-background-success, #f0fdf4);
23
+ --warn: var(--color-text-warning, #a16207);
24
+ --warn-bg: var(--color-background-warning, #fefce8);
25
+ --bad: var(--color-text-danger, #b91c1c);
26
+ --bad-bg: var(--color-background-danger, #fef2f2);
27
+ --radius: var(--border-radius-lg, 12px);
28
+ --font: var(--font-sans, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
29
+ --mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
30
+ }
31
+ :root[data-theme="dark"] {
32
+ --bg: var(--color-background-primary, #18181b);
33
+ --bg-2: var(--color-background-secondary, #27272a);
34
+ --fg: var(--color-text-primary, #fafafa);
35
+ --fg-2: var(--color-text-secondary, #a1a1aa);
36
+ --border: var(--color-border-primary, #3f3f46);
37
+ --accent: #3b82f6;
38
+ --ok: var(--color-text-success, #4ade80);
39
+ --ok-bg: var(--color-background-success, #052e16);
40
+ --warn: var(--color-text-warning, #facc15);
41
+ --warn-bg: var(--color-background-warning, #2a2205);
42
+ --bad: var(--color-text-danger, #f87171);
43
+ --bad-bg: var(--color-background-danger, #2a0b0b);
44
+ }
45
+ * { box-sizing: border-box; }
46
+ html, body { margin: 0; background: transparent; }
47
+ body { font: 14px/1.45 var(--font); color: var(--fg); }
48
+ .card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
49
+ .hidden { display: none !important; }
50
+ .head { display: flex; align-items: center; gap: 8px; color: var(--fg-2); font-size: 12px; }
51
+ .head svg { flex: none; }
52
+ .badge { margin-left: auto; border: 1px solid var(--border); border-radius: 999px; padding: 1px 8px; font-size: 11px; }
53
+ h1 { font-size: 16px; font-weight: 600; margin: 10px 0 2px; }
54
+ .sub { color: var(--fg-2); font-size: 12px; margin: 0 0 12px; overflow-wrap: anywhere; }
55
+ dl { display: grid; grid-template-columns: minmax(90px, max-content) 1fr; gap: 6px 12px; margin: 0; padding: 12px; background: var(--bg-2); border-radius: calc(var(--radius) - 4px); }
56
+ dt { color: var(--fg-2); font-size: 12px; overflow-wrap: anywhere; }
57
+ dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
58
+ dd.mono { font: 12px/1.4 var(--mono); }
59
+ .more { grid-column: 1 / -1; color: var(--fg-2); font-size: 12px; }
60
+ .note { margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 13px; overflow-wrap: anywhere; }
61
+ .note.warn { color: var(--warn); background: var(--warn-bg); }
62
+ .note.ok { color: var(--ok); background: var(--ok-bg); }
63
+ .note.bad { color: var(--bad); background: var(--bad-bg); }
64
+ .note.info { color: var(--fg-2); background: var(--bg-2); }
65
+ label.ack { display: flex; gap: 8px; align-items: flex-start; margin-top: 12px; font-size: 13px; }
66
+ .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; flex-wrap: wrap; }
67
+ button { font: inherit; font-size: 13px; font-weight: 500; border-radius: 8px; padding: 7px 14px; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--fg); }
68
+ button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
69
+ button.danger { background: var(--bad); border-color: var(--bad); color: #fff; }
70
+ button:disabled { opacity: .45; cursor: default; }
71
+ .steps { display: flex; gap: 6px; margin-top: 12px; font-size: 12px; color: var(--fg-2); }
72
+ .steps span.done { color: var(--ok); }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <div class="card hidden" id="card">
77
+ <div class="head">
78
+ <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>
79
+ <span data-t="brand">AdLoop · approval required</span>
80
+ <span class="badge" id="platform"></span>
81
+ </div>
82
+ <h1 id="title"></h1>
83
+ <p class="sub" id="subtitle"></p>
84
+ <dl id="changes"></dl>
85
+ <div id="warnings"></div>
86
+ <label class="ack hidden" id="ackRow"><input type="checkbox" id="ack"> <span data-t="ack">I understand that this cannot be undone.</span></label>
87
+ <div class="steps"><span id="stepTest" data-t="stepTest">1 · Test run</span><span>→</span><span id="stepApply" data-t="stepApply">2 · Apply</span></div>
88
+ <div id="status"></div>
89
+ <div class="actions" id="actions">
90
+ <button id="discard" data-t="discard">Discard</button>
91
+ <button id="test" class="primary" data-t="test">Test run</button>
92
+ <button id="apply" class="primary hidden" data-t="apply">Apply</button>
93
+ </div>
94
+ </div>
95
+ <script>
96
+ (() => {
97
+ "use strict";
98
+ const META_KEY = "adloop/approval";
99
+ const STRINGS = {
100
+ en: {
101
+ brand: "AdLoop · approval required", ack: "I understand that this cannot be undone.",
102
+ stepTest: "1 · Test run", stepApply: "2 · Apply", discard: "Discard", test: "Test run", apply: "Apply",
103
+ running: "Running the test…", applying: "Applying…",
104
+ testOk: "Test run passed. Nothing was changed yet. Apply makes the change live.",
105
+ testForced: "Live changes are switched off, so only test runs are possible.",
106
+ applied: "Applied.", discarded: "Discarded. Nothing was changed.",
107
+ failed: "Not applied:", more: "more fields not shown",
108
+ msgApplied: "Applied on the AdLoop approval card: {op} (plan {id}).",
109
+ msgDiscarded: "Discarded on the AdLoop approval card: {op} (plan {id}). Nothing was changed.",
110
+ gone: "This change is no longer pending (applied, discarded or expired).",
111
+ },
112
+ de: {
113
+ brand: "AdLoop · Freigabe nötig", ack: "Mir ist klar, dass sich das nicht rückgängig machen lässt.",
114
+ stepTest: "1 · Testlauf", stepApply: "2 · Anwenden", discard: "Verwerfen", test: "Testlauf", apply: "Anwenden",
115
+ running: "Testlauf läuft…", applying: "Wird angewendet…",
116
+ testOk: "Testlauf bestanden. Noch ist nichts geändert. Anwenden macht die Änderung live.",
117
+ testForced: "Echte Änderungen sind ausgeschaltet, daher sind nur Testläufe möglich.",
118
+ applied: "Angewendet.", discarded: "Verworfen. Nichts wurde geändert.",
119
+ failed: "Nicht angewendet:", more: "weitere Felder nicht angezeigt",
120
+ msgApplied: "Auf der AdLoop-Freigabekarte angewendet: {op} (Plan {id}).",
121
+ msgDiscarded: "Auf der AdLoop-Freigabekarte verworfen: {op} (Plan {id}). Nichts wurde geändert.",
122
+ gone: "Diese Änderung ist nicht mehr offen (angewendet, verworfen oder abgelaufen).",
123
+ },
124
+ };
125
+ const PLATFORMS = { google: "Google Ads", reddit: "Reddit Ads", ga4: "Google Analytics", gtm: "Tag Manager" };
126
+ const MAX_ROWS = 14;
127
+
128
+ let lang = "en";
129
+ // Until the host says otherwise, follow the system theme.
130
+ document.documentElement.dataset.theme =
131
+ window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
132
+ let nextId = 1;
133
+ const pending = new Map();
134
+ let plan = null; // { planId, code, tool, preview }
135
+ let busy = false;
136
+
137
+ const $ = (id) => document.getElementById(id);
138
+ const t = (key) => (STRINGS[lang] || STRINGS.en)[key] || STRINGS.en[key] || key;
139
+
140
+ function post(message) { window.parent.postMessage(message, "*"); }
141
+ function request(method, params) {
142
+ const id = nextId++;
143
+ post({ jsonrpc: "2.0", id, method, params });
144
+ return new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
145
+ }
146
+ function notify(method, params) { post({ jsonrpc: "2.0", method, params }); }
147
+
148
+ function applyContext(ctx) {
149
+ if (!ctx) return;
150
+ if (ctx.theme === "dark" || ctx.theme === "light") document.documentElement.dataset.theme = ctx.theme;
151
+ const vars = ctx.styles && ctx.styles.variables;
152
+ if (vars) for (const [k, v] of Object.entries(vars)) if (v) document.documentElement.style.setProperty(k, v);
153
+ if (typeof ctx.locale === "string" && ctx.locale.toLowerCase().startsWith("de")) lang = "de";
154
+ for (const el of document.querySelectorAll("[data-t]")) el.textContent = t(el.dataset.t);
155
+ }
156
+
157
+ function reportSize() {
158
+ const el = document.documentElement;
159
+ notify("ui/notifications/size-changed", { width: el.scrollWidth, height: el.scrollHeight });
160
+ }
161
+
162
+ function humanize(op) {
163
+ const s = String(op || "change").replace(/^(reddit|gtm|ga4)_/, "").replace(/_/g, " ");
164
+ return s.charAt(0).toUpperCase() + s.slice(1);
165
+ }
166
+
167
+ function platformOf(preview) {
168
+ const op = String(preview.operation || "");
169
+ if (preview.platform === "reddit" || op.startsWith("reddit_")) return "reddit";
170
+ if (op.startsWith("gtm_")) return "gtm";
171
+ if (op.includes("key_event")) return "ga4";
172
+ return "google";
173
+ }
174
+
175
+ function formatValue(value) {
176
+ if (value === null || value === undefined) return { text: "–", mono: false };
177
+ if (typeof value === "boolean") return { text: value ? "✓" : "✗", mono: false };
178
+ if (typeof value !== "object") return { text: String(value), mono: false };
179
+ if (Array.isArray(value) && value.every((v) => typeof v !== "object" || v === null)) {
180
+ return { text: value.map(String).join("\n"), mono: false };
181
+ }
182
+ return { text: JSON.stringify(value, null, 2), mono: true };
183
+ }
184
+
185
+ function row(dl, key, value) {
186
+ const dt = document.createElement("dt");
187
+ dt.textContent = String(key).replace(/_/g, " ");
188
+ const dd = document.createElement("dd");
189
+ const v = formatValue(value);
190
+ dd.textContent = v.text;
191
+ if (v.mono) dd.classList.add("mono");
192
+ dl.append(dt, dd);
193
+ }
194
+
195
+ function note(container, kind, text) {
196
+ const div = document.createElement("div");
197
+ div.className = "note " + kind;
198
+ div.textContent = text;
199
+ container.append(div);
200
+ }
201
+
202
+ function render() {
203
+ const p = plan.preview;
204
+ $("platform").textContent = PLATFORMS[platformOf(p)];
205
+ $("title").textContent = humanize(p.operation);
206
+ const sub = [];
207
+ if (p.entity_type) sub.push(String(p.entity_type).replace(/_/g, " ") + (p.entity_id ? " " + p.entity_id : ""));
208
+ if (p.customer_id) sub.push(String(p.customer_id));
209
+ sub.push("plan " + plan.planId);
210
+ $("subtitle").textContent = sub.join(" · ");
211
+
212
+ const dl = $("changes");
213
+ dl.replaceChildren();
214
+ const entries = Object.entries(p.changes || {});
215
+ entries.slice(0, MAX_ROWS).forEach(([k, v]) => row(dl, k, v));
216
+ if (entries.length > MAX_ROWS) {
217
+ const more = document.createElement("div");
218
+ more.className = "more";
219
+ more.textContent = (entries.length - MAX_ROWS) + " " + t("more");
220
+ dl.append(more);
221
+ }
222
+ if (!entries.length) dl.classList.add("hidden");
223
+
224
+ const warnings = $("warnings");
225
+ warnings.replaceChildren();
226
+ const list = [].concat(p.warnings || [], (p.changes && p.changes.warnings) || []);
227
+ for (const w of list) note(warnings, "warn", typeof w === "string" ? w : JSON.stringify(w));
228
+
229
+ $("ackRow").classList.toggle("hidden", !p.requires_double_confirm);
230
+ $("card").classList.remove("hidden");
231
+ updateButtons();
232
+ }
233
+
234
+ let tested = false;
235
+ let finished = false;
236
+ function updateButtons() {
237
+ const needsAck = plan.preview.requires_double_confirm && !$("ack").checked;
238
+ $("test").classList.toggle("hidden", tested || finished);
239
+ $("apply").classList.toggle("hidden", !tested || finished);
240
+ $("apply").classList.toggle("danger", !!plan.preview.requires_double_confirm);
241
+ $("apply").classList.toggle("primary", !plan.preview.requires_double_confirm);
242
+ $("discard").classList.toggle("hidden", finished);
243
+ $("test").disabled = busy;
244
+ $("apply").disabled = busy || needsAck;
245
+ $("discard").disabled = busy;
246
+ $("stepTest").classList.toggle("done", tested);
247
+ $("stepApply").classList.toggle("done", finished && tested);
248
+ reportSize();
249
+ }
250
+
251
+ function setStatus(kind, text) {
252
+ const s = $("status");
253
+ s.replaceChildren();
254
+ if (text) note(s, kind, text);
255
+ reportSize();
256
+ }
257
+
258
+ function resultPayload(result) {
259
+ if (!result) return {};
260
+ if (result.structuredContent && typeof result.structuredContent === "object") return result.structuredContent;
261
+ const text = (result.content || []).filter((c) => c && c.type === "text").map((c) => c.text).join("");
262
+ try { return JSON.parse(text); } catch (_) { return { message: text }; }
263
+ }
264
+
265
+ async function act(action) {
266
+ if (busy || !plan) return;
267
+ busy = true;
268
+ setStatus("info", action === "test" ? t("running") : action === "apply" ? t("applying") : "");
269
+ updateButtons();
270
+ try {
271
+ const result = await request("tools/call", {
272
+ name: plan.tool,
273
+ arguments: { plan_id: plan.planId, code: plan.code, action, acknowledged: $("ack").checked },
274
+ });
275
+ const r = resultPayload(result);
276
+ const status = r.status || "";
277
+ if (result && result.isError || r.error) {
278
+ setStatus("bad", t("failed") + " " + (r.error || r.message || JSON.stringify(r)));
279
+ if (/no pending plan|no longer pending/i.test(String(r.error || ""))) { finished = true; setStatus("info", t("gone")); }
280
+ } else if (action === "test") {
281
+ if (status === "DRY_RUN_SUCCESS") {
282
+ tested = !r.dry_run_forced_by;
283
+ setStatus(r.dry_run_forced_by ? "warn" : "ok", r.dry_run_forced_by ? (r.remediation || t("testForced")) : (t("testOk") + (r.note ? " " + r.note : "")));
284
+ } else {
285
+ setStatus("bad", t("failed") + " " + (r.error || r.message || status));
286
+ }
287
+ } else if (action === "apply") {
288
+ if (status === "APPLIED" || status === "PARTIAL_UPLOAD") {
289
+ finished = true;
290
+ setStatus("ok", t("applied") + (r.message ? " " + r.message : ""));
291
+ tell(t("msgApplied"));
292
+ } else {
293
+ setStatus(status === "DRY_RUN_SUCCESS" ? "warn" : "bad", r.remediation || r.message || r.error || status);
294
+ }
295
+ } else if (action === "discard") {
296
+ finished = true;
297
+ setStatus("info", t("discarded"));
298
+ tell(t("msgDiscarded"));
299
+ }
300
+ } catch (err) {
301
+ setStatus("bad", t("failed") + " " + (err && err.message ? err.message : String(err)));
302
+ } finally {
303
+ busy = false;
304
+ updateButtons();
305
+ }
306
+ }
307
+
308
+ function tell(template) {
309
+ const text = template.replace("{op}", humanize(plan.preview.operation)).replace("{id}", plan.planId);
310
+ request("ui/message", { role: "user", content: { type: "text", text } }).catch(() => {});
311
+ }
312
+
313
+ function onToolResult(result) {
314
+ if (plan || !result) return; // later results come from the card's own calls
315
+ const meta = (result._meta && result._meta[META_KEY]) || null;
316
+ if (!meta || !meta.code || !meta.plan_id) { reportSize(); return; }
317
+ const preview = resultPayload(result);
318
+ plan = { planId: meta.plan_id, code: meta.code, tool: meta.tool || "approve_change", preview };
319
+ render();
320
+ }
321
+
322
+ window.addEventListener("message", (event) => {
323
+ if (event.source !== window.parent) return;
324
+ const msg = event.data;
325
+ if (!msg || msg.jsonrpc !== "2.0") return;
326
+ if (msg.id !== undefined && pending.has(msg.id) && !msg.method) {
327
+ const { resolve, reject } = pending.get(msg.id);
328
+ pending.delete(msg.id);
329
+ if (msg.error) reject(new Error(msg.error.message || "error")); else resolve(msg.result);
330
+ return;
331
+ }
332
+ switch (msg.method) {
333
+ case "ui/notifications/tool-result": onToolResult(msg.params); break;
334
+ case "ui/notifications/host-context-changed": applyContext(msg.params); break;
335
+ case "ui/resource-teardown": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
336
+ case "ping": post({ jsonrpc: "2.0", id: msg.id, result: {} }); break;
337
+ default:
338
+ if (msg.id !== undefined && msg.method) post({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found" } });
339
+ }
340
+ });
341
+
342
+ $("test").addEventListener("click", () => act("test"));
343
+ $("apply").addEventListener("click", () => act("apply"));
344
+ $("discard").addEventListener("click", () => act("discard"));
345
+ $("ack").addEventListener("change", updateButtons);
346
+ new ResizeObserver(reportSize).observe(document.documentElement);
347
+
348
+ request("ui/initialize", {
349
+ protocolVersion: "2026-01-26",
350
+ appCapabilities: { availableDisplayModes: ["inline"] },
351
+ clientInfo: { name: "AdLoop approval card", version: "1" },
352
+ }).then((result) => {
353
+ applyContext(result && result.hostContext);
354
+ notify("ui/notifications/initialized", {});
355
+ reportSize();
356
+ }).catch(() => notify("ui/notifications/initialized", {}));
357
+ })();
358
+ </script>
359
+ </body>
360
+ </html>
@@ -8,12 +8,22 @@ import json
8
8
  from typing import Annotated, Callable
9
9
 
10
10
  from fastmcp import FastMCP
11
+ from fastmcp.server.middleware import Middleware
11
12
  from fastmcp.utilities.docstring_parsing import parse_docstring
12
13
  from mcp.types import Icon, ToolAnnotations
13
14
  from pydantic import BeforeValidator
14
15
 
15
16
  from adloop import diagnostics
16
17
  from adloop.runtime import current_config
18
+ from adloop.safety.approval import (
19
+ APPROVE_TOOL,
20
+ CARD_MIME_TYPE,
21
+ CARD_URI,
22
+ card_html,
23
+ client_shows_cards,
24
+ is_app_only,
25
+ with_card,
26
+ )
17
27
 
18
28
  # openWorldHint: every tool talks to an external service (Google, Reddit,
19
29
  # a live web page), which directories such as ChatGPT's require to be said.
@@ -115,6 +125,11 @@ def _build_orchestration_instructions() -> str:
115
125
  "- Default to `dry_run=true` on `confirm_and_apply`. Only set "
116
126
  "`dry_run=false` after the user explicitly approves the preview. "
117
127
  "`require_dry_run` in config can override this.\n"
128
+ "- Approval cards: in apps that show AdLoop's approval card with a "
129
+ "preview (`approval: \"card\"`), the user approves with Test run and "
130
+ "Apply on the card. Summarize the change and let them decide there; "
131
+ "`confirm_and_apply` with `dry_run=false` is refused in those apps "
132
+ "unless the workspace allows approval in chat.\n"
118
133
  "- Respect the config's `max_daily_budget` cap.\n"
119
134
  "- New Google Ads campaigns and RSAs, and every new Reddit campaign, "
120
135
  "ad group and ad, are created PAUSED. A new Google Ads ad group is "
@@ -179,6 +194,44 @@ mcp = FastMCP(
179
194
  ],
180
195
  )
181
196
 
197
+
198
+
199
+ @mcp.resource(
200
+ CARD_URI,
201
+ name="approval_card",
202
+ title="AdLoop approval card",
203
+ description="Renders a change preview with Test run and Apply buttons.",
204
+ mime_type=CARD_MIME_TYPE,
205
+ meta={"ui": {"prefersBorder": False}},
206
+ )
207
+ def approval_card() -> str:
208
+ return card_html()
209
+
210
+
211
+ class _AppOnlyToolsForCardClients(Middleware):
212
+ """List and run app-only tools only for apps that render cards.
213
+
214
+ The MCP Apps spec makes a card-capable app hide app-only tools from its
215
+ model. An app without the extension knows nothing about that rule and
216
+ would hand them to its model, so they are not offered to it at all.
217
+ """
218
+
219
+ async def on_list_tools(self, context, call_next):
220
+ tools = await call_next(context)
221
+ if client_shows_cards():
222
+ return tools
223
+ return [tool for tool in tools if not is_app_only(tool.meta)]
224
+
225
+ async def on_call_tool(self, context, call_next):
226
+ if context.message.name == APPROVE_TOOL and not client_shows_cards():
227
+ from fastmcp.exceptions import ToolError
228
+
229
+ raise ToolError(f"{APPROVE_TOOL} can only be called from the AdLoop approval card.")
230
+ return await call_next(context)
231
+
232
+
233
+ mcp.add_middleware(_AppOnlyToolsForCardClients())
234
+
182
235
  # name -> tags of every registered tool, so health_check can say which tools
183
236
  # this server offers without reaching into FastMCP internals.
184
237
  _TOOL_TAGS: dict[str, frozenset[str]] = {}
@@ -215,6 +268,20 @@ _API_DOCS: dict[str, str] = {
215
268
  }
216
269
 
217
270
 
271
+ # Write tools that do not produce a preview of their own.
272
+ _NO_CARD_TOOLS = frozenset({"confirm_and_apply", APPROVE_TOOL})
273
+
274
+
275
+ def _with_approval_card(fn: Callable) -> Callable:
276
+ """Hand a write tool's preview to the approval card when the app shows one."""
277
+
278
+ @functools.wraps(fn)
279
+ def wrapper(*args, **kwargs):
280
+ return with_card(fn(*args, **kwargs))
281
+
282
+ return wrapper
283
+
284
+
218
285
  def _tool(*, title: str, annotations: ToolAnnotations, tags: set[str], **kwargs):
219
286
  """Register a tool with its title in both places MCP defines one, and
220
287
  with a link to the API it calls.
@@ -234,13 +301,21 @@ def _tool(*, title: str, annotations: ToolAnnotations, tags: set[str], **kwargs)
234
301
  description = parse_docstring(fn).description or inspect.cleandoc(fn.__doc__ or "")
235
302
  if docs and "https://" not in description:
236
303
  description = f"{description}\n\n{docs}"
237
- _TOOL_TAGS[kwargs.get("name") or fn.__name__] = frozenset(tags)
304
+ name = kwargs.get("name") or fn.__name__
305
+ options = dict(kwargs)
306
+ if not annotations.read_only_hint and name not in _NO_CARD_TOOLS:
307
+ # Write tools return a preview; apps with MCP Apps support
308
+ # render it as the approval card.
309
+ options["meta"] = {**(options.get("meta") or {}), "ui": {"resourceUri": CARD_URI}}
310
+ fn = _with_approval_card(fn)
311
+ if not is_app_only(options.get("meta")):
312
+ _TOOL_TAGS[name] = frozenset(tags)
238
313
  return mcp.tool(
239
314
  title=title,
240
315
  annotations=annotations,
241
316
  tags=tags,
242
317
  description=description,
243
- **kwargs,
318
+ **options,
244
319
  )(fn)
245
320
 
246
321
  return register
@@ -3773,6 +3848,11 @@ def confirm_and_apply(
3773
3848
  APPLY_IN_PROGRESS means an earlier call claimed this plan first, so this
3774
3849
  call executed nothing.
3775
3850
 
3851
+ Approval cards: in AI apps that show AdLoop's approval card with the
3852
+ preview, dry_run=false is REFUSED with status APPROVE_ON_CARD, because
3853
+ only the user's click on the card applies the change. Dry runs still
3854
+ work. The workspace owner can allow approval in chat as well.
3855
+
3776
3856
  Args:
3777
3857
  plan_id: The plan_id returned by a prior draft_*, update_*, add_*,
3778
3858
  pause/enable/remove or other preview tool call. An unknown or
@@ -3781,10 +3861,78 @@ def confirm_and_apply(
3781
3861
  applies the change for real.
3782
3862
  """
3783
3863
  from adloop.ads.write import confirm_and_apply as _impl
3864
+ from adloop.safety.approval import chat_apply_refusal
3784
3865
 
3866
+ if not dry_run:
3867
+ refusal = chat_apply_refusal(plan_id)
3868
+ if refusal is not None:
3869
+ return refusal
3785
3870
  return _impl(current_config(), plan_id=plan_id, dry_run=dry_run)
3786
3871
 
3787
3872
 
3873
+ @_tool(
3874
+ title="Approve a change on its card",
3875
+ annotations=_DESTRUCTIVE,
3876
+ tags={"core"},
3877
+ meta={"ui": {"resourceUri": CARD_URI, "visibility": ["app"]}},
3878
+ )
3879
+ @_safe
3880
+ def approve_change(
3881
+ plan_id: str,
3882
+ code: str,
3883
+ action: str = "test",
3884
+ acknowledged: bool = False,
3885
+ ) -> dict:
3886
+ """Run, apply or discard a previewed change from its approval card.
3887
+
3888
+ Called only by the AdLoop approval card when the user clicks a button;
3889
+ AI apps keep this tool out of the model's tool list.
3890
+
3891
+ Args:
3892
+ plan_id: The plan shown on the card.
3893
+ code: The card's approval code for this plan.
3894
+ action: "test" (dry run), "apply" (real change) or "discard".
3895
+ acknowledged: The user ticked "cannot be undone"; required to apply a
3896
+ plan that needs double confirmation.
3897
+ """
3898
+ from adloop.ads.write import confirm_and_apply as _impl
3899
+ from adloop.safety.approval import card_click, code_matches
3900
+ from adloop.safety.audit import log_mutation
3901
+ from adloop.safety.preview import get_plan, remove_plan
3902
+
3903
+ if action not in {"test", "apply", "discard"}:
3904
+ return {"error": "action must be test, apply or discard."}
3905
+ plan = get_plan(plan_id)
3906
+ if plan is None:
3907
+ return {
3908
+ "error": f"No pending plan '{plan_id}': it is no longer pending "
3909
+ "(applied, discarded or expired)."
3910
+ }
3911
+ if not code_matches(plan, code):
3912
+ return {"error": "This card does not match the change. Draft the change again."}
3913
+ config = current_config()
3914
+ if action == "discard":
3915
+ remove_plan(plan_id)
3916
+ log_mutation(
3917
+ config.safety.log_file,
3918
+ operation=plan.operation,
3919
+ customer_id=plan.customer_id,
3920
+ entity_type=plan.entity_type,
3921
+ entity_id=plan.entity_id,
3922
+ changes=plan.changes,
3923
+ dry_run=True,
3924
+ result="discarded",
3925
+ )
3926
+ return {"status": "DISCARDED", "plan_id": plan_id, "operation": plan.operation}
3927
+ if action == "apply" and plan.requires_double_confirm and not acknowledged:
3928
+ return {
3929
+ "error": "This change cannot be undone: tick the confirmation on the "
3930
+ "card before applying."
3931
+ }
3932
+ with card_click():
3933
+ return _impl(config, plan_id=plan_id, dry_run=action == "test")
3934
+
3935
+
3788
3936
  # ---------------------------------------------------------------------------
3789
3937
  # Reddit Ads Tools
3790
3938
  # ---------------------------------------------------------------------------
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