AdsAgent / MCP Guide

AdsAgent MCP Agent Onboarding

Cross-channel ad ops for ChatGPT, Claude, and other MCP clients start here. This public page mirrors the canonical onboarding markdown used by AdsAgent hosted MCP. When mcp.guide_version changes, reconnect the MCP server, reread the guide resources, and use this page as the human-readable companion.

MCP Wire Support

2026-07-28 + legacy

Guide Version

2026-10-12.5

Production MCP

https://adsagent.md/mcp/v2

Test MCP

https://t.adsagent.md/mcp/v2

> 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-12.5.

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

Launch preflight and approval

New, appended, copied, structurally cloned and recreated launches retain their type-specific compatibility checks, then run one selected-asset preflight before saving an approval. Present summary.preflight: the exact account's live status and create permission, selected Page/Instagram and required Pixel/App/creative checks, account time zone, account-local time and remaining delivery windows. Definite permission or eligibility denials return launch_preflight_failed without an approval or ad task. Timeouts, rate limits, missing user_tasks and other incomplete health reads remain explicitly unknown and produce warnings; present these uncertainties before confirmation. Existing authorization and type-specific validation still apply. Billing gaps are warnings: Meta's reported balance is not available cash or credit, and remaining account spend cap is a separate limit. Never translate missing balance into zero or calendar hours into guaranteed delivery. Explain paused schedules, review/queue delays and any deferred partnership authorization before asking for approval. Reading a prepared approval refreshes current_preflight without changing the frozen summary or plan digest; confirmation repeats the checks before consuming it. A passed preflight does not guarantee Meta will accept, review or deliver the ad.

QuickCreate preparation returns approval_ref, plan_digest and bounded warnings alongside its summary. Present the warning messages, not only their count. operations_get_approval also recovers stored warning messages when available; warning_count remains the total if that bounded list is shorter. A blank-primary-text warning is advisory and does not by itself block approval. Internal configuration digests are omitted from recovered previews without changing the stored plan. Unknown material fields or an oversized preview still make summary_complete=false; review the complete original preparation before requesting confirmation. Never interpret a recovered preview as permission to skip explicit approval or confirmation checks.

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 for uploads; Assets and connections 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.
  • 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 operations_confirm_approval. On /mcp/v2, for one CBO change use campaigns_prepare_budget_change; for 2-10 CBO changes use campaigns_prepare_budget_changes, show the complete aggregate summary, then call operations_confirm_approval once. On legacy /mcp use overview_update_campaign_budget or overview_update_campaign_budgets. On /mcp/v2 use campaigns_prepare_status_change or adsets_prepare_status_change for campaign or ad-set status drafts; these names always prepare, even for operators. The old overview_update names remain callable for cached clients. 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 or user-entered forms. 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.

  1. Call products_list only when product discovery is needed; it is already sorted by recent spend.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Report server totals only when completeness is proven. A missing scope is unknown, never zero.

