# AdsAgent MCP Agent Onboarding

> Generated by `scripts/generate_mcp_contract_docs.py` from the server's profile, catalog, OAuth-scope, policy, and result-budget registries. Guide version: `2026-10-05.5`.

Public web guide: `https://adsagent.md/docs/mcp-onboarding`

### Per-ad copy in QuickCreate

For ordinary new ads, shared `overrides.title`, `overrides.body`, and optional
`overrides.description` are defaults. Set different copy on each
`creative_source.creative_overrides[]` entry using its selected `creative_name` (library selection key; internal `creative_id` remains accepted for operators)
and optional `title`, `body`, `description`. Each override must resolve to one selected
creative and appear once. Ambiguous filenames are rejected; select a unique library name. Carousel groups accept
these three fields directly on each `carousel_groups[]` item. Omitted/null
per-ad fields inherit shared/template copy; an empty string clears the field.
The dashboard defaults to **Use same copy for all ads**. Turn it off to edit each ad;
turning it back on submits shared copy and ignores the stored per-ad edits.
Description is link description, separate from body. Resolved copy is frozen
in the prepared draft. Partnership/source-post reuse retains original post copy.


## 0. Connection And Compatibility

- Default: `https://adsagent.md/mcp/v2` (`v2`, stateless JSON HTTP, `49152`-byte response budget).
- Legacy fallback: `https://adsagent.md/mcp` (`legacy_v1`, stateful HTTP, dotted aliases, `262144`-byte response budget).
- Test endpoints: `https://t.adsagent.md/mcp/v2` with `https://t.adsagent.md/mcp` as the legacy fallback.
- Header: `Authorization: Bearer <mcp_token>`.
- V2 `account_get_profile()` identifies the AdsAgent login account bound to these credentials; it accepts no account selector. Its standard OpenAI profile is the only response that exposes this stable account ID and optional email for connection labels. Different logins stay separate; refreshed/reconnected credentials for the same account retain its ID. To connect a different login, choose **Use another account** on the authorization page and approve that account.
- `setup_get_status.website_links` provides account-appropriate website links. Give the user the matching clickable link and label, not menu directions alone: [Creative library](https://adsagent.md/dashboard/creatives/browse) for uploads; [Assets and connections](https://adsagent.md/dashboard/assets/fb-users) for ordinary users' connection/access settings. Full Settings is restricted to accounts with that dashboard tier; use `website_links.settings` and `settings_label` rather than assuming access. Verify the website uses the same AdsAgent account as the plugin; opening a link does not select or switch the plugin account.
- `/mcp/v2` and `/mcp` select AdsAgent Meta product profiles; they are not MCP protocol-version identifiers.
- Let the client negotiate the protocol. MCP `2026-07-28` uses stateless `server/discover`; supported legacy revisions (`2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25`) continue to use `initialize`.
- Prefer the v2 product profile for new Claude, Codex, and other MCP connections. Use `/mcp` only when the client cannot use that product profile. Existing `/mcp` clients remain compatible.
- Keep the same endpoint and bearer across a protocol upgrade. MCP protocol changes alone never require re-registration or token replacement.
- Never synthesize `MCP-Protocol-Version` or `Mcp-Session-Id`; session recovery applies only after a legacy protocol was negotiated.
- After a server update, refresh connection tool metadata and verify `tools/list`, read `adsagent://guide/brief`, and rerun `setup_get_status`. For a ChatGPT developer MCP connection, select Refresh on the connection and start a new conversation; reconnecting alone does not prove the saved catalog refreshed. See [developer metadata refresh](https://developers.openai.com/plugins/deploy/connect-chatgpt#refresh-metadata).
- For BM/account/Page relationships use `assets_lookup_business_links` over cached snapshots. `accounts_get_account_detail` calls live Meta and does not return BM relationships. If the topology tool is missing locally, refresh and verify the client catalog instead of issuing unrelated live reads. Cached visible links do not prove unique ownership.

Reading order: `adsagent://guide/brief` -> `adsagent://guide/catalog` -> one bounded `adsagent://guide/catalog/<domain>` topic. `adsagent://guide/tools` is a large fallback reference and must not be read end-to-end during onboarding.

## 1. OAuth Safe Mode Capability Contract

New OAuth registration and omitted-scope authorization default-request `mcp mcp.optimize.write`; the user must still approve the consent screen. Explicit base-only `scope=mcp` upgrades to the delivery bundle on authorize. Existing grants and refreshes retain their stored scope and are never silently broadened. The server selects policy; the agent cannot self-elevate.

| OAuth scope | Effective capabilities | Added to baseline |
| --- | --- | --- |
| `mcp` | `mcp.read`, `mcp.templates.write`, `mcp.launch.prepare`, `mcp.launch.confirm` | Baseline |
| `mcp.assets.refresh` | `mcp.read`, `mcp.templates.write`, `mcp.launch.prepare`, `mcp.launch.confirm`, `mcp.assets.refresh` | `mcp.assets.refresh` |
| `mcp.insights.pull` | `mcp.read`, `mcp.templates.write`, `mcp.launch.prepare`, `mcp.launch.confirm`, `mcp.insights.pull` | `mcp.insights.pull` |
| `mcp.optimize.write` | `mcp.read`, `mcp.templates.write`, `mcp.launch.prepare`, `mcp.launch.confirm`, `mcp.optimize.write` | `mcp.optimize.write` |
| `mcp.routing.write` | `mcp.read`, `mcp.templates.write`, `mcp.launch.prepare`, `mcp.launch.confirm`, `mcp.routing.write` | `mcp.routing.write` |
| `mcp.operator` | `mcp.read`, `mcp.templates.write`, `mcp.launch.prepare`, `mcp.launch.confirm`, `mcp.operator` | `mcp.operator` |

`mcp.operator` is an explicit override for bounded operator-only tool families, but OAuth responses still hide internal IDs and never expose `insights_query_rows`. Public handles are `product_ref`, `task_ref`, `owner_ref`, external `ad_account_id`, exact template names, and creative selection keys.

Product card names remove all whitespace, including spaces between words (for example, `2 up` becomes `2up`), during creation, save, discovery and rename. Previously issued `product_ref` values remain valid after name cleanup. If names become ambiguous, re-list products and select the matching `product_ref`; never choose the first same-name card.
Use exact names or product_ref. On product_selection_required, choose from the returned public candidates; a brand such as Cardyes may cover several platform/objective cards. Ask for the intended scope when ambiguous; never choose the first card. On product_directory_unavailable, retry the read once later, then preserve support_ref if it persists; do not infer missing products, workspace damage, or lost Meta permissions.

For product account-ban or delivery-health questions, call products_get_health with product_ref; OAuth read access is sufficient. Report each account separately with account_health_checked_at, freshness, and data_coverage. DISABLED is an account observation, not proof of a Page restriction or permanent ban. ACTIVE and historical spend do not prove current delivery. Use accounts_get_account_detail for a live account read when needed; no operator token is needed for either read.

For no-spend or stopped-delivery questions, start with products_get_health for the product. Missing or incomplete Insights stops spend conclusions and metrics-based writes, not independent authorized read-only delivery diagnosis. insights_refresh_disabled blocks only MCP-initiated ledger refresh; backend scheduled pulls are unaffected, and advertised live configuration reads remain available. Use exact ad IDs from health delivery_issues or known task receipts; otherwise use insights_query_overview with group_by=ad, the same product and explicit dates, metadata_contract_version=1, and no metric/status filter so zero-Insights ads are included. Follow pagination before claiming all ads. Preserve inventory coverage and observation time; missing inventory is unknown. Read up to 50 exact ads per overview_get_live_configs call with metadata_contract_version=1, without mutation_ref. An ACTIVE account, Campaign, or Ad Set never proves child ads can deliver. Check each ad's effective_status and delivery_issues: DISAPPROVED is a rejection even when configured_status=ACTIVE and issue_count=0. Report verified blockers separately from unknown spend, only for observed IDs; claim all ads only with complete current inventory and live ID coverage. For each rejected ad, tell the user its review_feedback from the live read (scope, policy and Meta's reason). Only when review_feedback is absent, say Meta returned no reason; never invent a policy violation or exact zero spend.

Internal `task_id` is operator-only. OAuth workflows always use the returned `task_ref`.

Before Meta status/budget/bid changes, inspect `setup_get_status.capabilities.delivery_mutations`. If `allowed=false`, stop. Follow `permission_action`: full dashboard `enable_in_dashboard` uses `/dashboard/settings#mcp-access`; ordinary `permission_action=contact_support` uses returned official support; `reauthorize_with_scope` needs OAuth `mcp.optimize.write`. Never change customer permissions automatically. After the user changes access, reconnect the MCP transport and re-list tools. For ABO use `overview_update_adset_budget` then `overview_update_confirm`. For one CBO change use `overview_update_campaign_budget`; for 2-10 CBO changes use `overview_update_campaign_budgets`, show the complete aggregate summary, then call `overview_update_confirm` once. Never fan out singular CBO prepares. For one ad status change use `overview_update_ad_status`; for 2-10 ad pauses or activations use `overview_update_ad_statuses` under one aggregate approval. Never substitute one budget level for the other. For one known campaign, ad set, or ad, do not scan historical Insights to discover its current status, budget, or bid. Call the matching prepare tool directly: prepare reads the live current value and does not mutate Meta. After `group_by=ad` Insights, pause or activate using each row's `structuredContent.items[].ad_id` and `ad_account_id`; the OAuth markdown table may echo Ad ID for readability but never pause from display names alone (duplicate ad names across accounts require exact IDs). If a single-entity explicit preflight read is needed and `overview_get_live_configs` is present in the client-local catalog, call it with exactly one typed entity and no `mutation_ref`. If that optional selector is absent locally, the matching prepare remains the live preflight; do not report a server-registration failure or disable read-only schedule evaluation. `management=true` or product/date Insights is not live configuration evidence. For a bounded rule or decision over explicit candidate IDs, pass up to 50 exact candidate entities in one `overview_get_live_configs` request and match every result by `entity_type` and `entity_id`. Require complete live coverage before excluding paused candidates or claiming all candidates active. Never infer a child Ad's state from parent status, and keep any reported manual change outside the candidate ID set separate until an exact ID matches.

## 2. First Run

Before any AdsAgent tool or resource call, identify the user's request. Use AdsAgent only for a supported advertising workflow or explicit connection/readiness diagnosis. For unrelated tasks, explain the scope without AdsAgent calls. If asked to reveal, retrieve, or export passwords, bearer tokens, MFA codes, cookies, or other private authentication secrets, refuse directly without tool/resource calls, including setup_get_status. Do not run setup merely to offer alternative help. Explicit connection-health, expiry, or reauthorization diagnosis remains supported; configure credentials on the AdsAgent website through browser authorization. For connection/readiness or credential-expiry diagnosis alone, return the requested connection information and stop; do not discover products or ask for a report's product/date scope.

1. For a supported advertising workflow needing setup or explicit connection/readiness diagnosis, call `setup_get_status`. A new session alone does not require this call.
   Inspect top-level `client_skill_pack` as notify-only policy. If the installed version is unknown, continue silently; never auto-update.
2. Call `products_list` only when product discovery is needed; it is already sorted by recent spend.
3. For a reporting request needing a scope, tell the user the product-card count and ask for one product/account plus `date_from` and `date_to`.
4. If `setup_get_status.capabilities.agent_method_profile.profile_id=adsagent_agent_methods_v1` and its consistent query tool is present in the client-local catalog, call `insights_query_consistent` once with root `query_contract_version=1` and one `scope` or up to 20 `scopes`. If that advertised read tool is missing only from the local catalog, use the profile's named native fallback and do not report a server registration failure.
5. Poll every distinct returned `task_ref` serially with `tasks_get_status(response_mode=compact)`. When terminal, consume its bounded `result` directly only if task `status=completed`, `result.status=complete`, and `result.meta.complete=true`; do not rerun page 1 merely to continue. Stop on a missing/incomplete result or `partial_completed`, `failed`, or `cancelled`. For a next page, preserve every filter and increment only `page`. Set `min_as_of` to the task `result.meta.source_observed_at`, or for an immediate complete response use the corresponding scope overview's `query_contract.coverage.source_observed_at`. Locate a single-scope overview at `data`, and each ordered multi-scope overview at `data.items[].result`; with multiple scopes use the earliest first-page source anchor. Preserve each overview's `meta.inventory_anchor` and pass `inventory_anchors` on page 2+ as one ordered opaque value per original scope. If `continuation_valid=false` or an anchor is rejected, discard all partial rows and restart page 1 serially with the original query.
6. If `agent_method_profile` is absent, or its advertised read tool is missing only from the client-local catalog, preserve native behavior: one scope -> `insights_query_overview`; multiple scopes -> one `insights_query_batch_overview`. Pass `metadata_contract_version=1`; use native consistency only when advertised.
7. Report server totals only when completeness is proven. A missing scope is unknown, never zero.

## 3. Facebook, Google Ads, And TikTok Connect

- Meta, Google Ads, and TikTok: use `setup_begin_channel_connect`, let the human open the returned `authorize_url`, then call `setup_check_channel_connect` once after they confirm authorization. Do not poll OAuth automatically.
- Legacy aliases `setup_begin_facebook_connect` / `connections_create_intent` remain compatible; prefer the unified pair during onboarding.
- Never collect platform passwords, cookies, OAuth codes, or bearer tokens in chat.
- After Meta binding, an asset refresh starts automatically. When the user confirms authorization, check setup_check_channel_connect once; do not poll OAuth automatically. setup_get_status.asset_refresh describes the latest observed refresh/binding task; the connect check is pinned to that connection's binding task. Report its scope, observation time, refresh_summary.updated_assets names/counts, and failed_connections. has_more means the displayed asset list is a bounded sample, not the full inventory. Cached tenant counts or task_status=completed alone do not prove a complete refresh. If a refresh is queued/running, reuse its task_ref instead of resubmitting; tasks_get_status compact/standard retain the summary. Missing newly authorized assets can be refreshed by an operator for the tenant/connection in Ops without waiting for the schedule. A complete refresh only permits continuing target account, Page, Pixel/App, template and writer-permission validation through the advertised prepare tool. It does not guarantee launch eligibility, finish historical data sync, or authorize confirm. Preserve existing setup requirements and explicit user approval. No status read pushes a completion notification or creates ads. When the returned asset-refresh status or launch_readiness says core asset persistence finished but product label analysis or cache update is pending/failed, report that partial outcome and preserve the original observation time and coverage. Label/cache recovery does not upgrade asset freshness, complete coverage or launch eligibility. Recover only the unfinished enrichment through the supported operator path; this failure alone requires neither another Meta asset pull nor OAuth reconnection. A finalization failure with an unverified terminal acknowledgement is not successful completion; retain its task reference for recovery. Never invent public retry arguments.

## 4. Insights And Output Stability

- Never fan out one overview call per product/account. On `mcp_fanout_detected`, stop the loop and follow `data.recommended_tool` plus `data.recommended_mode` for all current and pending scopes.
- Profile mode is opt-in only through `query_contract_version=1` at the request root. Keep `scope` and `scopes` mutually exclusive, preserve input order, and stop on `verification_pending`, `rate_limited`, `data_not_fresh`, `incomplete_coverage`, `failed`, or `complete=false`.
- For 'latest', 'check again', or 'pull the data again', issue a new MCP read with consistency=cached against the latest stored AdsAgent data. Do not reuse conversation rows or an earlier completed task receipt. Start page=1 without old min_as_of, after_mutation_ref, or inventory_anchors; preserve the requested scope, dates, and filters, then anchor pagination within this new read. This does not request a Meta refresh.
- Use read_evidence per metric: a verified stored impressions/clicks total can be reported while unverified Page View remains blank. Sparse empty funnels are not missing ads or zero proof; use the verified server summary. read_ref and fingerprints identify calls/results in audits, not source watermarks or live inventory completeness. Preserve existing decision/verification gates before advertising writes.
- fresh_if_available and require_fresh can enqueue Meta pulls. Use them or explicit pull/asset-refresh tools only for an explicitly requested Meta source sync under the advertised capability. A request for latest MCP data or an advertising decision alone does not request source sync.
- Report metrics_source_observed_at/source watermark and inventory_source_observed_at separately; a query time or newer metrics timestamp does not advance Campaign inventory. inventory_coverage=complete covers the stored snapshot and requested scope; inventory_freshness=current is an age threshold, not proof of inventory observed now. If the re-read still returns old inventory, report its time and unresolved discovery. Do not claim all campaigns paused or an exhaustive running-account count from an older snapshot. A product card is one matching scope, not proof that every objective for the same app/brand was discovered.
- complete=true covers only the requested entity IDs. This read does not enumerate account inventory or discover newly created campaigns; configuration observation time does not advance inventory or metrics.
- Use `require_fresh` only for explicitly requested Meta source sync when `setup_get_status.capabilities.insights_consistency` advertises it. Otherwise use one `cached` tenant-ledger query; explicit cumulative cached windows may cover up to 365 days. Never turn one long read into repeated 31-day refresh calls. An advertised source refresh must obey `capabilities.insights_refresh.max_date_range_days`.
- Candidate filtering stays server-side: keep `page_size<=50`, use allowlisted `filters` combined with AND, and retry a corrected request once when `adsagent_query_invalid` names a public invalid field. Text hierarchy name fields support `contains`/`prefix`/`eq`; hierarchy id fields such as `ad_id` support `eq`/`in`; metrics, budgets, and bids support `gt`/`gte`/`lt`/`lte`/`eq`; status and delivery enums support `eq`/`in`. Legacy `search` and `spend_gt` remain compatible, but do not use `dedupe_by` in new workflows. Use `effective_status` for Meta delivery outcomes and `configured_status` for configured ACTIVE/PAUSED state. With `group_by=ad`, use returned `ad_account_id`, `ad_account_name`, `campaign_id`, `campaign_name`, `adset_id`, `adset_name`, `ad_id`, and `ad_name`; do not prefetch or fan out parents. Before pausing or activating ads from Insights, read each candidate's `structuredContent.items[].ad_id` and `ad_account_id`; the OAuth markdown preview may echo Ad ID for readability but never pause from display names alone. When the user explicitly asks for every matching Ad, preserve each `ad_id` and advance `page` serially while `data.meta.has_more=true`; keep all filters identical and pin `min_as_of` to the first-page source anchor described above. Deduplicate Ad names, classify language, and perform business grouping in the client. Never parallelize pages or enlarge the page. For an explicitly requested large full table, call grouped `insights_export_csv` with the same filters and consume the artifact instead of putting thousands of rows in chat. On `scope_unavailable`, do not infer another workspace/token or Meta permission, a specific missing account, a disabled-account gap, missing date coverage, stale ledger data, or zero spend from that signal alone. Run `setup_get_status` and the matching discovery tool (`products_list` or `accounts_list_linked_accounts`) once. If the same scope remains listed, retry the identical bounded read once; if it persists, stop and preserve `support_ref` for operator review. Never broaden scope or change permissions.
- Read `setup_get_status.support_reporting` before reporting. Submit automatically only when `automatic_allowed=true`; in manual mode submit only after explicit user request or approval; when off or unavailable, preserve the `support_ref` and stop. For an eligible unresolved internal/contract/dependency/verification error, call `support_report_error` once using the `request` wrapper, a stable `idempotency_key`, bounded classifications, and client versions. Never submit prompts, raw arguments, tokens, cookies, authorization, provider responses, or confirmation material. Reporting is observational only and never retries, confirms, resumes, or replays a write. Never report an error returned by either support tool; stop and retain its `support_ref`. Preserve expected validation/auth/permission/business-rule failures without auto-reporting unless repeated. Poll `support_get_report_status` no earlier than `poll_after_seconds` or when the user asks.
- Do not borrow profile behavior from another channel and do not synthesize `query_contract`, completeness, timezone, or freshness in the client. Absence of `agent_method_profile`, or a client-local miss of its advertised read tool, means use the channel's native fallback.
- `freshness_kind=age_only` means only that a pull is recent; it is not mutation coverage. `source_watermark`, `metrics_observed_after_mutation`, and `config_verified_live` are distinct proofs.
- `last_pull_scope=workspace` means `last_pull_status` and `last_pull_failed_account_count` describe the workspace scheduled/manual pull. A global `partial_completed` status does not establish failure of the requested account. `query_contract.coverage.account_coverage` describes this query's qualifying account watermarks, with at most 20 items across all scopes and full-scope counts; it does not evaluate the latest attempt or prove individual ad metrics. Missing metrics remain unknown even with complete account coverage. Stop when the current query or its own refresh task is incomplete or `partial_completed`; global sync diagnostics do not relax that requirement.
- Do not make a decision when the response is `verification_pending` / `data_not_fresh`, the launch date is unknown, or `complete=false`.
- Reference campaigns by ID, not name. Report effective Meta statuses verbatim.
- For website/upload/settings help, include the relevant clickable link in the reply, not just menu directions. Upload images or videos at [Creative library](https://adsagent.md/dashboard/creatives/browse): choose a folder if needed, then click or drop files in Upload Creative; search by filename to verify. Use the same AdsAgent account as the plugin. If this client cannot transfer the file, use this website link; do not claim a chat attachment was uploaded. Ordinary users manage Facebook connections and asset access at [Assets and connections](https://adsagent.md/dashboard/assets/fb-users). Their dashboard has Creatives and Assets; the full Settings page is unavailable. Do not send ordinary users to full Settings or internal/admin routes. For other settings, explain the limitation and use the returned official support contact. When setup_get_status is otherwise needed, use its website_links for this account; do not call setup or inspect the library solely to answer a how-to question. After the user finishes uploading, re-read creatives_list before preparing ads. Empty folder/search/time-filter results do not prove the whole library is empty.
- For Purchase-only breakdown diagnosis, pass `conversion_event="purchase"` on BOTH pull and read, with identical dates, level, product and optional ad IDs. This selects ads by optimization target, including ads with zero purchases, and overrides the saved funnel metric selection. Names do not filter events. Unfiltered requests retain the existing product-wide configured-funnel behavior. Whole-window snapshots require exact dates; old overlapping batches are never added. Read one dimension at a time and follow `next_cursor` until complete before grouping repeated names; reconcile the same ad scope, attribution, currency and observation time.
- To fetch multiple media details/links, call `creatives_get_batch(selection_keys=[<exact names from creatives_list>])` with 1–50 names. Results preserve order; each item has its own status. Follow `next_cursor` with the same ordered names until `complete=true`; completion does not mean every item succeeded. Retry only `read_failed` items; use `creatives_get` for `result_too_large`. Only owned files are resolved, and each page checks ownership again. Signed-link access/expiry rules below apply equally to batch results.
- For original library media, call `creatives_get(selection_key=<exact name from creatives_list>)`. It returns `download_url`, `thumbnail_url`, `download_available` and `download_expires_at`. Agents with HTTP/media support can GET the URL to download or inspect the original file without an AdsAgent bearer or browser session. Local-library links expire after about two hours; re-read after expiry. `download_expires_at` describes only the original file. `thumbnail_url` always expires after about two hours, including external creatives; re-read `creatives_get` to renew it. External-source expiry may be unknown. Missing URLs mean unavailable media. Keep signed URLs private; listing remains metadata-only.
- For unresolved AdsAgent bugs across any workflow, suggest emailing support@adsagent.md with the support_ref (if returned), affected page/tool, occurrence time with timezone, and short reproduction steps. Never include secrets or raw provider payloads. Offer to draft the email; sending requires explicit user approval and is separate from automatic support-report consent.
- Upload timestamps (`created_at`) are ISO 8601; the website displays YYYY-MM-DD HH:mm:ss in browser local time. Use `sort=newest` (default) or `sort=oldest` for server-side upload order; keep sort unchanged through pagination.
- Use `creatives_list(created_from=<inclusive>, created_to=<exclusive>, limit<=50)` with timezone-aware ISO 8601 bounds to discover one recent upload window. Follow `next_cursor` serially with unchanged filters while `meta.has_more=true`. Omit both bounds for the whole library; never infer upload completion from cache age alone.
- Follow `adsagent://guide/metadata-contract`: configured ACTIVE/PAUSED is `configured_status`; provider delivery, review, and rejection outcomes are `effective_status`; additive `delivery_status` plus `delivery_issues` expose normalized inherited blockers without rewriting child state. For campaign/adset/ad entity-existence totals, require `meta.inventory_coverage=complete` and `zero_insights_entities_included=true`; `partial`, `insights_rows_only`, and `unavailable` keep absence unknown. Inventory freshness is independent from Insights freshness. Legacy read `status` mirrors `effective_status`. `data_freshness.accounts_not_refreshing` names ad accounts that the workspace-wide `last_pulled_at` does not cover because every Facebook connection for them expired or was revoked; their days after `since` are missing, not zero, until the user reconnects. Money values are account-currency major units labelled with `currency` and `currency_symbol` (report them with the symbol and code, e.g. `₹1,234.56 INR`); `spend_usd`/`conversion_value_usd` sit alongside and are null for a day without an exchange rate. A total over several currencies has `spend=null` with `by_currency` and USD totals (`spend_usd`, `cpc_usd`, ...): never add amounts in different currencies. `money_basis` says which applies (`account_currency`, `mixed_currencies`, `usd`, `unverified`). `budget_level` is `campaign|adset`, and `bid_strategy`/`optimization_goal` are canonical lower-case values.
- Return metric summaries as horizontal Markdown tables. Do not sum visible rows or infer totals from page length.
- Workspace context is instrumented for these tools: `insights_export_csv`, `insights_query_batch_overview`, `insights_query_consistent`, `insights_query_overview`, `products_get_funnel_values`, `products_list`, `setup_get_status`, `tasks_get_status`, `tasks_latest`, `tasks_list`. Their normal results include an opaque `workspace_ref`; other tools and error/overflow responses may omit it. An absent reference proves neither a workspace match nor a mismatch. Save the reference when present with the exact product/date, task reference, artifact SHA-256, and report receipt. Compare it before combining evidence or resuming a report. A mismatch requires checking the connected AdsAgent workspace; references may also change after signing-key rotation. It is not an authorization credential. A missing product/task in the current connection does not prove deletion or identify another workspace. Discover once and preserve the original task/support reference for operator diagnosis; never substitute a similarly named product, switch accounts automatically, or broaden the query. If the original export task reference or report receipt is unavailable, preserve the support reference for operator correlation of audit records with the exact workspace, product and date. Do not substitute an arbitrary latest artifact from the same workspace.
- Meta funnel counts are sparse. A missing event key is neither proof of a missing ad Insights row nor a verified zero. Do not use the number of ads with event keys as the coverage denominator. Read server-computed `summary.funnel` with the Insights/source coverage and source watermark; preserve unknowns when coverage is unverified. Match the exact event: `landing_page_view` is not interchangeable with Pixel PageView or an AppsFlyer event. The optional row annotation `funnel_observation` describes the returned event evidence: `no_selected_events_returned` explains an empty funnel, `read_unverified` identifies an incomplete event read, and `insights_unavailable` identifies a missing Insights row. These observations prove neither zero nor upstream source coverage; `funnel_read_coverage` describes the query result only. For example, 20 covered ads with events reported on 17 can still have a complete server total; the other three must not be counted as missing ads merely because their selected event maps are empty.
- For Meta event reporting use the product/date-scoped Insights reads; `products_get_funnel_values` provides sparse per-ad counts. `products_get_actions` is paginated configuration discovery only, has no product/date filtering, and cannot recover historical daily totals.
- Use `insights_export_csv` only when explicitly requested. If queued, poll `tasks_get_status` with the returned `task_ref` until `terminal=true`. Compact and standard terminal responses include `result.artifact`; HTTP GET its opaque `download_url` byte-for-byte and never redact, rebuild, decode, or truncate it. Return artifact metadata/link and never raw CSV.
- Oversized normal results are bounded previews. Full tables and files use artifact metadata instead of consuming the agent's context window.

### Meta structured filter fields

| `group_by` | Name fields | Id fields | Numeric fields | Enum fields |
| --- | --- | --- | --- | --- |
| `account` | `ad_account_name` | `ad_account_id` | `clicks`, `conversion_value`, `conversions`, `cpa`, `cpc`, `cpm`, `ctr`, `impressions`, `reach`, `roas`, `spend` | `account_blocker_code`, `currency`, `delivery_issue_codes`, `delivery_status` |
| `campaign` | `ad_account_name`, `campaign_name` | `ad_account_id`, `campaign_id` | `bid_amount`, `clicks`, `conversion_value`, `conversions`, `cpa`, `cpc`, `cpm`, `ctr`, `daily_budget`, `impressions`, `lifetime_budget`, `reach`, `roas`, `spend` | `account_blocker_code`, `bid_strategy`, `budget_level`, `configured_status`, `currency`, `delivery_issue_codes`, `delivery_status`, `effective_status`, `objective`, `product_type`, `status` |
| `adset` | `ad_account_name`, `adset_name`, `campaign_name` | `ad_account_id`, `adset_id`, `app_id`, `campaign_id`, `pixel_id` | `bid_amount`, `clicks`, `conversion_value`, `conversions`, `cpa`, `cpc`, `cpm`, `ctr`, `daily_budget`, `impressions`, `lifetime_budget`, `reach`, `roas`, `spend` | `account_blocker_code`, `bid_strategy`, `billing_event`, `budget_level`, `configured_status`, `conversion_event`, `currency`, `delivery_issue_codes`, `delivery_status`, `effective_status`, `objective`, `optimization_goal`, `product_type`, `status` |
| `ad` | `ad_account_name`, `ad_name`, `adset_name`, `campaign_name` | `ad_account_id`, `ad_id`, `adset_id`, `app_id`, `campaign_id`, `pixel_id` | `bid_amount`, `clicks`, `conversion_value`, `conversions`, `cpa`, `cpc`, `cpm`, `ctr`, `daily_budget`, `impressions`, `lifetime_budget`, `reach`, `roas`, `spend` | `account_blocker_code`, `bid_strategy`, `billing_event`, `budget_level`, `configured_status`, `conversion_event`, `currency`, `delivery_issue_codes`, `delivery_status`, `effective_status`, `objective`, `optimization_goal`, `product_type`, `status` |

Cross-level id filters are downward only. Virtual `entity_id` (`eq`/`in`) resolves server-side.

All conditions are AND. Name operators: `contains`, `prefix`, `eq`; id operators: `eq`, `in`; numeric operators: `gt`, `gte`, `lt`, `lte`, `eq`; enum operators: `eq`, `in`. At most 12 conditions and 20 values per `in` condition.

`effective_status` is Meta's actual delivery/review outcome, including `DISAPPROVED` and `PENDING_REVIEW`. `configured_status` is the configured `ACTIVE`/`PAUSED` state. Legacy `status` remains an alias of `effective_status`.

### Meta metadata contract

```json
{
  "canonical_values": {
    "bid_strategy": "platform_agnostic_lowercase",
    "billing_event": "meta_native_uppercase",
    "budget_level": [
      "campaign",
      "adset"
    ],
    "conversion_event": "lowercase_separate_from_optimization_goal",
    "currency": "uppercase_three_letter_account_currency",
    "delivery_status": {
      "configured": "configured_status",
      "effective": "effective_status",
      "write_target": "target_configured_status"
    },
    "money_unit": "major",
    "normalized_delivery_block_state": {
      "field": "delivery_status",
      "values": [
        "BLOCKED",
        "PENDING",
        "UNKNOWN"
      ]
    },
    "objective": "meta_native_uppercase",
    "optimization_goal": "platform_agnostic_lowercase"
  },
  "public_metadata_contract_version": 1,
  "resource": "adsagent://guide/metadata-contract",
  "rules": {
    "activation": "Pass metadata_contract_version=1 to native Insights/live-config tools, or query_contract_version=1 to insights_query_consistent.",
    "conflicts": "Canonical and legacy write values that differ are rejected.",
    "delivery_block_boundary": "delivery_status is an additive normalized block state. It never replaces configured_status, effective_status, or write targets.",
    "inventory_boundary": "For campaign, adset, and ad reads, inventory_coverage=complete means the stored entity inventory was left-joined before filtering and pagination, including zero-Insights entities. partial, insights_rows_only, and unavailable never prove entity absence or delivery health. Read inventory_freshness and inventory_source_observed_at separately from Insights freshness. Report metrics_source_observed_at/source watermark and inventory_source_observed_at separately; a query time or newer metrics timestamp does not advance Campaign inventory. inventory_coverage=complete covers the stored snapshot and requested scope; inventory_freshness=current is an age threshold, not proof of inventory observed now. If the re-read still returns old inventory, report its time and unresolved discovery. Do not claim all campaigns paused or an exhaustive running-account count from an older snapshot. A product card is one matching scope, not proof that every objective for the same app/brand was discovered.",
    "legacy_scope": "Aliases are accepted only at the exact listed scope.",
    "live_config_scope_boundary": "complete=true covers only the requested entity IDs. This read does not enumerate account inventory or discover newly created campaigns; configuration observation time does not advance inventory or metrics.",
    "metric_zero_boundary": "Live configuration proves configured and delivery state, not spend. Exact zero metrics require metrics_evidence.zero_proven=true; otherwise report no observed metrics without claiming zero. For grouped entity reads, unverified_metric_items covers the full filtered scope, including later pages; a missing count does not prove zero unknown items. If missing metrics leave numeric filter membership unknown, an empty result cannot prove complete coverage or exact zero. filter_membership_complete=false keeps the result incomplete even when all returned rows have observed metrics.",
    "mutation_coverage_boundary": "Mutation coverage applies only when after_mutation_ref was supplied to a metrics query. It never proves live delivery configuration.",
    "new_clients": "Use canonical fields and canonical values only.",
    "no_global_aliases": true,
    "product_discovery_spend_boundary": "products_list recent_spend is a seven-day discovery value, not daily-report evidence. When recent_spend_status=unknown, the numeric zero is a compatibility placeholder, not observed zero spend. Use explicit-date Insights for reports.",
    "product_pagination_boundary": "A multi-account product result is one globally sorted and paginated scope. total_items and source_row_count describe the full filtered scope, not the current page. Preserve its inventory anchor on continuation. metrics_source_observed_at describes contributing ledger rows; empty accounts do not suppress it, but unknown ledger timestamps remain unknown. For a paginated product summary, the time remains unknown unless the returned page covers the full contributing scope. This timestamp is not proof of complete date-range coverage.",
    "product_scope_boundary": "A product scope includes current cached asset bindings as well as accounts with Insights rows in the requested window. An account with no window Insights remains in coverage; missing metrics are unknown and must make a required-complete read incomplete rather than silently shrinking the product scope.",
    "read_write_boundary": "effective_status is an observed delivery outcome; target_configured_status is a requested configured state.",
    "snapshot_persistence_boundary": "mutation_applied=true with snapshot_status=pending means Meta accepted the write but AdsAgent snapshot persistence is unconfirmed. Preserve the applied receipt; older stored status does not prove a rollback. Do not replay the write. configured_status_observed_at timestamps only the winning dated configuration snapshot, independently from inventory or metrics freshness; it never proves effective delivery.",
    "source_boundary": "Insights ledger metadata and live Meta configuration are distinct evidence sources and must remain labeled.",
    "spend_reconciliation_boundary": "Product spend reconciliation compares only the same full product and exact seven-day discovery window. not_comparable means the query window or filters differ; unavailable means a required value could not be read. Null spend is unknown, not zero. Alignment never establishes freshness, complete coverage, authoritative metrics, or zero_proven."
  }
}
```

Queued response shape:

```json
{
  "status": "queued",
  "task_ref": "<opaque public ref>",
  "poll_after_ms": 3000,
  "poll": {
    "tool": "tasks_get_status",
    "arguments": {"task_ref": "<opaque public ref>"}
  }
}
```

```python
import time

poll = resp["poll"]
task = resp
wait_deadline = time.monotonic() + 60  # Example local wait budget.
while not task.get("terminal", False):
    delay_seconds = max(3000, task.get("poll_after_ms", 3000)) / 1000
    if time.monotonic() + delay_seconds > wait_deadline:
        break
    time.sleep(delay_seconds)
    task = call_tool(poll["tool"], poll["arguments"])
    if "terminal" not in task:
        break
```

Wait at least the returned `poll_after_ms` before each status read; this example
uses a three-second fallback and a 60-second local wait budget. Stop on terminal.
If the budget expires, retain the same `task_ref` and resume observation later;
never submit the original operation again to keep waiting. If a call returns an
error instead of task status, follow the recovery contract below before another
read. Polling is observation and never authorizes replaying a write.

## 5. Consequential Actions

To review copy before editing, use `tasks_list_create_history` then
`tasks_get_create_detail` with its default `include_live=false`.
`creation_snapshot.ad_copies` associates each final `ad_id` with creation-time
`title`, `body`, `description`, `captured_at`, and `availability`. The enclosing
snapshot identifies the ad account. This reads stored task evidence, not Meta.
Follow `ad_copies_next_request` until null to read all copy pages. It binds
`copy_generation` and may set `include_live=false` to separate a large live
snapshot from complete copy text. If `creation_copy_snapshot_changed` is
returned, discard accumulated pages and restart at `copy_offset=0` without
`copy_generation`: receipt reconciliation can change even after terminal status.
This is not current copy. Empty strings are recorded blanks; null fields were not
recorded. Unprovable historical associations and unsupported copy tasks/creative
formats remain unavailable. Select the ad ID and proposed new text, then use
`ads_update_copy_prepare` to show live before/after, including any difference
from the creation snapshot. Get approval for that final comparison and confirm
once. Snapshot availability does not establish edit support: existing single
image-link/video restrictions and new creative/post semantics still apply.
`include_live=true` adds adset targeting only, not current ad copy.

If snapshot text is unavailable, `ad_copies[].submitted_copy` exposes saved input
for confirmed successful result IDs, including the successful part of partial
tasks. `source=task_submitted_params` means the task's original submitted text,
not proof of the final outgoing creative. For single/grouped copies,
`source=source_task_submitted_params` and `source_ad_id` identify the source ad's
original submission, not its copy-time text: it may have been edited meanwhile.
Source history is read within the same tenant, with bounded lineage lookup.
Missing, ambiguous, cyclic or unavailable source history remains unavailable.
Failed/planned ads never supply rows. Do not recreate, copy, or prepare an edit
just to inspect saved text. The same copy pagination includes this fallback.

On meta_clone_probe_rate_limited, the post availability check could not proceed or source preflight stopped at request limits; use the returned stage. Honor retry_after_seconds and recovery flags, not an aggregate limit for all concurrent requests. An omitted probe_request_sent means unknown, never false. A prepare response with manual_new_task_allowed=true and fresh_approval_required=true permits preparing the clone again and obtaining fresh explicit approval. A tasks_get_status error with operator_review_required=true and manual_new_task_allowed=false means only this invocation stopped at its probe/preflight; earlier or stale-worker writes remain unverified. Preserve the task reference for operator review before any replacement task. Never automatically retry the task or reuse its confirmation token.

Structural-clone approval freezes the source connection, source tree and business plan. On `mutation_confirm_submission_unknown`, preserve `approval_ref` and any `task_ref`, then read `operations_get_context` for that same approval/task; never replay confirmation, change the frozen plan or prepare a replacement while submission is unknown. An existing task receipt is not proof that queue delivery succeeded. Read the whole-task `structure_reconciliation`, including Campaign, Ad Set and Ad created counts, pending counts and receipt completeness. Unknown or partial receipt coverage remains unknown; zero observed Ads does not prove no parent writes or a completed clone. Preserve every created ID. Follow the returned recovery flags; only permitted fresh approval may contain the unfinished objects under existing destination parents. Never rebuild completed parts of the tree.

For a task error `meta_write_verification_pending`, do not replay THAT task's uncertain ads; other already-prepared independent launches with a different confirm_token may continue after user approval. For `meta_write_verification_ambiguous`, call `operations_get_context(task_ref=...)` and never repeat the write. Follow each returned failure's retry flags; only `manual_new_task_allowed=true` permits preparing a new task with fresh approval and a new confirm token.

Launches and delivery changes use prepare -> sanitized `approval_request.summary` -> explicit user approval -> confirm. Status mutations use `target_configured_status=ACTIVE|PAUSED`; never pass an `effective_status` outcome as a write target. Confirm tokens are exact, single-use values with a 15-minute TTL. Never reconstruct, retry, or automatically confirm them. If a token expires, prepare again, show the new summary, and obtain fresh approval. Use deny to discard.

Before a saved-template write, require `setup_get_status.capabilities.template_mutations.allowed=true` and `required_capability=mcp.templates.write`. If either check fails, stop before the write; never broaden permissions. Use only the tools named in that capability block. The capability block's `inline_contract` is authoritative for clients without MCP Resources. When `guide_resource_required=false`, failure to read `adsagent://guide/catalog/templates` must not block the write; read that resource only when the client exposes it. An exact `templates_get` miss returns `complete=true`, `found=false` and `status=not_found` as a normal read result; it is not operator review and does not itself authorize a write. APP templates reject mismatched goal/event pairs: app_installs and app_install require each other. For AEO set goal=conversions and keep the requested supported non-install conversion_event (for example purchase or subscribe). For VO set goal=revenue and conversion_event=purchase. Preserve the requested optimization event; correct the incompatible goal instead of substituting purchase or app_install. After `templates_create` or `templates_update`, call `templates_get` with the exact `template_name`. For a partnership import, pass the exact `source_ad_id` and optional `source_account_id` without configuration maps; the server re-reads only that Ad and its parent, persists the fixed account/Page/Sponsor binding, and keeps Creator/media preview-only. An Ad-Set-level import may instead pass `source_adset_id`; never pass both source selectors. If a legacy client supplies all three complete configuration maps, those maps are preserved and reverse-engineered source provenance is cleared. A manual complete-map template may retain `source_account_id` without `source_adset_id`; this is an account binding, not proof of a Meta source snapshot. Partial source-plus-map requests are rejected. Treat `templates_list` as bounded screening only: when its `migration_state.persisted=false`, perform the exact-name detail read and repair or re-save if detail is not launch-ready. Continue to QuickCreate prepare only when `template_snapshot.persisted=true`, `template_snapshot.normalization.rejected_paths_complete=true`, `template_snapshot.configuration_coverage.complete=true`, and `template_snapshot.launch_readiness.status=ready_for_prepare`. `template_snapshot.launch_critical_coverage` describes saved coverage; runtime fields may still be supplied by the QuickCreate request and are validated after overrides by prepare. `legacy_projection` and `quarantined` templates remain readable but block prepare until repaired or re-saved. Preserve `template_snapshot_ref` and `template_snapshot_revision` from the approval summary. If confirm returns `template_snapshot_changed`, do not reuse the old confirm token: read the template again, prepare again, show the new summary, and obtain fresh explicit approval. Never infer snapshot identity from `updated_at`. Use `assets_list_instagram_accounts` for cached IG IDs, usernames/names and account relationships from Auto Pull Assets. Cached visibility and old ad identities do not prove current create access. Empty inventory does not mean the selector is unsupported. For ordinary new ads, call `accounts_list_instagram_accounts(ad_account_id, page_id)` and use a returned ID in `campaigns_quick_create` request.overrides.instagram_user_id. Omit it to inherit template ad_params.instagram_user_id; an empty string clears the saved choice and uses Page identity. Source imports preserve ordinary IG identity. Check the selected ID in the approval summary. Applies to image, video, carousel, and multi-media ads in new and append modes; the actual create connection is revalidated before provider writes. Partnership and existing source creatives reject this override.

AdsAgent pushes updates through MCP Events on MCP 2026-07-28 connections: `events/list`, then `events/subscribe` to `notification.created`, `approval.pending`, `approval.expiring` or `task.finished`. Events carry refs only; read details with `notifications_list`, `operations_get_approval` or `tasks_get_status`. An event is never approval: confirm a change only after the user explicitly approves it.

### Notification monitoring contract

Cached asset-health monitoring runs after an asset refresh or `notifications_scan`; that scan does not call Meta directly:

| Notification type | Condition | Severity |
| --- | --- | --- |
| `ad_account_status` | `account_status_not_active` | `warning_or_critical` |
| `ad_account_recharge` | `spend_cap_remaining_below_threshold` | `warning_or_critical` |
| `page_unpublished` | `page_is_not_published` | `critical` |
| `page_ads_restricted` | `page_is_not_promotion_eligible` | `critical` |
| `page_no_advertise_access` | `no_connected_fb_user_has_advertise_access` | `warning` |
| `fb_user_abnormal` | `fb_user_connection_not_active` | `warning_or_critical` |
| `fb_user_disabled` | `create_pull_and_decision_are_all_disabled` | `warning` |
| `fb_user_token_expiring` | `user_token_expires_within_threshold` | `warning` |
| `fb_user_token_expired` | `user_token_has_expired` | `critical` |

Default thresholds: spend-cap remaining <= `50.0` account-currency major units or <= `10%`; USER-token expiry <= `7` days; duplicate alert cooldown `3600` seconds. Product ownership is included when a saved product maps to the affected asset.

Push delivery uses MCP Events (webhook) for: `notification.created`, `approval.pending`, `approval.expiring`, `task.finished`. Events come from AdsAgent's own pulls and scans; they do not stream spend metrics or replace Insights pulls.

When a prepare returns `category=launch_request_incomplete`, `code=adsagent_request_incomplete`, and public `invalid_fields`, correct only those advertised fields and rerun the prepare tool once. Prepare creates no Meta object. If that one correction fails, the rule is redacted/unknown, or `operator_review_required` is returned, stop and preserve `support_ref` for operator handoff. Never reuse or automatically retry a confirm token or consequential write.

On facebook_connection_expired, tell the user directly: "连接已过期，需要重连" (the Facebook connection has expired and must be reconnected). Show connection_label, expired_at when supplied, and action_url. This is authorization_required with operator_review_required=false; do not call it an operator approval block or account ban. Reconnect the named Facebook connection, then retry the read or prepare a fresh action. Never replay a confirmation or assume reconnection succeeded.

For an explicitly requested refresh of one known ad account, call `assets_refresh_ad_account(ad_account_id, page_id?)`. This ordinary OAuth tool uses the Dashboard single-account refresh service and existing create-route eligibility, including optional Page-aware routing. It updates that account snapshot and the selected connection's Page snapshot, without a tenant-wide refresh or changes to Meta assignments. Do not use `assets_list_ad_accounts(refresh=true, search=...)`: filtered refresh requests return `single_account_refresh_required` and never enqueue full refresh. Keep `assets_refresh_all` behind its existing `mcp.assets.refresh` capability. Report returned counts and degraded coverage, then use `accounts_get_account_detail` for live read-route Pixel diagnostics (launch prepare separately validates the intended writer); an empty live adspixels response cannot be fixed by merely refreshing caches and does not prove which BM assignment or authorization is missing.

Facebook reconnection is deduplicated by the tenant and Facebook UID. After authorization, use setup_check_facebook_connect or setup_check_channel_connect and its connection.owner_ref, connection.label, connection.needs_label and optional connection.binding_mode. When binding_mode=reconnected, report that the existing connection was renewed; keep its saved label and settings. Never invent a new label for a labeled connection or treat a tenant-wide unlabeled count as proof that this connection is new. Different UIDs are different connections unless identity mapping is verified; overlapping ad accounts, Pages or names do not prove identity and do not authorize replacement. If the intended connection was not renewed, explain the mismatch instead of claiming replacement by renaming.

On meta_business_advertising_restricted (Meta 10 / 2446325), say the Business Account is not allowed to advertise and direct the user to the affected Business in Meta Business Support Home. Do not diagnose a missing Advertiser role from this error alone or promise that reconnecting fixes it. Connection health, allow_create, account_status=1, Page eligibility and setup readiness do not prove that Meta will accept an Ad Set or Ad write; prepare validates the available evidence, not every later Meta policy check. Preserve partial creation receipts and existing Campaign IDs. After the Business restriction is resolved, prepare only the remaining work with fresh approval, never replay the confirmation or duplicate created parents. An unexecuted second account remains untested, not proven blocked by the first account's error.

When copy prepare returns copy_source_unavailable, the source read failed before a draft was created; this is not an operator approval gate or proof of account disablement. Verify the source ad/account and its authorized Facebook connection. On copy_create_route_unavailable, check the owning connection's expiry and Create permission in AdsAgent FB Users; never bypass these checks. Reauthorize an expired connection before a new prepare. A copy_source_read_incomplete permits one later source-read retry, not a confirm replay.

When confirm first returns `code=mcp_meta_quota_deferred`, recover only if `request_sent=false`, `safe_to_retry=true`, and `operator_review_required=false`. The server has already exhausted its bounded pre-send admission retries and did not send the Graph POST. Treat `plan_recovery.action=stop_remaining_mutations` as a circuit breaker: stop the entire mutation loop immediately and call no later confirm during the blocked window. Preserve `completed_mutations`, `receipts`, and `support_refs`, place only the current item in `not_sent_mutations`, leave later items in `remaining_mutations`, and set `safe_resume_from` to the current item. Honor the largest applicable `retry_after_seconds` plus jitter. After the window, re-prepare only unchanged not-sent and remaining entity/value pairs, show one fresh consolidated approval summary, and resume serially. Never repeat a completed mutation or reuse an old confirm token. Any sent or uncertain write must use operation recovery and must not be replayed.

QuickCreate prepare checks the final Page, Pixel, FB User status, Create switches, and Token before issuing approval, and the worker repeats the check before execution. On `no_create_permission`, direct the user to `https://adsagent.md/dashboard/assets/fb-users` to enable Create on an active connection, then prepare again. Never change customer permissions automatically.

QuickCreate modes stay server-owned. Use `creative_source.media_mode=multi` for a frozen 2-10 item media window, `creative_source.mode=carousel` for ordered 2-10 image cards per group, `interest_pack_mode=random` for prepare-time per-ad-set Audience Pack assignment, and append mode only with its matching target id. Never fan out or re-randomize these choices client-side, and never replace an append target after prepare.

QuickCreate builds web sales/leads conversions (Pixel), web traffic (link clicks or landing-page views: link required, no Pixel), awareness, app promotion, and click-to-message ads whose template destination is one app (`MESSENGER`, `INSTAGRAM_DIRECT` or `WHATSAPP`: Page required, no Pixel or link). Instagram profile-visit ads and multi-app message ads are not built by QuickCreate: copy an existing ad with `copy_ad_quick_copy`. QuickCreate requires `destination.type=web|app`. Web requires `web_url` and forbids `app_id`, `store_url`, and `deep_link`. App requires at least one of `app_id`, `store_url`, or `deep_link` and forbids `web_url`. Follow the machine-readable creation contract; do not infer destination type from a template or URL.

QuickCreate Append supports exactly append_mode=append-campaign with target_campaign_id, or append_mode=append-adset with target_adset_id. append-adset requires execution.campaign_count=1 and execution.adset_count=1 and inherits budget and bid. append-campaign cannot change Campaign budget; Ad Set budget requires an ABO parent. append-campaign creates only Ad Sets and Ads, while append-adset creates only Ads. When execution.statuses is supplied, include only the levels created by that mode: new requires campaign/adset/ad; append-campaign requires adset/ad; append-adset requires ad. Omit statuses entirely to use legacy ACTIVE defaults. Never send status controls for parents that already exist. Never send append_mode=existing, existing_campaign_id, existing_adset_id, product_ref, fb_user_label, folder_name. On adsagent_request_incomplete with public invalid_fields, correct only those fields and rerun prepare once; show the new approval summary and obtain fresh explicit approval before confirm. Never auto-confirm, change permissions, rotate accounts, or replay a confirm token.

After a delivery confirm, consume top-level `verification` and `verification_result` first. The server attempts one same-writer live read inside the confirm response. Only when verification remains pending and its returned `next_action` tool exists locally, call that exact action. If the canonical `operations_get` or `overview_get_live_configs` selector is absent only from the client-local catalog, use `agent_method_profile.native_operation_fallback` (`operations_get_context` for Meta) scoped to the same known entity/run. If no recovery selector is available, preserve `mutation_ref`, report applied-but-pending, keep later runs read-only, and never replay. A local selector miss is client snapshot drift, not an authorization or server-registration failure. Verification is pinned to the FB user that performed the write; if that subject is unavailable, keep pending/not_available and never substitute another token or stored Insights. Use `operations_get(mutation_ref)` when available for a delivery-mutation receipt. For a task error `meta_write_verification_pending` or `meta_write_verification_ambiguous`, use `operations_get_context(task_ref=...)` and never repeat the write. Follow each returned failure's retry flags; only `manual_new_task_allowed=true` permits preparing a new task with fresh approval and a new confirm token. Use `insights_query_consistent(consistency=require_fresh, after_mutation_ref=mutation_ref)` only for explicitly requested Meta post-write metrics sync: `metrics_observed_after_mutation` does not verify delivery configuration.

For a terminal create/copy task, inspect `result.create_reconciliation` before `result.failures`. Require `reconciled=true` before claiming every requested object is accounted for. Use `creative_results` to map each `ad_name` and available `selection_key`/`selection_keys` to its created `ad_id`. When `create_reconciliation.next_action` is present, call that exact bounded read once; use its live configured/effective/delivery fields for current delivery state and never retry the write. Live configuration does not prove spend. Claim exact zero only when an Insights common result has `metrics_evidence.zero_proven=true`; otherwise report no observed metrics and say the exact amount is unproven. `mutation_coverage` applies only when the metrics request supplied `after_mutation_ref`; it never proves live delivery. Treat `configuration.source=approved_task_payload` and `live_verified=false` as approved execution input, not live Meta configuration. A failed auxiliary `adimages` receipt with `workflow_status=recovered_by_url_fallback` has no final-Ad impact and never permits retry or a new task. `operations_get_context(response_mode=compact)` returns receipt totals, reconciliation, and anomalous receipts; request `standard` only when every receipt is needed. Report each bounded `result.failures.items[]` entry with its public `ad_name`, `code`, `message`, and `next_action`; raw Meta errors remain private. Never retry the unchanged write. If `operator_review_required=true` or `unclassified_count>0`, report the known items, stop, and preserve the task/support reference for operator review. A corrected manual retry must be a newly prepared task with fresh user approval.

Bulk Ad writes may be split into configurable, sequential AdsAgent chunks. This is a defensive reliability policy, not proof of a fixed Meta batch limit. Preserve acknowledged objects and receipts across chunks. Only an item with `manual_new_task_allowed=true` may enter a newly prepared task with fresh user approval; pending or ambiguous items must remain in operation recovery.

Copy routing:

- One source ad -> `copy_ad_quick_copy`.
- Campaign or ad set -> `copy_ad_clone_structure`.
- Repeat a previous task -> `campaigns_recreate_from_task`.
- Ask whether to preserve original-post engagement or create new creatives; never guess.
- Partnership/boosted-post sources must preserve the original post. New-creative copy is rejected during prepare before approval; show the bounded warning and do not retry with new creatives.
- To change a website destination on an existing Ad, use `ads_update_url_prepare` (v3: `ads_prepare_change` with `action=update_url`). Review the complete live before/after URL and obtain explicit approval, then use the existing `operations_confirm_approval` / `operations_deny_approval` (v3: `operations` with `action=confirm_approval` / `deny_approval`) and the returned `approval_ref` plus `plan_digest`. Single-image website ads require a verified source image hash (including picture-backed ads resolved from the same creative); single-video web CTA ads are supported. Missing or conflicting image identity and unsupported formats are rejected before a draft. Media, copy, Page/Instagram identity, CTA type, URL tracking tags, name and configured status are preserved. A fresh creative/post may reset engagement and trigger Meta review. An already matching URL creates no draft or write. Applied requires verified live URL and preservation readback. For a fixed batch, prepare each selected Ad and show the full list once; after approval of that exact list, execute the shared confirmation serially for each draft and retain each result. This is not an atomic batch and unknown or partial writes must never be replayed.
- To correct body/title/description on an existing Ad, use `ads_update_copy_prepare` → review live before/after → explicit approval → `ads_update_copy_confirm` (or `ads_update_copy_deny`). Omitted/null fields are preserved; empty strings clear. Single-image/video ads without placement-specific media settings only. Own-tenant Deep Copy single-image link descendants with verified lineage may be recovered on the same Ad ID; the warning explains that a new creative/post is attached and prior post engagement is not transferred. Other existing-post or unsupported shapes return `ad_copy_unsupported_creative` with a bounded `reason_code`/`subcode`, `write_outcome=not_sent`, and `operator_review_required=false` before any draft; do not retry the identical prepare or treat it as an account lock. The Ad ID, media, Page and configured status stay unchanged; a fresh creative/post may reset engagement and trigger review. Applied requires verified live readback. Copy, creative and Page confirms return stage evidence in `write_receipt` on success and error `details` on failure: `write_outcome`, `meta_error`, `diagnostics_availability` and `receipt_persistence_available`. Preserve `operation_ref`/`support_ref`; `accepted_unverified` is not success. Safe reasons never authorize replay, automatic replacement drafts, or leftover-creative deletion. Do not swap the same media as a copy-edit workaround.
- For copy_ad_quick_copy, use campaign_status, adset_status, and ad_status with ACTIVE or PAUSED. With creation_contract_version=3, clone_all requires all three statuses, new_adsets requires adset_status and ad_status, new_ads requires ad_status, and grouped copy requires all three. Omitted required statuses fail before an approval token exists. Legacy single copy defaults ACTIVE; grouped defaults PAUSED (see approval summary and default_applied). status_option belongs only to copy_ad_clone_structure and must not be sent to copy_ad_quick_copy. When grouped_plan is set, put CBO budget_mode/custom_budget on grouped_plan.campaigns[] and ABO budget_mode/custom_budget on every grouped_plan.campaigns[].adsets[]; never send both on the same campaign, never leave an ABO Ad Set on inherit, and never send mode, budget_*, or compliance_* on the request root. Campaign-level custom_budget creates CBO; Ad Set custom_budget creates ABO even when the source campaign is CBO. ABO campaigns (Ad Set custom_budget) may set grouped_plan.campaigns[].is_adset_budget_sharing_enabled=false; true is rejected. Same-node daily_budget/budget_amount aliases are accepted; a positive amount implies custom only when budget_mode is omitted. Conflicting aliases or explicit inherit are rejected; root budgets are never distributed. Do not send the field on CBO or inherit campaigns. Omit it and AdsAgent still sends false on ABO campaign create. To add Ad Sets to an existing Campaign through copy_ad_quick_copy, use mode=new_adsets with target_campaign_id; append_mode belongs to campaigns_quick_create. To add Ads to an existing Ad Set, use mode=new_ads with target_adset_id and omit target_campaign_id. The target Ad Set determines its Campaign and retains its settings. On adsagent_request_incomplete with explicit invalid_fields, correct the listed fields and prepare once again. If invalid_fields_complete=false, the field list is missing, or operator_review_required=true, stop and keep support_ref. Review the new summary before confirm. Copy contract v3 sets `creation_contract_version=3` and explicit `request_mode=single|grouped`. Single mode uses `source_ad_id`, `source_ad_account_id`, `target_ad_account_id`, `campaign_count`, `adset_count`, `ads_per_adset`, and `target_campaign_name`. Grouped mode uses `grouped_plan.campaigns[].campaign_name`, `campaigns[].adsets[].adset_name`, and `campaigns[].adsets[].ads[].source_ad_id`; optional destination Ad names use `ads[].ad_name`. Bare nested `name` remains compatible only at the exact object path and conflicting values are rejected. Read `adsagent://guide/name-contract`; flattened Insights names, source snapshots, naming patterns, selectors, and destination names are distinct roles. The grouped tree supports 1-1-N, 1-N-1, custom layouts, post-preserving versus new-creative modes, and same/cross-account copies as one paused-by-default resumable task. The first ad of the first ad set seeds campaign settings, and the first ad of every ad set seeds that ad set's settings; order them deliberately and verify settings-source ad names in the approval summary. Show the complete summary, obtain one explicit approval, then pass its `cgb_confirm_*` token unchanged to `launch_confirm`.
- Use `countries_override` for explicit included countries. Use `worldwide_override=true` plus `excluded_countries_override` for worldwide-minus-country targeting, and read the frozen `geo_targeting_override` back before approval.

Machine-readable creation model and read-to-write mapping:

```json
{
  "adapter_mappings": {
    "destination.app_id": "meta.promoted_object.application_id",
    "destination.deep_link": "meta.creative.app_link",
    "destination.store_url": "meta.promoted_object.object_store_url",
    "destination.web_url": "meta.creative.link_url"
  },
  "copy_ad_tool_boundaries": {
    "campaigns_quick_create": {
      "append_mode_not_accepted_by": "copy_ad_quick_copy",
      "existing_campaign": {
        "append_mode": "append-campaign",
        "target_field": "target_campaign_id"
      }
    },
    "copy_ad_clone_structure": {
      "status_field": "status_option",
      "status_option_not_accepted_by": "copy_ad_quick_copy"
    },
    "copy_ad_quick_copy": {
      "existing_campaign": {
        "mode": "new_adsets",
        "target_field": "target_campaign_id"
      },
      "status_fields": {
        "ad": "ad_status",
        "adset": "adset_status",
        "allowed": [
          "ACTIVE",
          "PAUSED"
        ],
        "campaign": "campaign_status"
      },
      "unexpected_fields": [
        "status_option"
      ]
    }
  },
  "creation_contract_version": 3,
  "grouped_copy_canonical_example": {
    "ad_status": "PAUSED",
    "adset_status": "PAUSED",
    "campaign_status": "PAUSED",
    "creation_contract_version": 3,
    "grouped_plan": {
      "campaigns": [
        {
          "adsets": [
            {
              "ads": [
                {
                  "ad_name": "<ad name>",
                  "source_ad_id": "<source ad id>"
                }
              ],
              "adset_name": "<ad set name>",
              "compliance_beneficiary": "<beneficiary>",
              "compliance_payor": "<payor>"
            }
          ],
          "budget_mode": "custom",
          "campaign_name": "<campaign name>",
          "custom_budget": 50
        }
      ],
      "source_ad_account_id": "<source ad account id>",
      "target_ad_account_id": "<target ad account id>"
    },
    "request_mode": "grouped"
  },
  "grouped_plan_canonical_example": {
    "campaigns": [
      {
        "adsets": [
          {
            "ads": [
              {
                "ad_name": "<ad name>",
                "source_ad_id": "<source ad id>"
              }
            ],
            "adset_name": "<ad set name>",
            "compliance_beneficiary": "<beneficiary>",
            "compliance_payor": "<payor>"
          }
        ],
        "budget_mode": "custom",
        "campaign_name": "<campaign name>",
        "custom_budget": 50
      }
    ],
    "source_ad_account_id": "<source ad account id>",
    "target_ad_account_id": "<target ad account id>"
  },
  "path_scoped_fields": [
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "request_mode",
      "conflict_policy": "must_match_single_or_grouped_payload_shape",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(single,grouped)"
    },
    {
      "accepted_legacy_aliases": [
        "ad_id"
      ],
      "canonical_field": "source_ad_id",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "campaign_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 3,
      "scoped_json_path": "grouped_plan.campaigns[]",
      "sensitive_classification": "business_text",
      "value_type": "non_blank_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "adset_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 3,
      "scoped_json_path": "grouped_plan.campaigns[].adsets[]",
      "sensitive_classification": "business_text",
      "value_type": "optional_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "ad_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 3,
      "scoped_json_path": "grouped_plan.campaigns[].adsets[].ads[]",
      "sensitive_classification": "business_text",
      "value_type": "optional_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "ad_id"
      ],
      "canonical_field": "source_ad_id",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "grouped_plan.campaigns[].adsets[].ads[]",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "camp_num",
        "num_campaigns"
      ],
      "canonical_field": "campaign_count",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count"
    },
    {
      "accepted_legacy_aliases": [
        "adgroup_count",
        "adset_num",
        "num_adsets"
      ],
      "canonical_field": "adset_count",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count"
    },
    {
      "accepted_legacy_aliases": [
        "ad_num",
        "num_ads_per_adset"
      ],
      "canonical_field": "ads_per_adset",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count_per_parent"
    },
    {
      "accepted_legacy_aliases": [
        "ad_account_id"
      ],
      "canonical_field": "source_ad_account_id",
      "conflict_policy": "reject_if_values_differ_after_act_prefix_normalization",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_account_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "target_account_id"
      ],
      "canonical_field": "target_ad_account_id",
      "conflict_policy": "reject_if_values_differ_after_act_prefix_normalization",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_ad_account_identifier"
    },
    {
      "accepted_legacy_aliases": [
        "campaign_name_override",
        "campaign_name"
      ],
      "canonical_field": "target_campaign_name",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "business_text",
      "value_type": "optional_display_name"
    },
    {
      "accepted_legacy_aliases": [
        "country_override"
      ],
      "canonical_field": "countries_override",
      "conflict_policy": "canonical_array_wins_only_when_legacy_is_absent_or_equal",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "copy_ad.single",
      "sensitive_classification": "business_targeting",
      "value_type": "iso_3166_1_alpha_2_array"
    },
    {
      "accepted_legacy_aliases": [
        "adgroup_count"
      ],
      "canonical_field": "adset_count",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "integer_count"
    },
    {
      "accepted_legacy_aliases": [
        "budget_level",
        "owner"
      ],
      "canonical_field": "budget.level",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(campaign,adset)"
    },
    {
      "accepted_legacy_aliases": [
        "budget_type"
      ],
      "canonical_field": "budget.type",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(daily,lifetime)"
    },
    {
      "accepted_legacy_aliases": [
        "budget_amount"
      ],
      "canonical_field": "budget.amount",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "business_value",
      "value_type": "positive_money_major_units"
    },
    {
      "accepted_legacy_aliases": [
        "bid_strategy"
      ],
      "canonical_field": "bid.strategy",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "bid_strategy"
    },
    {
      "accepted_legacy_aliases": [
        "bid_cap"
      ],
      "canonical_field": "bid.cap",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "business_value",
      "value_type": "optional_positive_money_major_units"
    },
    {
      "accepted_legacy_aliases": [
        "campaign_status"
      ],
      "canonical_field": "statuses.campaign",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(ACTIVE,PAUSED)"
    },
    {
      "accepted_legacy_aliases": [
        "adset_status"
      ],
      "canonical_field": "statuses.adset",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(ACTIVE,PAUSED)"
    },
    {
      "accepted_legacy_aliases": [
        "ad_status"
      ],
      "canonical_field": "statuses.ad",
      "conflict_policy": "reject_if_values_differ",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.execution",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(ACTIVE,PAUSED)"
    },
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "destination.type",
      "conflict_policy": "web_app_fields",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "non_sensitive",
      "value_type": "enum(web,app)"
    },
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "destination.web_url",
      "conflict_policy": "distinct_from_store_url_and_deep_link",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_url",
      "value_type": "http_url"
    },
    {
      "accepted_legacy_aliases": [
        "overrides.store_url"
      ],
      "canonical_field": "destination.store_url",
      "conflict_policy": "distinct_from_web_url_and_deep_link",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_url",
      "value_type": "http_url"
    },
    {
      "accepted_legacy_aliases": [
        "overrides.deeplink"
      ],
      "canonical_field": "destination.deep_link",
      "conflict_policy": "distinct_from_web_url_and_store_url",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_url",
      "value_type": "application_deep_link"
    },
    {
      "accepted_legacy_aliases": [
        "creative_names"
      ],
      "canonical_field": "creative_source.creative_names",
      "conflict_policy": "reject_multiple_source_modes",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_text",
      "value_type": "creative_display_name_array"
    },
    {
      "accepted_legacy_aliases": [
        "partnership_rows"
      ],
      "canonical_field": "creative_source.partnership_rows",
      "conflict_policy": "reject_multiple_source_modes",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_payload",
      "value_type": "partnership_row_array"
    },
    {
      "accepted_legacy_aliases": [
        "carousel_groups"
      ],
      "canonical_field": "creative_source.carousel_groups",
      "conflict_policy": "reject_multiple_source_modes",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create",
      "sensitive_classification": "business_payload",
      "value_type": "carousel_group_array"
    },
    {
      "accepted_legacy_aliases": [
        "name"
      ],
      "canonical_field": "ad_name",
      "conflict_policy": "reject_if_both_present",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.creative_source.carousel_groups[]",
      "sensitive_classification": "business_text",
      "value_type": "non_blank_display_name"
    },
    {
      "accepted_legacy_aliases": [],
      "canonical_field": "app_id",
      "conflict_policy": "adapter_maps_to_meta_application_id",
      "deprecation_version": null,
      "first_supported_version": 2,
      "scoped_json_path": "campaigns_quick_create.destination",
      "sensitive_classification": "business_identifier",
      "value_type": "meta_application_identifier"
    }
  ],
  "quick_create_canonical_example": {
    "ad_account_id": "<target ad account id>",
    "creation_contract_version": 3,
    "creative_source": {
      "creative_names": [
        "<creative name>"
      ],
      "mode": "creative"
    },
    "destination": {
      "app_id": null,
      "deep_link": null,
      "store_url": null,
      "type": "web",
      "web_url": "https://example.com"
    },
    "execution": {
      "adset_count": 1,
      "bid": {
        "cap": null,
        "strategy": "lowest_cost"
      },
      "budget": {
        "amount": 50,
        "level": "adset",
        "type": "daily"
      },
      "campaign_count": 1,
      "statuses": {
        "ad": "PAUSED",
        "adset": "PAUSED",
        "campaign": "PAUSED"
      }
    },
    "template_name": "<template name>"
  },
  "quick_create_creative_source_contract": {
    "exactly_one_mode": true,
    "field": "creative_source",
    "mode_field": "mode",
    "modes": {
      "carousel": {
        "required": [
          "carousel_groups"
        ],
        "row": {
          "additional_properties": false,
          "card_count": {
            "max": 10,
            "min": 2
          },
          "required": [
            "ad_name"
          ],
          "required_one_of": [
            "creative_ids",
            "creative_names"
          ]
        }
      },
      "creative": {
        "optional": [
          "media_mode",
          "creative_overrides"
        ],
        "required_one_of": [
          "creative_ids",
          "creative_names"
        ]
      },
      "partnership": {
        "ad_code_handling": "prepare_input_only_never_echo",
        "required": [
          "partnership_rows"
        ],
        "row": {
          "additional_properties": false,
          "optional": [
            "deeplink"
          ],
          "required": [
            "ad_code",
            "ad_name"
          ]
        }
      },
      "template_source": {
        "append_mode": "new_only",
        "creative_id_handling": "server_only_never_echo",
        "destination": "inherit_exact_source_creative",
        "optional": [
          "ad_name"
        ],
        "required_statuses": {
          "ad": "PAUSED",
          "adset": "PAUSED",
          "campaign": "PAUSED"
        },
        "template_requirements": [
          "schema_version=2",
          "template_kind=partnership",
          "structure=1x1x1",
          "exact source_ad_id and source_adset_id",
          "same account/Page/sponsor"
        ]
      }
    },
    "rules": [
      "Do not mix source-mode fields.",
      "Partnership mode: partnership rows only.",
      "Template-source: no destination or creative overrides"
    ]
  },
  "quick_create_partnership_canonical_example": {
    "ad_account_id": "<target ad account id>",
    "creation_contract_version": 3,
    "creative_source": {
      "mode": "partnership",
      "partnership_rows": [
        {
          "ad_code": "<partnership ad code>",
          "ad_name": "<ad name>",
          "deeplink": "<optional deep link>"
        }
      ]
    },
    "template_name": "<template name>"
  },
  "quick_create_template_source_canonical_example": {
    "ad_account_id": "<template source ad account id>",
    "creation_contract_version": 3,
    "creative_source": {
      "ad_name": "<optional new ad name>",
      "mode": "template_source"
    },
    "execution": {
      "adset_count": 1,
      "budget": {
        "amount": 50,
        "level": "adset",
        "type": "daily"
      },
      "campaign_count": 1,
      "statuses": {
        "ad": "PAUSED",
        "adset": "PAUSED",
        "campaign": "PAUSED"
      }
    },
    "template_name": "<saved Partnership v2 template name>"
  },
  "read_to_write_mappings": {
    "reuse_source_ad_name_as_target_default": {
      "read_field": "insights.ad_name",
      "relation": "explicit_copy_default",
      "write_field": "grouped_plan.campaigns[].adsets[].ads[].ad_name"
    },
    "reuse_source_adset_name_as_target_default": {
      "read_field": "insights.adset_name",
      "relation": "explicit_copy_default",
      "write_field": "grouped_plan.campaigns[].adsets[].adset_name"
    },
    "reuse_source_campaign_name_as_target_default": {
      "read_field": "insights.campaign_name",
      "relation": "explicit_copy_default",
      "write_field": "grouped_plan.campaigns[].campaign_name"
    },
    "select_insights_ad_as_copy_source": {
      "read_field": "insights.ad_id",
      "relation": "selected_source_reference",
      "write_field": "grouped_plan.campaigns[].adsets[].ads[].source_ad_id"
    }
  },
  "single_copy_canonical_example": {
    "ad_status": "PAUSED",
    "ads_per_adset": 1,
    "adset_count": 1,
    "adset_status": "PAUSED",
    "campaign_count": 1,
    "campaign_status": "PAUSED",
    "creation_contract_version": 3,
    "engagement_mode": "preserve_post",
    "request_mode": "single",
    "source_ad_id": "<source ad id>",
    "target_ad_account_id": "<optional target ad account id>"
  },
  "single_copy_new_adsets_canonical_example": {
    "ad_status": "ACTIVE",
    "ads_per_adset": 1,
    "adset_count": 2,
    "adset_status": "ACTIVE",
    "campaign_count": 1,
    "creation_contract_version": 3,
    "engagement_mode": "new_creatives",
    "mode": "new_adsets",
    "request_mode": "single",
    "source_ad_account_id": "<source ad account id>",
    "source_ad_id": "<source ad id>",
    "target_ad_account_id": "<target ad account id>",
    "target_campaign_id": "<target campaign id>"
  }
}
```

## 6. MMP Reads

Use product-scoped MMP tools and pass `channel_pid`. Long supported windows are split by the server. For `channel_pid=all`, `status=partial` and `complete=false` mean at least one channel failed; report failed channels instead of silently returning a smaller total.

## 7B. B2B Agent Output Contract

Replies: results, not approval handles/JSON/debug. Keep confirm_token, approval_ref and raw JSON out of normal replies. Show readable target names, requested changes, before/after values, verified outcomes and the user's next action. Explain partial or pending results without claiming success or zero. Keep confirmation material, plan digests, internal diagnostics and protocol field names out of ordinary chat prose. Use public entity IDs only when useful to identify the target; an unresolved support_ref may be shown for support. These are presentation rules: preserve exact handles and all required fields in tool calls and tool results, including text-only client fallbacks. Never ask the user to copy a confirmation token. ChatGPT's expandable tool details may show tool arguments; do not claim those host-controlled details are hidden.

### Answer

Lead with the direct answer and server-computed totals. Use cleaned / aggregated results.

### Scope

State product/account, date range, grouping, attribution, and completeness.

### Results

Use a compact horizontal Markdown table. Do not use raw-row reads for normal business questions, and do not return only item counts when totals or summaries are available. Preserve effective statuses and public IDs.

### Notes

Mention partial/missing scopes, artifact links, or retry state. Do not volunteer optimization advice unless requested.

## 7C. Optional Client Enhancements

### AdsAgent Tri-Channel Skill Pack (Claude Code, Codex CLI, and other Agent-Skills clients)

This optional skill pack does not replace MCP. Do not pause or block hosted MCP setup for installation. After the MCP servers are configured and `setup_get_status` succeeds, present it as a post-setup reminder for Claude Code users, not a prerequisite. Do not ask the user for permission to install the skill pack before completing MCP setup. Skip this section for Grok, Notion, Cursor, and clients without plugin support. If uncertain whether plugins are supported, complete MCP setup first.

Claude commands:

```bash
claude plugin marketplace add adsagents/adsagent-ai-skills
claude plugin install adsagent-ai-skills@adsagent-ai-skills
claude plugin update --scope user adsagent-ai-skills@adsagent-ai-skills
claude plugin uninstall --scope local adsagent-ai-skills@adsagent-ai-skills
```

Versions below v0.7.0 are stale. `adsagent-meta-ai-skills` remains a compatibility alias. Install at both local and user scope only when the client explicitly supports those scopes.

Codex compatibility path:

```bash
git clone git@github.com:adsagents/adsagent-ai-skills.git ~/.codex/skills/adsagent-ai-skills
```

The pack contains `/adsagent-router`, `/meta-insights`, `/meta-copy`, `/adsagent-notifications`, `/google-ads-insights`, and `/tiktok-insights`. Suggested first test in a fresh Claude Code session: list connected Meta products, Google Ads customers, and TikTok advertisers, then ask which scope's today data to inspect. Never use a hardcoded example account, product, or advertiser name.

## 9. MCP Client Reliability Contract (REQUIRED for onboarding pass)

Passing the MCP onboarding check requires a client-side retry/backoff implementation:

1. The client MUST bound general tool-call concurrency per token to 4-6; it MUST NOT launch unbounded parallel calls. This recommendation is not a standalone GET SSE connection allowance.
2. The client MUST use batch tools for multiple scopes.
3. The client MUST honor `Retry-After` plus jitter and retry serially.
4. Cancellation MUST release local leases and stop retries.
5. The client MUST retry read-only/idempotent calls only; it MUST never automatically retry a confirm token or consequential write. The strict `mcp_meta_quota_deferred` pre-send contract is a plan-level circuit breaker: stop before later confirms, wait, and re-prepare only the unchanged remainder under one fresh consolidated approval, never the old confirm.
6. The client MUST poll queued tasks with `task_ref` until `terminal=true`.
7. The client MUST treat profile top-level `complete=false`, native `meta.complete=false`, missing scopes, partial channels, `verification_pending`, and `data_not_fresh` as incomplete data.
8. The client MUST NOT treat age-only freshness or a bare mutation receipt as verification. An inline `verification_result` with `evidence_kind=config_verified_live` is live configuration proof; otherwise preserve pending and follow only the returned read-only `next_action`.
9. The client MUST reuse one connection group for the same negotiated endpoint, token and session across GET/POST, with at most one standalone GET stream per session. Before reconnecting, cancel and await the old reader, then close its HTTP transport.

### 9.2 Onboarding acceptance criteria

- Launch 20 parallel requests on the same token and prove local concurrency stays bounded while the server returns structured 429 for excess work.
- Simulate an MCP restart on legacy `/mcp`, discard the stale session, call initialize again, and retry one read.
- Prove `Retry-After` is honored before retry.
- Cache initialize/tools-list once per transport and refresh after a guide-version bump.
- Hold two real GET streams, reject the third with `scope=sse_token`, close one and prove admission resumes. Test disconnect, cancellation and failed sends; server leases and client readers must drain once.
- Send simultaneous stale-session reads and prove they share one close/initialize/tools-list sequence, without an old session header; each original read may replay only once.

### 9.3 Reference implementation

`backend/mcp/client_recovery.py` provides an opt-in async helper for application-owned adapters. It is not installed into third-party MCP clients. An adapter must negotiate its own protocol, keep GET/POST in one connection group, and await reader cleanup in `close()` (the module provides `close_reader_and_http`). Its `request()` must close its own response reader on cancellation. Stateless adapters perform discovery themselves and set `stateful=False`.

The helper handles `mcp_session_not_found` with one legacy-session replay, `mcp_concurrency_limited` with `Retry-After` plus jitter, `mcp_fanout_detected` with a returned batch recommendation, and `dependency_unavailable` with bounded read retries. Its cancellation path stops scheduling new attempts.

```python
from backend.mcp.client_recovery import MCPClientRecovery

async def run_read_plan(transport, reads, *, stateful=True, batch_arguments=None):
    # Create one helper per negotiated endpoint/token connection group.
    client = MCPClientRecovery(
        transport, stateful=stateful, max_concurrency=6,
        max_read_retries=2, batch_arguments=batch_arguments,
    )
    try:
        await client.start()
        results = []
        for tool, arguments in reads:
            results.append(await client.request(tool, arguments, read_only=True))
        return results
    finally:
        await client.aclose()
```

The caller supplies known read-only operations and an already bounded plan; do not label writes as reads. Keep a helper alive across calls when the connection group outlives one plan. The optional `batch_arguments(data)` maps returned `recommended_tool`, `recommended_mode`, `recommended_action` and `batch_scope_limit` to approved pending scopes; the helper follows the returned tool rather than a hardcoded tool name. Without that adapter it returns the fan-out response for the caller to replan. Writes default to `read_only=False` and are never automatically replayed, including sent/unknown writes and confirm tokens.

### 9.4 Why this is required

The 2026-05-12 slim incident combined unbounded client concurrency, repeated initialize/tools-list calls, and immediate retries. The result was avoidable saturation and 503s. This contract prevents that failure mode as customer volume grows.

## 10. Client recovery contracts (404 / 429 / 503)

### 10.1 Session-not-found (404)

For legacy `/mcp`, branch on structured fields, never message text:

```json
{"error": {"data": {"subcode": "mcp_session_not_found", "action": "discard_session_and_initialize"}}}
```

The action requires cancellation and awaiting of the old reader, HTTP transport close, clearing the cached session and header, single-flight fresh initialize without `Mcp-Session-Id`, tool re-list, and at most one replay of the original read. Keep the bearer and negotiated protocol; this 404 does not request OAuth. Do not reuse the stale session ID or replay a write. V2 is stateless and avoids this recovery path.

### 10.1A Duplicate standalone SSE stream (409)

For `subcode=duplicate_sse_stream`, follow `reuse_existing_stream_or_close_stale_transport`: reuse the usable existing stream first. Otherwise cancel and await its stale reader, then close the old transport. Do not blindly initialize, open another GET, or replay the original operation on this 409. Explicitly starting a replacement connection group after closure follows ordinary negotiated startup.

### 10.2 Concurrency caps (429) - two-layer

`"mcp_concurrency_limited"` is a transient admission signal. The per-token trigger is more than 8 simultaneous calls; the per-customer trigger is more than 20 simultaneous calls across tokens. Response header: `Retry-After: 5`. Wait plus jitter and retry one read at a time. More than one outstanding retry refills the same slots and is non-compliant.

Standalone GET SSE has a separate connection budget: defaults are two per token and eight per customer, with at most one stream per session. Read the returned `data.scope` and `data.limit`; deployment configuration can override defaults. A POST SSE tool response retains its method's admission lane rather than acquiring the GET lane. Close stale connections before retrying `scope=sse_token`; do not increase caps or interpret the admission counter's crash-cleanup TTL as a session TTL.

### 10.2A Fan-out guard (429)

`mcp_fanout_detected` is a query-planning correction, not a transient retry. Stop the single-scope loop and follow `data.recommended_tool` plus `data.recommended_mode` for all pending scopes. The server recommends canonical `insights_query_consistent` scopes when the Agent Method Profile is live and native `insights_query_batch_overview` otherwise.

### 10.3 Full status-code branching table

| Status | Signal | Action |
| --- | --- | --- |
| 400/422 | invalid or missing input | Ask for the listed fields; never guess. |
| 401/403 | `"invalid_token"` or scope failure | Read `/.well-known/oauth-protected-resource`, reconnect, or request the named scope; do not retry-loop. |
| 404 | `"mcp_session_not_found"` on legacy | Run `discard_session_and_initialize` and retry one read once. |
| 409/410 | stale/used confirm token | Re-prepare only after the user still wants the action. |
| tool error | `mcp_meta_quota_deferred` + `request_sent=false` + `safe_to_retry=true` | Stop the entire plan before later confirms; preserve completed/current/later partitions; honor the largest Retry-After plus jitter; re-prepare only the unchanged remainder under one fresh consolidated approval. |
| 429 | `"mcp_concurrency_limited"` / `"mcp_fanout_detected"` | Honor backoff, or follow the server-recommended tool/mode for fan-out. |
| 503 | `"dependency_unavailable"` / `server_overloaded` | Honor backoff and retry one idempotent read. |

### 10.8 Client retry/backoff reference implementation

The parser handles top-level errors, JSON-RPC `error.data`, and MCP tool-result
`structuredContent.details`, including a JSON-RPC `result` envelope. Use the
largest applicable finite positive delay. A delay alone does not authorize a
retry: first apply the operation's retry/recovery flags, and never replay an
uncertain write.

```python
def retry_after_seconds(payload, headers=None):
    headers = {str(k).lower(): v for k, v in (headers or {}).items()}
    candidates = [headers.get("retry-after")]
    if isinstance(payload, dict):
        roots = [payload]
        if isinstance(payload.get("result"), dict):
            roots.append(payload["result"])
        for root in roots:
            for envelope in (root, root.get("structuredContent")):
                if not isinstance(envelope, dict):
                    continue
                error = envelope.get("error")
                error = error if isinstance(error, dict) else {}
                for node in (
                    envelope, envelope.get("data"), envelope.get("details"),
                    error, error.get("data"), error.get("details"),
                ):
                    if isinstance(node, dict):
                        candidates += [node.get("retry_after"), node.get("retry_after_seconds")]
    delay = 0.0
    for value in candidates:
        if isinstance(value, bool):
            continue
        try:
            seconds = float(value)
        except (TypeError, ValueError, OverflowError):
            continue
        if delay < seconds < float("inf"):
            delay = seconds
    if delay > 0:
        return delay
    return None
```

Never print or persist bearer tokens, cookies, authorization headers, raw request bodies, or internal IDs in retry/audit logs.

### 10.9 Launch confirm recovery (prepare → launch_confirm)

Branch on structured `details.recommended_action` and `details.confirm_token_consumed`, never message text alone.

| Signal | Action |
| --- | --- |
| `recommended_action=retry_launch_confirm` and `confirm_token_consumed=false` | Retry `launch_confirm` **once** with the **same** confirm_token. Do not re-prepare first. |
| `recommended_action=prepare_again` / `prepare_quick_create_again` or category `confirm_token_invalid` on expired/used token | Re-run the base prepare tool, show a fresh summary, obtain explicit approval, confirm with the **new** token. |
| `adsagent_request_incomplete` with public `invalid_fields` | Fix only the listed fields, then re-prepare. |
| `category=temporary_service_failure` without both `recommended_action=retry_launch_confirm` and `confirm_token_consumed=false` | Stop and preserve `support_ref`; reconcile the original operation or task when its reference is available. Do not replay the token or automatically re-prepare. |
| `operator_review_required` | Stop and preserve `support_ref`. Do not replay the token. |

QuickCreate 1×1×N with one creative repeated N times is valid: repeat `creative_names` N times or use one name with `creative_distribution=all_per_adset`. Duplicate generated ad names reconcile by adset FIFO; read `creative_results[].selection_key` (for example `name#1`) after terminal create tasks.

### 10.10 Create reconciliation (`create_reconciliation`)

For terminal create/copy tasks, inspect `result.create_reconciliation` before `result.failures`. Require `reconciled=true` before claiming every requested object is accounted for. Prefer `creative_results[]` over parsing raw `result.ads`.

**Matching keys (priority):**

1. `(campaign_name, adset_name, ad_name)` with adset-scoped FIFO when duplicate names share one adset.
2. `selection_key` / `selection_keys` on each `creative_results[]` row (for example `Ad Name#2`).
3. Name-only FIFO for single-adset plans when created rows lack parent ids.
4. Fail-closed when the same ad name spans different adsets without parent context, or duplicate names mix with unordered failure rows.

| Condition | `reconciled` | Action |
| --- | --- | --- |
| All planned ads matched (created / recovered / failed with clear status) | `true` | Map `creative_results` to `ad_id`; optional bounded live-config read |
| Planned count ≠ created + failed + pending | `false` | Investigate errors; poll while nonterminal |
| Duplicate-name FIFO within one adset succeeded | `true` | Normal success; use `selection_key` |
| Ambiguous cross-adset same name | `false` | Narrow the plan or stop for operator review |
| Meta object ID collision in receipts | `false` | Operator review; use per-row `failure_code` |
| Task nonterminal with settlement-only errors | n/a | Poll; do not treat as operator review |

When prepare summary includes `naming_collision_policy=positional_reconciliation`, duplicate ad names in one adset are expected; reconciliation uses positional FIFO, not a client bug.

## MCP token identity & central-auth (Google Ads / TikTok)

The same central AdsAgent identity can route to Meta, Google Ads, and TikTok hosted MCP servers. Each server keeps its own tool catalog and platform authorization; a valid central token does not prove the linked ad-platform account is connected.