3. Facebook, Google Ads, And TikTok Connect

  • account_get_profile identifies the AdsAgent login only; its success and MCP authentication do not prove any advertising channel is connected. For user-requested setup, continue with setup_get_status and inspect the matching channels entry. When disconnected, call setup_begin_channel_connect with the matching channel (meta, google_ads, or tiktok), show the returned authorize_url as a clickable link, and wait for the user to complete authorization. A background account-profile lookup alone does not request setup.
  • User-owned Meta System User tokens are supported, including for ordinary users. Give the clickable FB Users link: Add Token → System User token, or Long-lived token (any app) for a token from the user's own Meta app. The user must enter it themselves on the website, using the same AdsAgent account as the plugin. Never request or submit the token through chat or MCP. No tool call is needed for these instructions; after the user confirms saving, re-read connection status.
  • 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 and metric_completeness/result_segments per metric. Zero needs observation; omitted Results/empty funnels are unknown. Segment incompatible indicator/attribution/currency; partial counts cannot yield ratios. historical_result is dated/unverified. read_ref/fingerprints are audit IDs, not freshness. Writes need verified dependencies and authorization; incomplete comparisons/shared budgets block changes.
  • 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.
  • On an oversized consistent read, follow its next_action once: it keeps the same scope, dates, filters and evidence requirements with a smaller compact page. Restart at page 1, discard previously collected pages and obtain new pagination anchors: inventory anchors bind page_size. Do not switch to an unfiltered overview or repeat the unchanged request. If no next_action is available, reduce fields or split scopes serially without dropping any scope.
  • 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. When the user disputes data or numbers, or a tool fails twice, retain the latest relevant tool result support_ref and use support_report_error under that consent policy. 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: 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. 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. User-owned Meta System User tokens are supported, including for ordinary users. Give the clickable FB Users link: Add Token → System User token, or Long-lived token (any app) for a token from the user's own Meta app. The user must enter it themselves on the website, using the same AdsAgent account as the plugin. Never request or submit the token through chat or MCP. No tool call is needed for these instructions; after the user confirms saving, re-read connection status. 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 [email protected] 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. data_freshness.accounts_awaiting_history (with accounts_awaiting_history_count) names newly bound ad accounts whose first 30-day history pull is still pending, syncing or failed; their empty days are not yet loaded, not zero, even when the workspace label is fresh. A new binding reads syncing until that pull finishes; tell the user the history is still loading instead of reporting no spend. 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). The legacy goal messaging normalizes to conversations. Older clients may save or edit conversations templates without adset_params.destination_type; launch requires an explicit single-app destination and never guesses one. Multi-app message creation, post engagement creation (including library images/videos), phone call creation, and templates that reuse an existing Page post (outside profile visits) are disabled pending Meta validation. Instant Form lead creation is unsupported.

Profile-visit ads: a template with goal profile_visit (legacy visit_instagram_profile normalizes to it) launches a Traffic (OUTCOME_TRAFFIC) campaign whose ad set optimizes for PROFILE_VISIT with the Page as its only promoted object; no Pixel, link or destination.web_url is needed. adset_params.destination_type picks the profile: INSTAGRAM_PROFILE (default when unset), FACEBOOK_PAGE, or INSTAGRAM_PROFILE_AND_FACEBOOK_PAGE (Meta picks per person). The CTA and the profile link are server-owned. The Instagram destinations need an Instagram identity (overrides.instagram_user_id from accounts_list_instagram_accounts). Library images, videos and carousels work; carousel with FACEBOOK_PAGE and media_mode=multi are refused at prepare. To turn existing posts into ads use creative_source.mode=existing_post with existing_posts[] of {ad_name, instagram_media_id} (any profile-visit destination; numeric id from GET /{ig-user-id}/media?fields=id,permalink) or {ad_name, page_post_id} (<page_id>_<post_id>, FACEBOOK_PAGE only); each row becomes one ad, and post URLs or shortcodes are not accepted. profile_and_page_engagement stays unavailable. Meta enables API profile-visit creation per ad account. accounts_get_account_detail.profile_visit_eligibility and prepare check it with a validate-only request that writes nothing; check it before asking the user for post ids. eligible proceeds, unknown proceeds with a warning, and not_eligible returns status=blocked with code=profile_visit_account_not_eligible and no draft. The same code is reported if Meta refuses at publish (2016153); the run's new campaign or ad sets are then deleted. In both cases tell the user to create the ad in Meta Ads Manager or ask Meta support / their Meta representative to enable profile-visit ads; never switch to website traffic on your own.

Other QuickCreate launches require 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"
        ]
      },
      "existing_post": {
        "media_mode": "single",
        "page_post_id_destination": "FACEBOOK_PAGE",
        "required": [
          "existing_posts"
        ],
        "row": {
          "additional_properties": false,
          "id_format": {
            "instagram_media_id": "numeric",
            "page_post_id": "<page_id>_<post_id>"
          },
          "required": [
            "ad_name"
          ],
          "required_one_of": [
            "instagram_media_id",
            "page_post_id"
          ]
        },
        "template_requirements": [
          "profile_visit adset"
        ]
      },
      "partnership": {
        "ad_code_handling": "prepare_input_only_never_echo",
        "required": [
          "partnership_rows"
        ],
        "row": {
          "additional_properties": false,
          "optional": [
            "deeplink",
            "sponsor_instagram_user_id"
          ],
          "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",
      "Existing-post: profile_visit only; no copy/CTA 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 [email protected]: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.