---
name: scope3-agentic-buyer
version: "2.0.0"
description: Scope3 Agentic Buyer API - AI-powered programmatic advertising
api_base_url: https://api.interchange.io/api/v2/buyer
auth:
  type: bearer
  header: Authorization
  format: "Bearer {token}"
  obtain_url: https://interchange.io/user-api-keys
---

# Scope3 Agentic Buyer API

This API enables AI-powered programmatic advertising with inventory discovery, campaign management, and creative orchestration.

**Important**: Use the most specific MCP tool first. Session-level actions such as customer switching use dedicated tools like `customer_switch`; do not route them through `api_call`. For buyer REST operations, use `api_call` with the `operation` field — every call must supply a named operation; there is no raw `method` + `endpoint` mode.

## ⚠️ CRITICAL: Presentation Rules

**Tool responses return JSON data.** For most endpoints (advertisers, sales agents, campaigns, etc.), YOU are responsible for presenting the data clearly in your message. Follow the Display Requirements for each endpoint — they tell you exactly what fields to show and how to structure the output. Never summarize into vague prose — always show the specific data points listed in the display requirements for each item.

**Exception: Product discovery and reporting endpoints render interactive UI components.** When those tools return a UI, display it as-is — do not generate your own competing visualization.

**Every value you present must trace to a tool response or user input.** When you show the user a status, ID, count, date, or any other data point, it must come from a `api_call` response, an `ask_about_capability` response, or something the user told you in this conversation. If you are unsure whether a value is still current (for example, a status that may have changed since the last fetch), qualify it — "last known value from `get_advertiser`" — rather than stating it as a fresh fact. Do not restate values "from memory" without re-fetching.

## ⚠️ CRITICAL: Show Operational Details, Not Just Names

When listing entities (advertisers, sales agents, storefronts), you MUST display the operational fields from the response — account status, credential requirements, linked accounts, sandbox status — not just names. The `ask_about_capability` response tells you which fields to show for each operation.

**Common mistake:** Listing advertisers or sales agents and showing only names. This is WRONG. Show account status, credential requirements, linked accounts, and other operational details for each item.

## ⚠️ CRITICAL: Exact Field Names Required

**DO NOT GUESS FIELD NAMES.** Use these exact camelCase names:

| Field | Type | Notes |
|-------|------|-------|
| `advertiserId` | string | NOT `advertiser_id` |
| `brand` | string | Required on advertiser create (e.g., `"nike.com"`) |
| `brandCountries` | string[] | Optional country qualifiers for this advertiser's BrandKey scope (for example, Nike Italy uses `IT`). Empty means no country qualifier. Campaigns may inherit this market scope and refine it with delivery targeting |
| `saveBrand` | boolean | Optional on advertiser create/update. Set `true` only after reviewing enrichment or when the user confirms they want to save the brand to the registry |
| `primaryCurrency` | string | API field shown to buyers as **Currency**. Required on create |
| `preferredTimezone` | string | Optional IANA Reporting time-zone preference. Seller-effective time zones may differ |
| `sandbox` | boolean | Optional on advertiser create. When `true`, all ADCP operations use sandbox accounts (no real spend). Immutable after creation |
| `optimizationApplyMode` | string | Optional. `"AUTO"` or `"MANUAL"` (default `"MANUAL"`). Do not ask for this during advertiser setup; only set it when the user explicitly asks to configure approval mode or volunteers a clear preference. |
| `flightDates` | **object** | NOT `startDate`/`endDate` at root level |
| `flightDates.startDate` | string | ISO 8601: `"2026-02-05T00:00:00Z"` |
| `flightDates.endDate` | string | ISO 8601: `"2026-02-10T23:59:59Z"` |
| `budget` | **object** | NOT a number |
| `budget.total` | number | e.g., `1000` |
| `budget.currency` | string | Three-letter ISO code. Every campaign is created in the advertiser's currency; an advertiser is single-currency. Pass the advertiser's currency here or omit it — never a different currency, the server rejects mismatches (400, field `budget.currency`). Preserve the user's stated currency (`€`/`euro`/`euros`/`Euro` -> `"EUR"`, `£`/`pound`/`pounds`/`sterling` -> `"GBP"`, `USD`/`US dollar`/`US$` -> `"USD"`); do not let a non-USD request fall back to USD. Ask if ambiguous, including standalone `$`, `dollar`, `dollars`, or `dólares`. |
| `constraints` | object | Optional. Channel filter plus AdCP-shaped targeting overlay (geo, language, device). Targeting fields flow into every media-buy package; include lists intersect with package targeting, exclude lists union. |
| `constraints.channels` | array | e.g., `["display"]`, `["ctv"]` |
| `constraints.geo_countries` / `geo_countries_exclude` | string[] | ISO 3166-1 alpha-2 codes. Replaces the deprecated `countries` field. |
| `constraints.geo_regions` / `geo_regions_exclude` | string[] | ISO 3166-2 subdivision codes (e.g. `"US-CA"`). |
| `constraints.geo_metros` / `geo_metros_exclude` | `{system, values}[]` | Metro targeting, e.g. `{ system: "nielsen_dma", values: ["501"] }`. Always use numeric string codes. |
| `constraints.geo_postal_areas` / `geo_postal_areas_exclude` | `{country, system, values}[]` | Postal-area targeting. Use ISO 3166-1 alpha-2 `country` plus country-local `system` — each of the 10 registered countries requires its own specific `system` value, not the generic `"postal_code"` fallback. See "Postal-Area Country/System Pairing" below. Legacy `{system, values}` is accepted only for deprecated country-fused systems. |
| `constraints.language` | string[] | ISO 639-1 codes (e.g. `["en"]`). |
| `constraints.device_type` / `device_type_exclude` | string[] | `desktop \| mobile \| tablet \| ctv \| dooh`. |
| `constraints.device_platform` | string[] | `ios \| android \| roku_os \| ...` |
| `performanceConfig` | object | Optional. Contains `optimizationGoals` array. Each goal has `kind` (`"event"` or `"metric"`). Event goals have `eventSources` array + optional `target`. Metric goals have `metric` + optional `target`. |

## Postal-Area Country/System Pairing

`constraints.geo_postal_areas[].system` is not a free-standing format choice — 10 countries each have their own required `system` literal, and sending the wrong one fails validation:

| `country` | Required `system` | Example |
| -- | -- | -- |
| `US` | `zip` (or `zip_plus_four`) | `{ country: "US", system: "zip", values: ["90210"] }` |
| `GB` | `outward` (or `full`) | `{ country: "GB", system: "outward", values: ["SW1A"] }` |
| `CA` | `fsa` (or `full`) | `{ country: "CA", system: "fsa", values: ["M5H"] }` |
| `DE`, `CH`, `AT` | `plz` | `{ country: "DE", system: "plz", values: ["10115"] }` |
| `FR` | `code_postal` | `{ country: "FR", system: "code_postal", values: ["75001"] }` |
| `AU` | `postcode` | `{ country: "AU", system: "postcode", values: ["2000"] }` |
| `BR` | `cep` | `{ country: "BR", system: "cep", values: ["01310-100"] }` |
| `IN` | `pin` | `{ country: "IN", system: "pin", values: ["110001"] }` |
| `ZA` | `postal_code` | `{ country: "ZA", system: "postal_code", values: ["8001"] }` |
| any other country | `postal_code` (generic fallback) | `{ country: "NL", system: "postal_code", values: ["1011"] }` |

`system: "postal_code"` only validates for `ZA` or an unregistered country — it fails for every other registered country above (e.g. `{ country: "AU", system: "postal_code" }` is rejected; use `postcode`).

## Nielsen DMA Resolution

When using `geo_metros` with `system: "nielsen_dma"`, always send numeric string codes — never market names. If a buyer provides a market name, casual label, or spreadsheet/export row, call `buyer_api_call` with `operation: "resolve_targeting_dimension"`, `pathParams: { "system": "nielsen_dma" }`, and `params: { "q": "<buyer text>" }` for each label. Do not list the full geo-metro table to do fuzzy matching yourself unless the resolver returns no candidates or an ambiguous result.

If the response has `ambiguous: true`, multiple high-confidence candidates, or no candidates, ask a concise clarification before creating/updating the campaign. If the buyer says "LA DMA", resolve it within `nielsen_dma` and use the returned Los Angeles DMA code.

When you resolve a market name to a DMA code, send only the code in `constraints.geo_metros`, for example:

```json
{
  "constraints": {
    "geo_metros": [{ "system": "nielsen_dma", "values": ["532"] }]
  }
}
```

For browsing display labels, use `operation: "list_targeting_dimension_values"` with `pathParams: { "system": "nielsen_dma" }`. For campaign display, pass `params: { "fields": "geo_metro_names" }` to `list_campaigns` or `get_campaign` when human-readable metro labels are needed. Included metro labels return in `geo_metro_names`; excluded metro labels return in `geo_metro_names_exclude`. The returned labels are derived from the same local English label table. If a code has no label, show the code and ask for clarification instead of guessing a DMA name.

## ⚠️ CRITICAL: Always Send the ENTIRE Client Brief

**When sending a `brief` field to ANY endpoint, you MUST include the COMPLETE brief text the client provided — word for word, in full.** Do NOT summarize, truncate, paraphrase, or shorten the brief under any circumstances.

The brief is used by sales agents and the discovery system to match relevant inventory. Sending a partial or summarized brief degrades match quality and loses important context. Even if the brief is long, always pass it through in its entirety.

**Rules:**
- **Always include the brief** — if the client provided a brief, you MUST send it in every API call that accepts a `brief` field. Never omit it or leave it out.
- **Copy the full brief verbatim** — include every detail the client provided
- **Never summarize** — "Premium CTV for tech enthusiasts" is NOT an acceptable substitute for a multi-paragraph brief
- **Never truncate** — if the client gave you 500 words, send all 500 words
- **Applies everywhere `brief` is used** — product discovery, campaign creation, and any other endpoint that accepts a brief

## ⚠️ CRITICAL: Never Fabricate User Data

**Before making any API call that creates or modifies a resource, you MUST have explicit user input for all required fields.** Do NOT invent, guess, or auto-fill values the user hasn't provided.

**Rules:**
- **NEVER fabricate values** for required fields. If the user hasn't provided a value, ask for it.
- **Read-only calls are fine** — you can freely call GET endpoints to fetch data and present it to the user.
- **Confirm before mutating** — Before any POST, PUT, or DELETE call, verify you have user-provided (or user-confirmed) values for all required fields.
- **Inferring is OK when obvious** — If the user says "optimize for purchases with a 4x ROAS target", you can infer an event goal with `eventType: "purchase"` and `target: { kind: "per_ad_spend", value: 4.0 }`. But if intent is ambiguous, ask.
- **Disclose every inference before calling** — Even when an inference is unambiguous, always tell the user the value you are about to use before making the mutating call ("I'll set pacing to 'even' based on your brief — confirm?"). Silently applying inferred values without disclosure is not acceptable, even when the inference is correct.
- **Never make up IDs** — IDs (advertiserId, discoveryId, campaignId, etc.) must come from previous API responses or the user. Never generate them.
- **Account IDs for linking MUST come from the correct discovery surface** — For an official adapter connection, use only an `accountId` returned by `list_storefront_connection_accounts` or `list_storefront_connection_account_mappings` for that `connectionId`. For a legacy or third-party external AdCP inventory source, use only an `accountId` returned by `list_available_accounts`. Never use an account ID or name supplied only in conversation. If the account is absent from the appropriate response, tell the user it was not found; never pretend it was linked.
- **Only use what's documented** — Do NOT invent endpoints, fields, query parameters, or enum values that are not explicitly listed in this skill document. If you're unsure whether something exists, check this document first. If it's not here, don't use it.

## Before Every api_call: Verify Each Field Has a Source

Before invoking `api_call`, mentally walk through every field in `body`, `pathParams`, and `params` and name its source:

- "`advertiserId`: from the `list_advertisers` response above" ✅
- "`brief`: verbatim from the user's message" ✅
- "`budget.total`: user said $5,000" ✅
- "`flightDates.startDate`: I assumed next Monday" ❌ — STOP, ask the user
- "`optimizationGoals[0].metric`: typically these APIs use `CTR`" ❌ — STOP, call `ask_about_capability`

If any field's source is "I assumed…", "it's probably…", "typically these are…", or "the docs usually say…" — do NOT send the call. Either ask the user for the value, or call `ask_about_capability` to find the correct schema.

This check is cheap; a failed or wrong-data API call is expensive.

---

## V3 Preferred Paths

If this account is connected to `/mcp/v3`, use the V3 tool for any of the following operations instead of the v2 `api_call` equivalent. V3 paths are typed, standalone tools that do not require the `api_call` wrapper.

### Account and session

| v2 Operation | V3 Tool |
|---|---|
| `get_current_account` | `get_status` |
| `list_accounts` | `get_status` (summary) or `switch_account` |

### Advertisers

| v2 Operation | V3 Tool |
|---|---|
| `list_advertisers` | `search({ kind: "advertiser" })` |
| `list_all_advertisers_home` | `open_advertisers_page` |
| `get_advertiser` | `get({ kind: "advertiser", id })` |
| `delete_advertiser` | `save_advertiser({ advertiserId, isArchived: true })` |
| `restore_advertiser` | `save_advertiser({ advertiserId, isArchived: false })` |

### Campaigns

| v2 Operation | V3 Tool |
|---|---|
| `list_campaigns` | `search({ kind: "campaign", filter: { advertiserId } })` |
| `get_campaign` | `get({ kind: "campaign", id })` |
| `delete_campaign` | `save_campaign({ campaignId, isArchived: true })` |
| `execute_campaign` | `save_campaign({ campaignId, desiredPhase: "active" })` — see v3 launch flow |
| `reactivate_campaign` | `save_campaign({ campaignId, isPaused: false })` |

### Media buys

| v2 Operation | V3 Tool |
|---|---|
| `create_media_buys` | `save_media_buy` |
| `get_media_buy` | `get({ kind: "media_buy", id })` |
| `get_media_buy_packages` | `get({ kind: "media_buy", id, include: ["products"] })` |

### Creatives

| v2 Operation | V3 Tool |
|---|---|
| `list_creatives` / `list_campaign_creative_manifests` | `search({ kind: "creative", filter: { campaignId: "<id>" } })` |
| `list_advertiser_creatives` | `search({ kind: "creative", filter: { advertiserId: "<id>" } })` |
| `get_creative` | `get({ kind: "creative", id, sourceId: campaignId })` |
| `list_creative_collections` | `search({ kind: "creative_collection", filter: { campaignId } })` |
| `get_creative_collection` | `get({ kind: "creative_collection", id, sourceId: campaignId })` |
| `list_advertiser_creative_collections` | `search({ kind: "creative_collection", advertiserId })` |
| `get_advertiser_creative_collection` | `get({ kind: "creative_collection", id, advertiserId })` |
| `create_advertiser_creative_collection` / `update_advertiser_creative_collection` / `add_advertiser_creative_collection_members` / `remove_advertiser_creative_collection_members` | `save_creative_collection({ advertiserId, ... })`; changes carry `expectedUpdatedAt` |

### Connections

The following v2 connection write operations are all handled by `save_connection` in V3 — see the V3 skill's "Connections and provider-account setup" section for the full flow.

`accept_storefront_direct_billing`, `connect_storefront`, `map_storefront_connection_account_to_advertiser`, `refresh_storefront_connection_accounts`, `remove_storefront_connection`, `save_enhanced_reporting_account`, `select_storefront_connection_account`, `unassign_account`, `update_advertiser_storefront_activation_preference`, `update_storefront_billing_policy`, `update_storefront_connection_policy`, `update_storefront_selection_override`

For reading connections: `list_storefront_connections` maps to `search({ kind: "seller" })` or `search({ kind: "connection" })`; `list_storefront_connection_accounts` and `list_storefront_connection_account_mappings` map to `search({ kind: "connection" })` or `get({ kind: "connection", id })`.

### Supply requests

| v2 Operation | V3 Tool |
|---|---|
| `create_supply_request` | `save_ask({ type: "supply", title, detail, subject, channel })` |
| `remove_supply_request` | `save_ask({ id: "<askId>", requesterState: "withdrawn" })` |

### Reporting

| v2 Operation | V3 Tool |
|---|---|
| `get_reporting_metrics` | `get_delivery({ report: "campaign_delivery", filters: { advertiserId, campaignId }, range: { startDate, endDate } })` |

### Notifications and release notes

| v2 Operation | V3 Tool |
|---|---|
| `list_notifications`, `read_notification`, `read_all_notifications`, `acknowledge_notification`, `get_notification_delivery_preferences`, `update_notification_delivery_preference` | `open_notifications` (no args) |
| `list_release_updates`, `mark_release_updates_seen` | `open_page({ page: "release_notes" })` |

### Billing and buyer operator

| v2 Operation | V3 Tool |
|---|---|
| `accept_tos` | `save_billing({ terms: { accepted: true, version: "<version>" } })` |
| `update_buyer_operator` | `save_buyer_operator` |

---

## Notifications

**V3:** Use `open_notifications` (no args) on `/mcp/v3` — it handles listing, reading, acknowledging, and preference updates without additional tool calls.

The `help` and `ask_about_capability` tools include unread notifications in their responses. When a response contains a "Unread Notifications" section, summarize those notifications for the user before answering their question.

Notifications can be listed, marked as read, or acknowledged via the `/api/v2/notifications` endpoints — see the Notifications section below for details.

**Setup:** To receive notifications proactively at the start of every session, add this to your Claude Desktop Project instructions, CLAUDE.md, or system prompt: `When using Scope3 tools, always start by calling the help tool. The response includes unread notifications — summarize those for the user before answering their question.`

## Advertisers page (`open_advertisers_page`)

Use `open_advertisers_page` for any natural human ask to see or browse advertisers — "list advertisers", "show my advertisers", "what advertisers do I have", "where do I start", or "I need to pick one to work in." It opens the account-level Advertisers landing — the buyer entry point — listing each advertiser with its campaign and draft counts and brand identity, plus one next action per row. Opening an advertiser switches the chat into that advertiser and lands on its campaigns.

This is the default response to a bare "list advertisers"-style request — prefer it over calling the `list_advertisers` operation and reciting the results as text. Reserve `list_advertisers` for internal uses: resolving an `advertiserId` you need for another call, applying filters the human explicitly asked for (by name, status, or linked partner), or serving a headless/AdCP JSON client that has no chat surface to render a widget in.

The tool takes no arguments. Spend rollups and attention signals are not yet surfaced here (server-owned rollups land with the all-advertisers-home projector); campaign and draft counts and brand identity render today.

## Add advertiser (`open_add_advertiser`)

Use `open_add_advertiser` for any natural human ask to create, add, set up, or edit an advertiser. It opens the canonical guided in-chat setup surface for name, AdCP brand scope, Currency, Reporting time zone, Sandbox, and channel preferences. Without `advertiserId` the widget creates through `create_advertiser`; with `advertiserId` it loads a focused edit view: name and channels remain editable, reporting time zone remains editable only before first use, and creation-time brand scope, currency, and environment render read-only. Seller account management stays in Connections.

Prefer this over asking for each field in turn or mutating from raw text. Extract whatever the buyer already stated and pass it as args: `advertiserId` for edit, `name`, `brand`, `brandCountries`, `primaryCurrency`, `preferredTimezone`, `sandbox`, and `channels` using canonical AdCP media-channel values. Omit anything unstated. Operator identity is confirmed separately in Buyer Setup before the first advertiser can be created; it is not an advertiser field. The canonical surface keeps brand scope, currency, and environment read-only after creation. Channel preferences remain editable and are planning hints only: they do not qualify seller inventory. Market/channel qualification and billing negotiation are separate workflows.

## Buyer Setup / go-live (`get_buyer_readiness`)

Use `get_buyer_readiness` as the default response to a buyer ask about their own **buyer-account readiness** — going live as a buyer, finishing buyer setup, or being able to buy at all: "what do I need to go live", "what do I need to do to go live", "am I ready to buy", "what's left to set up", "finish my setup", "why can't I buy yet". It opens the **Buyer Setup** widget: the buyer-wide platform go-live checks (operator identity, terms, account standing, buying readiness), the confirmed operator identity, and the per-destination connection, mapping, or billing requirement for every seller and buying platform. The widget self-fetches its data.

This is **buyer-account readiness only** — do NOT open it for launching or managing a campaign or media buy (use the campaigns/discovery surfaces) or for onboarding, adding, or setting up an advertiser (`open_add_advertiser`). "Launch my campaign" and "onboard an advertiser" are not buyer-account setup.

**Prefer the widget over reciting the readiness state as text.** Do NOT narrate the checklist, blockers, fees, rate card, or the per-destination list as prose — call `get_buyer_readiness` to open the page, give a brief **state-independent lead-in** and let the buyer act in the widget. Do NOT claim a specific next step or that anything is or isn't done (e.g. don't say "you're one step from live — set your operator identity"): the widget self-fetches its state inside the iframe and you cannot see it, so a definite claim can be wrong (telling an already-complete buyer to set operator identity). A safe lead-in names the surface, not the state — e.g. "Here's your Buyer Setup — your go-live checklist and each seller's remaining step are below." For a buyer's go-live ask, open Buyer Setup — do NOT read buyer readiness through `buyer_api_call` and recite it, and do NOT reach for seller/storefront readiness (sales-agent health, brand-domain verification, storefront go-live, `create_media_buy` transaction proof); those are the seller's go-live concerns, not the buyer's.

The operator-identity confirmation and every remaining step are performed inside the widget; do not walk the buyer through them as text.

## Campaigns page (`open_campaigns_page`)

Use `open_campaigns_page` when a buyer wants to browse, filter, or find campaigns in the chat surface. The tool opens the Campaigns Page widget with filters by phase, handling, and search; a stats band with live/draft/blocked counts; per-campaign attention signals; and load-more pagination.

Accepts an optional `advertiserId` to scope the list to one advertiser. When an advertiser is active in the conversation, resolve its id and pass it so the page opens pre-filtered. Omit it to show campaigns across all advertisers.

`open_campaigns` is retired and replaced by this tool. Do not call `open_campaigns`.

## Campaign creative mapping (`open_campaign_creative_mapping`)

Use the read-only `open_campaign_creative_mapping` tool when a buyer asks to
review or edit which placements an existing campaign's creatives map to. Pass
the exact `campaignId` from `list_campaigns` or `get_campaign`; never invent an
ID. The tool opens the canonical editable mapping Task, where the buyer can
batch placement changes and save them directly without creating another chat
turn.

The launcher reuses the same data and UI as `api_call` with
`operation: "get_creative_confirmation"`. Prefer the typed launcher when the
goal is to open the mapping Task; use `get_creative_confirmation` directly
when programmatic callers need the underlying read response as part of a
larger `api_call` workflow.

## Connections page (`open_connections_page`)

Use `open_connections_page` as the default response to any natural human ask about connecting, reconnecting, refreshing, or managing a seller connection, or mapping seller accounts to advertisers, in the chat surface — "connect Snap", "add a seller", "how do I connect Google Ads", "my new MCC client is missing", "refresh my seller accounts", "what sellers are connected", "manage my seller connections", "map this account to an advertiser", or "why do you need access to my ad platform." It opens the Sellers widget: marketplace readiness and selection per seller, connection and account-refresh actions for official seller adapters, and inline advertiser-account mapping. Creative, signal, measurement, optimization, and composite providers use their own surfaces. OAuth and API-key authorization open outside chat; the widget never collects credentials.

**Prefer the widget over reciting connect steps as text.** Do NOT walk the buyer through "go to Settings → Connections, find X, click Connect" or narrate the connect/refresh/map operations — open the page and let them act in it. Reserve the operation-by-operation flow below (`connect_storefront` → `list_storefront_connections` → `refresh_storefront_connection_accounts` when provider access changed → `map_storefront_connection_account_to_advertiser`) for headless/AdCP JSON clients with no chat surface, or for the registered external-AdCP-source path the widget does not cover (`register_source_credentials` / `list_available_accounts`).

The tool accepts optional positive integer-string `advertiserId` and `sellerId`
seeds. Pass the resolved advertiser id in advertiser scope, pass a seller id to
open one exact Storefront, or omit both for the account overview. Add
`connectionAction: "connect"` with `sellerId` only when the buyer asked to open
connection setup. The launcher is read-only; authorization starts only after
the buyer confirms in the widget. Never invent an id. The widget self-fetches
live sellers, connections, mappings, and advertisers. A seller is the
commercial counterparty; a connection is one authorization grant to it, and a
single connection may expose several provider accounts.

When the conversation is scoped to one advertiser, the Sellers widget offers
Inherit, On, and Off for that advertiser only. Headless clients use
`save_connection` with
`advertiserActivation: { advertiserId, decision: "DEFAULT" | "ENABLED" |
"DISABLED" }`. Never substitute the account-wide selection operation:
`ALWAYS_INCLUDE` and `ALWAYS_EXCLUDE` affect every current and future
advertiser and remain authoritative over scoped preferences.

## Plan & Billing (`open_plan_and_billing` / `get_billing_account`)

Two read-only tools cover the organization's consolidated commercial-account
view — plan/contract status, effective pricing, intelligence usage,
credit/prepay standing and balance, agreements, and the single next action (if
any) needed to become or remain paid.

- **`open_plan_and_billing`** opens the Plan & Billing Page (the widget) —
  call this when the user asks to see their account status, plan, billing, or
  usage in the chat surface. Takes no arguments.
- **`get_billing_account`** returns the document as JSON — call this when you
  need the actual figures (balance, next action, usage) to answer a question
  or reason about them. Optional `section` param (one of `organization`,
  `scope`, `standing`, `plan`, `pricing`, `usage`, `payment`, `contracts`,
  `enterpriseContracts`, `accounts`, `nextAction`, `permissions`) returns just
  that section to keep the response small; omit it for the full document.
  In model-visible output, accepted Enterprise package history appears once in
  `enterpriseContracts.packages`; ordinary IU plan history remains in
  `enterpriseContracts.commercialState.history`. Each list is capped at the 20
  newest entries, accepted records/offers are compact summaries, and rights are capped
  at 10 per package. Check the companion total/truncated fields before treating
  absence from a bounded list as absence from the contract. Exact schedules,
  authored display names, Rate Card snapshots, proposals, and payment choices
  remain outside model context; model plan labels are code-owned and unknown
  plans appear as numbered options. Use `open_iu_plan_task` for authenticated
  human review.
  `section: "accounts"`
  also returns `totalAccounts` — the `accounts` array is capped at 50
  entries, and `totalAccounts` is the uncapped count.

The trusted portable Page hydrates and performs human-confirmed ToS and seller
payout actions through resource-bound app-only tools in buyer, storefront, and
v3 hosts. Those private billing and bank values never pass through the
assistant. Payout setup is shown only in the active seller billing organization;
child and buyer-only contexts cannot route funds.

  For questions that mix media pricing, IU pricing, child-account delegation,
  or buyer-domain verification, retrieve the current explanations from
  `buyer/billing/tasks/get-billing-account`, `buyer/billing/organization-iu-rate-card`,
  and `buyer/account/setup`. Present those distinctions instead of inferring
  readiness from similarly named fields.

Both tools are read-only — no mutation Tasks exist yet (accepting ToS, funding
a balance, applying for credit are separate, deferred flows; `accept_tos`
already exists standalone). Pricing, standing, and credit figures are ground
truth from this document — never state a balance, limit, or next action from
memory or a prior turn; call `get_billing_account` fresh.

## Adding a payment method (`add_payment_authority`)

When the organization needs a payment method — `get_billing_account` shows no
verified payment method, or billing setup is blocking a paid action — use the
`add_payment_authority` bounded Task. Card data NEVER passes through you or
this API: the Task produces a secure link the organization's own cardholder
opens in a browser (no Scope3 sign-in needed) to enter the card directly with
the payment processor.

**The three-step contract:**

1. `add_payment_authority` with `action: "request"` — stages the request and
   returns a single-use `confirmationToken` (expires in 5 minutes). Nothing
   is created yet.
2. `add_payment_authority` with `action: "confirm"` and that
   `confirmationToken` — issues the capture link:
   `{ method: "capture_link", url, status: "pending", expiresAt }`. The link
   is valid for 30 minutes and serves one verified card capture. SAVE THE
   URL — it is returned exactly once and cannot be re-fetched.
3. **Hand the `url` to the human cardholder** (paste it in the chat, an
   email, wherever the user directed) and tell them: open it, enter the
   card, done — no account or sign-in is needed.

**Polling etiquette:** after handing off the link, poll
`add_payment_authority` with `action: "status"` to learn the outcome:
`pending` → `opened` (page visited) → `verified` (card confirmed) or
`expired`. Poll no more than every 15-30 seconds — the human step usually
takes minutes. On `verified`, the organization has an active payment
authority (it appears in `get_billing_account` → `payment`). On `expired`,
start over with a fresh `request`.

Requires org-admin access (a human admin session or an org-scoped service
token; advertiser-scoped tokens are refused). `method` is always
`"capture_link"` today; switch on the field rather than assuming it — a
future agent-held payment token method would be an additive value.

## Quick Start

1. **Use the most specific MCP tool first**: For session state, use dedicated tools such as `customer_switch`; do not route those actions through `api_call`.
2. **Use `ask_about_capability` before REST work when needed**: Ask about the user's request to learn the correct workflow, endpoints, and field names.
3. **Use `api_call` with the `operation` field for REST operations**: **Always use the `operation` enum** — it prevents hallucinated endpoints and wrong HTTP methods.
4. **Use `ask_murph` when the user wants explanation, not execution**: see "When to call Murph" below.
5. **Authentication**: Handled automatically by the MCP session

### `api_call` parameters

- `operation` (string): Named operation — each endpoint section below shows the operation name
- `pathParams` (object): Path parameters (e.g. `{ "advertiserId": "abc-123" }`)
- `body` (object): Request body for POST/PUT operations
- `params` (object): Query parameters as key-value pairs

## When to call Murph (`ask_murph`)

Murph is your AI guide to Interchange. It's mounted on this server so you don't have to switch MCP servers when a user pivots from "do X" to "explain X."

**Route to Murph when the user asks**:
- "How do I…" / "What's the right way to…" — onboarding, workflow, "where do I start"
- "Why is my X in state Y?" — campaign paused, sync not running, sales agent not responding
- "What changed recently?" — they noticed something different
- "This looks broken" / "I think there's a bug" — Murph files a report for the Scope3 team
- General help where the user doesn't know which tool / endpoint applies

**Stay on `api_call` when the user asks for**:
- A specific CRUD action (`create advertiser`, `pause campaign`, `add product to campaign`, etc.)
- A specific data fetch (`list my campaigns`, `show me reporting`)
- Any mutation — Murph is read-only

**Don't double-route.** If the user's intent is unambiguously mutate-this, just call `api_call` and tell the user what you did. Don't ask Murph first; that wastes a Claude round-trip and confuses the user.

**Murph parameters:**
- `prompt` (string, required): The user's question, verbatim. Plain English.
- `conversationUid` (string, optional): Pass back what Murph returned on the previous turn to continue a thread. Omit on first call.

**Murph caveats** (foundation mode):
- Murph answers from its own knowledge of Interchange. Live-state inspection and full docs citations are follow-up work — Murph will tell you when it's guessing vs sourced.
- Murph CAN record an internal report (`escalated: true` in the response). Do NOT mention any ticket ID or internal tracking reference — tell the user the Scope3 team has been notified.
- Murph is scoped to the caller's customer. One Murph conversation == one customer; don't reuse `conversationUid` across `customer_switch`.

---

---

## Campaigns

Every campaign has a `mode` (`discovery`, `performance`, or `directed`) and a
`management` state (`tracked` or `managed`). `management` is the axis to teach
buyers: **tracked** means the campaign was mirrored from a connected provider
account Interchange did not set up — a read-only shell derived from the buys
underneath it, updating automatically as the upstream campaign changes. **managed**
means the campaign uses the canonical Interchange buyer product and media-buy
lifecycle. In both states the upstream execution system remains the source of
truth; Interchange reflects it. The `directed` mode value is a
deprecated wire discriminator retained for compatibility — read `management`
instead, and never teach "directed" or "seller-managed" as product nouns.
Mirrors are read-only by construction. Campaigns created or operated through
Interchange use the canonical product and media-buy lifecycle.

Campaign lists default to the live working set: `list_campaigns` returns
every non-terminal campaign (ACTIVE, DRAFT, PAUSED) across both management
states, so live spend on a connected provider account is visible by default
while its terminal history is not. Pass `status=ALL` (or explicit statuses)
to reach completed, canceled, or archived campaigns; pass `management=managed` or `management=tracked` to
narrow by who operates the campaign. Historical tracked campaigns are not an
inventory to browse — a connected account may mirror thousands — so present
their scale through the provider-account relationship rollup
(`campaignsTracked`, `campaignsActive`, spend rollups) and enumerate them
only when the buyer explicitly asks to drill in.

Create a platform-managed campaign via the `create_campaign` operation. Campaigns are configured at creation or update time with:

- **Products**: Select products via the `discover_products` and `add_discovery_products` operations, then attach it via `discoveryId` at campaign creation or update.
- **Optimization goals**: Include `performanceConfig` to attach optimization goals to any campaign — goals are hints allowed on any mode, not a composition-ownership declaration (AI-8988). `performanceConfig: null` clears goals.
- **Composition ownership**: Controlled by `autonomy.inventorySelection` (`manual` | `propose` | `automatic`). `manual` (default) means the buyer selects sellers and products directly; `automatic` means the platform optimizer owns product selection and rejects manual `create_media_buys` calls. **Trapped-campaign recovery (including Pia's):** AI-8988 backfilled existing goal-bearing campaigns without an explicit selection to `inventorySelection: "automatic"`. To stage manual media buys again, set `autonomy.inventorySelection: "manual"` explicitly via `update_campaign`, or clear goals through AI-8963's follow-up; no support ticket is needed.
- **Audience targeting**: Target or suppress audiences via the `audienceConfig` field at campaign creation or update. Audiences are synced to the advertiser first via `sync_audiences`.
- **Auto-select products (pick for me)**: Use the `auto_select_products` operation to let the system automatically choose products and allocate budget using AI. Requires `autonomy.inventorySelection: "automatic"` and discovered products.

### External AdCP buyers (alpha)

When a user asks whether their own AdCP SDK/client can control a seller through
Interchange, teach from the public
`/v2/concepts/adcp-on-the-buy-side` documentation. The inbound protocol flow is a
directed campaign because the client addresses one Seller:

- **Seller-endpoint provenance** (`campaign.directed.provenance =
  "storefront_endpoint"`): an external client calls one Interchange-hosted Seller.
  The Seller Account manages execution while Interchange anchors contract, governance,
  ledger, and reporting evidence and dispatches the full zero-fee media budget. The
  campaign projects as `mode: "directed"`.
- **Provider-account provenance** (`campaign.directed.provenance =
  "connected_account"`): an Interchange buyer connects a provider account.
  Upstream campaigns adopted as read-only mirrors are tracked; campaigns
  authored through Interchange are managed. Both project as `mode: "directed"`.

For an external AdCP client, ask whether it is addressing one Seller directly
or asking Interchange to perform cross-Seller discovery/allocation. Direct
single-Seller addressing is compatibility
`mode: "directed"`; a brief is `discovery`; an objective is `performance`.
Those external-client paths are `management: "managed"` because the buyer
authored the buy through Interchange. A provider-account mirror is instead
`management: "tracked"`. A campaign may end with one seller without that
changing anything. “Inbound” describes provenance, not a fourth campaign mode.

This is an external-client integration, not a sequence to drive by improvising
buyer `api_call` operations. Explain that the client follows the Listing's
verified AdCP action: `/adcp/mcp` on a Seller CNAME or
`/seller/{platformId}/mcp` on the shared host. It uses standard AdCP tasks. The authenticated
credential must resolve to the buyer and advertiser, and enrollment requires a
currency policy plus per-buy and aggregate caps. Do not offer to provision
Seller profile access or buyer policy from a buyer conversation.

#### Private creative upload (gated)

On a configured Meta Storefront, `upload_creative_asset` offers JPEG/PNG upload
in compatible MCP App hosts. Call the model-visible tool with the selected
delegated provider account as `{ "account_id": "act_..." }`; it is required and
must match the connection scope. Managed MP4 and audio uploads use their own
account-access and service settings. Other Storefront connections do not show
the JPEG/PNG option. Never invent an account ID or substitute credentials, URLs,
or image bytes.

The Task accepts one JPEG or PNG up to 30,000,000 bytes. It checks the file
signature, size, and SHA-256 digest in the browser, then performs a direct
private upload. `prepare_creative_asset_upload` and
`finalize_creative_asset_upload` are app-only tools. Never call them from the
model, relay their signed URL, or persist that URL. Successful finalization
returns a stable `scope3-asset://v1/...` reference for the creative workflow;
use that reference rather than the temporary storage capability.

If `upload_creative_asset` is absent, the capability is not configured for
that Storefront. Explain that it is unavailable and do not invent an app-only
bypass. Treat fixture coverage as protocol evidence only: staging and
production readiness require successful Claude and ChatGPT live canaries.

State the money and reporting boundary precisely: the external client authorizes
a GROSS buyer budget; Interchange pins zero-fee AdCP entitlement terms, so GROSS and
NET are equal and the full media budget reaches the seller. `get_media_buys` and live
`get_media_buy_delivery` stay correlated by the dual-key media-buy identity.
Governed `update_media_buy` and cancellation are supported for storefront-endpoint
campaigns when the customer/storefront policy is active and the addressed storefront
declares the operation. They share the durable campaign-spine journal, budget-delta
caps, SCD2 activation, commitment reconciliation, and ambiguous-outcome fence. Adding
packages on this path remains unsupported. Webhooks, scheduled delivery, and
invoice-grade delivery are not part of the current alpha. Direct `ADAPTER` ingress
also requires the delegated provider account. Never describe the alpha delivery read
as a cached reporting history or a billing statement.

For a platform-managed campaign created through Interchange,
`get_campaign_delivery` can follow its persisted provider media-buy identity
when the campaign has exactly one current primary media buy and every product
on that buy resolves to the same storefront. Use the Interchange campaign ID;
never substitute an upstream campaign or media-buy ID. If the campaign spans
storefronts or resolves ambiguously, the read fails closed.

### Tracked campaigns from connected provider accounts (API mode: `directed`)

> Connected provider account enrollment is read-only. Provider capability,
> credential, account-access, and advertiser-mapping checks still apply. Never
> improvise a provider-specific write bypass.

In buyer-facing language, call these **tracked campaigns**
(`management: "tracked"`). `directed` is the deprecated wire mode value,
not a product name; never invent a `seller_managed` or `tracked` mode value.

Tracked campaigns are storefront-authoritative 1:1 projections. Do not run
discovery or execute them, and never edit the shell independently. Each one is
exactly one upstream campaign, one AdCP media buy, and one provider account. Its shell
fields are projections of the buy. To pull a fresh upstream snapshot without
writing to the provider, call `update_campaign` with
`{ "mode": "directed", "refresh": true }`.

Campaigns created or operated through Interchange use the canonical product and
media-buy lifecycle. A new campaign in a provider account must be created in
that account first and then mirrored. The inbound AdCP buyer edge is a separate
protocol door with its own policy; do not drive it by improvising buyer
`api_call` operations.

**In the chat surface, prefer `open_connections_page`** (see *Connections page* above) for connecting an official adapter and mapping its accounts — the widget owns the Connect handoff and inline advertiser mapping. Use the operation-by-operation flow below only for a headless/AdCP JSON client with no chat surface, or the registered external-AdCP-source branch (step 4) the widget does not cover.

Required read-only setup and flow, one operation per turn:

1. Identify the provider path. For an official adapter, use
   `list_storefronts` with `limit: 100`, inspect `adapterConnection`, and
   follow steps 2–3. For a registered external AdCP source, use
   `get_storefront_capabilities` and `list_agent_credentials`, then follow
   the registered-source branch below. Never accept an arbitrary endpoint URL
   or raw secret.
2. **Official adapter:** if it is not connected, call `connect_storefront`,
   give its `connectionUrl` to the human, and END YOUR TURN while they
   complete authorization. Then use `list_storefront_connections`. If the
   buyer says provider-side access changed or a newly granted account is
   missing, call `refresh_storefront_connection_accounts` with the exact
   `connectionId` returned by that list; do not reconnect a healthy grant.
   Re-list with `list_storefront_connection_accounts`; let the buyer choose a buyable
   advertiser account. Organization/manager and publisher-identity rows cannot
   be subscribed.
3. **Official adapter mapping:** use
   `list_storefront_connection_account_mappings`. If the account is not
   mapped, use `list_advertisers`, ask which advertiser owns it, then call
   `map_storefront_connection_account_to_advertiser`. Re-list mappings later
   and do not proceed until the mapping is visible.
4. **Registered AdCP source:** register credentials only through
   `register_source_credentials`, discover the source account with
   `list_available_accounts`, and link it to the advertiser. Then call
   `connect_adcp_storefront` with the exact returned IDs. The returned
   `connectionId` and `connectionAccountId` enter the same subscription
   flow. Do not use this operation for official adapters, managed sources,
   unknown storefronts, or unregistered provider identities.
5. Call `subscribe_directed_campaigns` with the mapped IDs. Subscription
   starts enumeration and a one-year campaign-metadata backfill attempt. It is
   durable even when enumeration is unsupported or unhealthy. Do not claim
   delivery history is imported at subscribe time.
6. On a later turn, call `get_directed_campaign_subscription` to inspect
   `lastSyncedAt`, `lastSyncStatus`, safe error fields, and the fixed
   metadata-history boundary `backfillStart`.
7. On the next turn, call `list_campaigns` with
   `params: { "mode": "directed", "advertiserId": "..." }` to show the
   mirrors.
8. Call `get_campaign_delivery` with `pathParams.campaignId` to read live
   delivery through the provider. Optional `startDate` / `endDate` use
   `YYYY-MM-DD`; omitted dates default to the one-year window through
   yesterday.

Recovery guidance:

- Account or mapping not found: re-run the corresponding official-adapter list
  operation and use only IDs it returns.
- `lastSyncStatus: "ERROR"`: show `lastSyncError`, `lastSyncedAt`, and
  safe machine-readable fields. Existing mirrors survive failed or partial
  snapshots. Reconnect only for authentication or account-authorization errors.
- `mirrored: 0` with `lastSyncStatus: "SUCCESS"` is a successful empty
  account, not a failed backfill.

When the buyer wants to stop mirroring, call
`unsubscribe_directed_campaigns`. It pauses the subscription and retires
local projections; it does not delete upstream campaigns.

**Delivery is always live read-through.** Every `get_campaign_delivery` call
invokes the provider's `get_media_buy_delivery`. Raw payloads and time series
are not persisted. Only the latest aggregate metrics, requested window, and
refresh timestamp are stored. Subscription and metadata sweeps do not pull
delivery. This operation also supports an unambiguous single-storefront
platform-managed campaign through its persisted provider media-buy identity;
it is not limited to tracked campaigns.

```json
{
  "operation": "subscribe_directed_campaigns",
  "pathParams": { "connectionId": "42", "accountId": "81" },
  "body": { "advertiserId": "12345", "sourceId": "tiktok" }
}
```

```json
{
  "operation": "get_directed_campaign_subscription",
  "pathParams": { "connectionId": "42", "accountId": "81" }
}
```

```json
{
  "operation": "get_campaign_delivery",
  "pathParams": { "campaignId": "campaign_123" },
  "params": { "startDate": "2026-01-01", "endDate": "2026-07-10" }
}
```

**⚠️ HARD RULE: One API Call Per Turn**

Only make ONE mutating or discovery API call per turn. After that call, present the results and END YOUR TURN. Do not paginate, re-discover, or chain additional calls — wait for the user to tell you what to do next.

**Required flow when user says "create a campaign":**
1. First determine who should steer. A connected provider account campaign is a
   read-only tracked projection with compatibility `mode: "directed"`; create
   it in that platform. Use `discovery` when Interchange should act from a brief
   and `performance` when Interchange should allocate toward an objective. A
   managed campaign can still realize one seller.
2. For a platform-managed campaign, collect campaign details: name, advertiser, budget, any stated budget currency, flight dates, brief, and any targeting constraints. There is no storefront routing type or routing choice; never ask the user to choose `DECISIONED` or `ROUTED`. The budget is always GROSS — `budget.total` is the all-in amount the customer pays, and the Scope3 fee is carved out of it server-side. There is no separate `mediaBudget` field in the response and no fee-model choice to collect.
3. Ask whether they want to target or suppress any audiences. If yes, list the advertiser's audiences (`list_audiences` operation) and let them choose. If the advertiser has no audiences, let the user know and offer: "You don't have any audiences synced yet. I can help you sync audiences to Scope3 — just ask me how to get started."
4. Ask if they want to attach a catalog — if yes, list their catalogs via `list_catalogs` operation and let them pick one
5. Optionally collect `performanceConfig.optimizationGoals` — if the user wants Scope3 to optimize toward specific performance goal(s), ask what they want to optimize toward. This is optional; campaigns can be created without it.
6. Ask if they also want to browse and attach specific products (discovery). Based on their choice:
   - **Yes**: Run discovery ONCE, present results, END YOUR TURN. The user drives what happens next.
   - **No**: Proceed to campaign creation.
7. Include `audienceConfig` if the user selected audiences in step 3
8. When ready, launch: use `execute_campaign` operation

**Required fields for platform-managed campaign creation:**
- `advertiserId` (number) — NOT `advertiser_id`
- `name` (string)
- `flightDates` (object) — NOT `startDate`/`endDate` at root level
  - `flightDates.startDate` (ISO 8601 datetime)
  - `flightDates.endDate` (ISO 8601 datetime)
- `budget` (object) — NOT a number
  - `budget.total` (number)
  - `budget.currency` (string, optional). Every campaign is created in the advertiser's currency; an advertiser is single-currency. Pass the advertiser's currency or omit it — never a different currency, the server rejects mismatches (400, field `budget.currency`). Preserve the user's stated currency as a three-letter ISO code (`€`/`euro`/`euros`/`Euro` -> `"EUR"`, `£`/`pound`/`pounds`/`sterling` -> `"GBP"`, `US dollar`/`USD` -> `"USD"`); do not let a non-USD request fall back to USD. Ask if ambiguous, including standalone `$`, `dollar`, `dollars`, or `dólares`. Currency is immutable after campaign creation.

The budget is always GROSS — `budget.total` is the all-in amount the customer pays, fee included. Media buy budgets are gross too and are capped against `budget.total` directly (`Σ media buy budgets ≤ budget.total`, same denomination — there is no separate post-fee media ceiling). Use `unallocatedBudget` (see **Get Campaign** below) for the remaining headroom rather than re-deriving it yourself. There is no fee-model field on the campaign to send or ask the buyer about.

**Optional fields at creation:**
- `discoveryId`: Attach an existing discovery session
- `productIds`: Product IDs to pre-select (requires discoveryId)
- `performanceConfig`: For performance optimization. Contains `optimizationGoals` array. Each goal has `kind` (`"event"` or `"metric"`). Event goals have `eventSources` array (each with `eventSourceId`, `eventType`, optional `valueField`) + optional `target` object (`kind: "per_ad_spend"` or `kind: "cost_per"` with `value`). Metric goals have `metric` string + optional `target`. Goals can include `attributionWindow` and `priority`.
- `optimizationApplyMode`: `"AUTO"` or `"MANUAL"` (default). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Overrides the advertiser-level default.
- `catalogId` (number, optional): Attach a single catalog to the campaign. Only **one** catalog per campaign. The catalog must belong to the same advertiser. Get available catalogs via `list_catalogs` operation. When attached, the catalog is automatically included in product discovery requests — referenced by ID for agents that have the catalog syndicated, or sent inline (feed URL or items) otherwise.
- `constraints.channels`: Target channels (display, olv, ctv, social)
- `constraints.geo_countries` / `geo_countries_exclude`: ISO 3166-1 alpha-2 country codes (`countries` is accepted as a deprecated alias and normalized to `geo_countries`)
- `constraints.geo_regions` / `geo_regions_exclude`: ISO 3166-2 subdivisions (e.g. `"US-CA"`)
- `constraints.geo_metros` / `geo_metros_exclude`: metro targeting, `{ system, values }` objects (e.g. Nielsen DMAs)
- `constraints.geo_postal_areas` / `geo_postal_areas_exclude`: postal-area targeting, preferably `{ country, system, values }` objects. Each registered country requires its own `system` value (e.g. `{ country: "AU", system: "postcode", values: ["2000"] }` — NOT `system: "postal_code"`, which only works for `ZA` or an unregistered country like `{ country: "NL", system: "postal_code", values: ["1011"] }`). See "Postal-Area Country/System Pairing" above for the full table. Use legacy `{ system, values }` only for deprecated country-fused systems.
- City names are not a supported targeting field. When a buyer names cities/towns, preserve the exact city list in `brief` and use a machine-readable overlay only when you can resolve the intent safely: `geo_postal_areas` for known postal areas, `geo_proximity` for radius/travel-time/store-area intent, or `geo_metros` only for declared metro systems. Do not invent `geo_cities`, do not pass raw city names as targeting, and call out ambiguous places (for example, Bergen or Laren) for confirmation.
- `constraints.language`, `constraints.device_type`, `constraints.device_platform`: AdCP language/device overlays
- `brief`: Campaign brief. **MUST be the ENTIRE brief from the client — never summarize or truncate.**
- `audienceConfig`: Audience targeting and suppression. Contains `targetAudienceIds` (audiences to **include**) and `suppressAudienceIds` (audiences to **exclude**). Audience IDs come from `list_audiences` operation.

---

## Catalogs (product feeds)

A catalog is a feed of an advertiser's **products** (items, prices, links, images) synced onto the advertiser account and used for catalog-based buying / dynamic creative. Catalogs are per-advertiser and distinct from creative assets.

**Operations** (all scoped to `/api/v2/buyer/advertisers/:advertiserId`):
- `list_catalogs` — `GET .../catalogs`. List the advertiser's catalog feeds with health, sync status, item count, and latest version.
- `refresh_catalog` — `POST .../catalogs/:catalogId/refresh`. Re-pull a URL-backed feed (`executeActivation` optional).
- `preview_catalog_activation_plan` — `POST .../catalogs/:catalogId/activation-plan/preview`. Dry-run the plan (campaign groups, syndication targets, and — currently gated — creative assets) without writing; `save` optional.
- `execute_catalog_activation_plan` — `POST .../catalogs/:catalogId/activation-plan/execute`. Persist the plan's downstream jobs as `pending` (`dryRun` optional). A runner that carries out those jobs (live campaigns, creative, seller push) is not yet available, so executing does not by itself make anything go live; the creative leg is additionally gated on brand identity + per-format output.

**As a portable Page:** `open_buyer_catalogs` opens Catalogs for the selected advertiser. It connects hosted URLs, uploads or replaces CSV/TSV/XLS/XLSX/JSON rows through the typed `sync_catalogs` operation, shows item review evidence, lists feed health, and owns sync, preview, and save-plan actions inline. If the buyer supplies an exact URL feed or inline items in chat, call `sync_catalogs` directly; if they want to browse, add, upload, replace, or manage feeds, open the Page. Never manufacture a prompt from a Page button.

---

## Browsing Products Before Creating a Campaign

**When a user wants to browse products but hasn't created a campaign yet:**

Users may want to explore available inventory before committing to a campaign. Use the `discover_products` operation which discovers products based on the advertiser's context and returns a `discoveryId` along with the discovered products.

**Interactive flow:**
1. **Discover products** — Use the `discover_products` operation with advertiser context
   - Returns `discoveryId` and product groups — save the `discoveryId` for later use
2. **Present products** — Show available inventory in a user-friendly way
3. **Refine (optional)** — If the user wants changes ("more video", "remove that one", "more like this"), use `discover_products` operation again with `discoveryId` and a `refine` array
4. **Select products** — When the user likes products, add them via `add_discovery_products` operation
5. **Attach to a campaign** — Create a campaign with the `discoveryId` via `create_campaign`, or attach it to an existing campaign via `update_campaign` with `discoveryId`

**Request Parameters (Filtering):**
- `publisherDomain` (optional): Filter products by publisher domain (exact domain component match). Example: "example" matches "example.com", "www.example.com" but "exampl" does not match
- `storefrontIds` (optional, array of integers): **Highly encouraged when the user wants results scoped to specific sellers.** Filter discovery to these storefronts (from `list_storefronts`).
- `storefrontNames` (optional, array of strings): Filter discovery to storefronts whose name matches (case-insensitive substring). Use when the user names a seller (e.g. "Acme", "Acme Exchange").
- `pricingModel` (optional): Filter by pricing model (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`). Use when a user wants inventory with a specific pricing type.
- `proposalCode` (optional): Storefront-issued proposal code (e.g. `"PRP-XK4A29"`) for a seller's saved offer. When set, the server short-circuits discovery and returns that saved snapshot; all other discovery filters (`brief`, `budget`, `channels`, `refine`, etc.) are ignored.

> **Note:** Empty arrays for `storefrontIds` / `storefrontNames` are treated the same as omitting the field — both mean "no request-level filter." If a `campaignId` is also provided and the campaign was created with `storefrontIds`, the campaign-level pin is used as the fallback in that case.

See the Campaign Workflow below for the full step-by-step with HTTP examples.

---

## Adding Products to a Campaign

**When the user wants to choose specific inventory:**

Product discovery and selection is done via the discovery endpoints. Discover products, select the ones you want, then attach them to the campaign.

1. **Discover products** — Use `discover_products` operation
   - Show product groups, publishers, channels, and price ranges in a user-friendly way
2. **Present results and let the user choose** — Show the discovered products and ask which ones they want to add. Do NOT auto-select products for the user.
3. **Refine (optional)** — If the user wants to iterate ("more like this", "remove that", "more video options"), use `discover_products` operation again with `discoveryId` and a `refine` array
4. **Add their selections** — Use `add_discovery_products` operation with the products the user chose
   - Show the updated product list and budget allocation
5. **Attach to campaign** — Create the campaign with `discoveryId` via `create_campaign`, or update an existing campaign via `update_campaign` with `discoveryId`
5. **Confirm readiness** — "Your campaign has X products selected with $Y allocated. Ready to launch?"
6. **Launch** — Use `execute_campaign` operation

See the Discovery Workflow (Pre-Campaign Product Discovery) section below for the full step-by-step with HTTP examples.

### Reading multi-storefront `get_products` results

`get_products` fans out to every storefront in the buyer's eligible discovery scope (unless the request pins `storefronts`), and seller answers arrive independently. Read results with this contract:

- **Poll to completion.** The first response is a provisional snapshot containing only the sellers that have answered so far. Keep polling with the returned execution identity until `results_complete: true`:

```json
{
  "buying_mode": "brief",
  "ext": { "interchange": { "execution_id": "query-id", "since_revision": 3 } }
}
```

- **Limit the wait.** Space polls a few seconds apart, and do not poll forever: if `results_complete: true` has not arrived after roughly a minute, stop and present what you have, clearly labeled as partial, with the still-pending sellers named from `pending_agents`. Offer to keep waiting; the `execution_id` remains the durable query record, so stopping and resuming later loses nothing.
- **Replace, never append.** Every response is a replacement snapshot: `revision` increases, `provisional: true` means discard the previously displayed result, and `pending_agents` lists sellers still working.
- **Never present an early provisional revision as the whole market.** Response speed decides which sellers appear early, not which sellers are retained in the completed result. If you show partial results, say they are partial and finish polling.
- **Paginate only after completion.** No stable cursor is issued until `results_complete: true`; then follow `pagination.cursor` until `has_more: false`. `pagination.max_results` bounds the response page size, not the seller fan-out.
- **A successful empty storefront result is not "no matching inventory."** `storefront_results[].message` preserves the seller's explanation (including readiness blocks); surface it instead of concluding nothing matched.

See the Multi-Storefront Get Products documentation page for the full progressive-response contract.

### Continuing a multi-storefront `get_products` query

Use `create_media_buys` when the user has chosen one or more storefront-qualified proposals or proposal-less products returned by `get_products`. The `ext.interchange.execution_id` is the durable query/refinement record; the DRAFT campaign is the durable shopping cart and the only parent of the resulting media buys.

```json
{
  "operation": "create_media_buys",
  "body": {
    "productQueryId": "query_123",
    "campaign": { "campaignId": "campaign_123" },
    "mode": "stage",
    "replace": false,
    "selections": [
      { "kind": "proposal", "proposalId": "sfp1:42:...", "totalBudget": 40000 },
      { "kind": "product", "productId": "sf1:57:...", "budget": 10000, "bidPrice": 8.5 }
    ]
  }
}
```

- Use `campaign: { "campaignId": "..." }` for an existing cart, or `campaign: { "create": { ... } }` with the complete campaign create fields to create the DRAFT campaign inline.
- Use `mode: "stage"` to update DRAFT media buys without contacting sellers. Use `mode: "execute"` only after the buyer has confirmed purchase; it stages and then invokes the normal campaign execution path.
- `replace: false` merges selections idempotently; `replace: true` makes this batch the campaign cart's complete selection set.
- A proposal selection applies the proposal's product allocations to the cart. It does not send the seller's proposal ID as an exact proposal-acceptance primitive.
- Execution spans bilateral seller calls and is not atomic. On partial failure, successful buys remain successful; retry the unchanged request to dispatch only remaining DRAFT work.
- There are no independently managed media-buy resources in the buyer workflow: this convenience operation always creates or uses a campaign cart.

### Setting Performance Optimization

**When the user wants the system to optimize for business outcomes:**

1. **Check conversion events** — Use `get_event_summary` operation with `eventType: "conversion"` to see what events are available for optimization
   - If none exist, help the user configure event sources first
   - **Note:** Event data is aggregated hourly. Newly reported events may take up to 1 hour to appear in the summary.
2. **Set optimization goals** — Include `performanceConfig` when creating or updating a campaign. Goals are hints, not a composition rule. Send `performanceConfig: null` to clear them.
   - Required: `optimizationGoals` array with at least one goal object
   - Each goal has `kind` (`"event"` or `"metric"`)
   - Event goals: `eventSources` array (each with `eventSourceId`, `eventType`, optional `valueField`), optional `target` (`kind: "per_ad_spend"` or `kind: "cost_per"` with `value`), optional `attributionWindow`, optional `priority`
   - Metric goals: `metric` string, optional `target`, optional `priority`
3. **Launch** — Use `execute_campaign` operation

### Auto-Selecting Products (Pick For Me)

**When the user wants the system to choose products automatically:**

Instead of manually browsing and selecting products, campaigns with automatic
inventory selection can use auto-selection:

1. **Set composition ownership** — Set `autonomy.inventorySelection: "automatic"`. Goals are optional hints, not the eligibility rule.
2. **Ensure products are discovered** — The campaign must have discovered products (via `discover_products` operation or auto-discovery at campaign creation with `performanceConfig` + `constraints.channels`)
3. **Auto-select** — Use `auto_select_products` operation (no request body needed)
   - The system uses AI to select the best products based on the campaign brief, budget, constraints, and optimization goals
   - Budget is allocated across selected products based on strategic fit
   - Any previous product selections in the discovery session are replaced
4. **Review selections** — Present the selected products, budget allocations, and rationale to the user
5. **Refine (optional)** — The user can adjust selections:
   - `discover_products` operation with `discoveryId` and `refine` — Iterate on results (e.g., "more like this", "omit that", "more video")
   - `add_discovery_products` operation — Add products
   - `remove_discovery_products` operation — Remove products
   - Or use `auto_select_products` operation again to re-select
6. **Launch** — When the user confirms, use `execute_campaign` operation

**Response includes:**
- `selectedProducts`: Array of products with budget allocations
- `budgetContext`: Campaign budget vs allocated amount
- `selectionRationale`: AI-generated explanation of the selection strategy
- `selectionMethod`: `"scoring"`, `"measurability"`, or `"cpm_heuristic"`
- `testBudgetPerProduct`: Test budget allocated per product (when using measurability or scoring strategy)
- `productCount`: Number of products selected

---

## Discovery Workflow (Pre-Campaign Product Discovery)

**When to use:** User wants to browse, select, or control which specific inventory/products to include before or independently of campaign creation.

**Prerequisites:** Advertiser exists with a linked brand (set during advertiser creation via `brand`).

**Storefront discovery:**
- Discovery is not filtered by a legacy routing classification. If the user wants execution through specific storefronts, narrow results via `storefrontIds` / `storefrontNames`.
- `DECISIONED` / `ROUTED` is a deprecated billing observation, never a storefront or media-buy execution type and never a client input.
- Derive real storefront differences only from explicit capabilities and authentication requirements. Adapter storefronts may require the buyer's registered provider credentials, but adapter identity does not create a separate routing type.
- Adapter products expose publisher-scoped AdCP `placements[]` derived from the same catalog published in `adagents.json`. Treat `mode: "included"` as descriptive inventory already fixed by the product; never promise that the buyer can narrow it. Only `mode: "targetable"` authorizes selection. Request placement performance with `get_media_buy_delivery.reporting_dimensions.placement` only when the product reports `supports_placement_breakdown: true`; read the resulting package-level `by_placement[]` rows rather than inferring performance from targeting.
- Meta products expose publisher-scoped placements for Facebook and Instagram Feed, Stories, and Reels. Preserve the buyer's exact requested surfaces in the full discovery brief or a `refine[].ask`; do not substitute creative `placement_refs`, because those route creative assets and do not narrow purchased inventory. If the buyer does not select a placement, leave it unspecified so Meta Advantage+ placements remain the default.

**Highly encouraged: ask the user which storefronts to target.** Discovery returns far more relevant results — and the buyer keeps control of seller mix — when you scope to specific storefronts via `storefrontIds` (preferred, IDs from `list_storefronts`) or `storefrontNames`. Only run unscoped discovery if the user explicitly wants to see everything available. If a campaign was created with `storefrontIds`, that filter is auto-applied to every discovery run on that campaign — you don't need to resend it.

### Interactive Flow

Follow these steps in order. **Do NOT skip product discovery.**

**Step 1: Discover products**

**Operation:** `discover_products`
```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "channels": ["ctv", "display"],
  "countries": ["US"],
  "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>",
  "storefrontIds": [42, 57]
}
```
**As operation:**
```json
{ "operation": "discover_products", "body": { "advertiserId": "12345", "channels": ["ctv", "display"], "countries": ["US"], "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>", "storefrontIds": [42, 57] } }
```
→ Returns `{ "discoveryId": "...", "productGroups": [...], "totalGroups": 25, "hasMoreGroups": true, "summary": { ... } }`

Save the `discoveryId` for all subsequent steps.

**MCP progress notifications:** `discover_products` supports the MCP protocol's `_meta.progressToken`. When the client supplies one on the `api_call` tool call, the server emits `notifications/progress` once per seller as it answers during the fan-out — `progress` = sellers settled, `total` = sellers queried, message like "Magnite answered — 11 products (3 of 14)" — before the tool result returns. Works on both progressive and non-progressive calls; clients that send no progress token see no change.

**Step 2: Present results and END YOUR TURN**

Present the discovered products and END YOUR TURN. If `hasMoreGroups: true`, tell the user more are available. If `incompleteAgents` is present with `retryWithLongerWaitAvailable: true`, explain that some storefronts timed out or returned partial results under the quick wait, include any seller-provided `incompleteScopes` detail, and ask whether the user wants to retry with a longer wait window before setting `waitMode: "long"` or `waitSeconds` above 30.

To browse more products or apply filters, use:

**Operation:** `browse_discovery`
```http
GET /api/v2/buyer/discovery/{discoveryId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=15
```
**As operation:**
```json
{ "operation": "browse_discovery", "pathParams": { "discoveryId": "<id>" }, "params": { "groupLimit": "10", "groupOffset": "0", "productsPerGroup": "15" } }
```

**When discovery returns no products:**

A discovery response with 0 products is **not an error** — it means no products matched. Do NOT say "discovery failed." Do NOT speculate about why (e.g. "likely because X wasn't set up correctly" — you have no idea why, and guessing will be wrong and confusing). Just state the fact and offer these specific next steps:

1. **Add specificity** — Include budget, flight dates, specific channels (e.g. CTV, display), or audience targeting. Richer briefs give agents more to match against.
2. **Try different channels or geos** — The available inventory may not cover the requested combination.
3. **Reduce the ask** — If the brief is very narrow (e.g. a niche audience + specific publisher + tight budget), broadening one or more constraints often unlocks results.
4. **Try specific filters** — Filter by `storefrontIds` (preferred), `storefrontNames`, or `publisherDomain` to target sellers known to have relevant inventory.

Example response when no products are returned:
> No products were returned for this brief. A few things that might help: adding a budget or flight dates, specifying channels (CTV, display, etc.), broadening the audience, or filtering by a specific seller. Want to try refining the brief?

**Explaining product relevance (IMPORTANT):**

Each product includes a `briefRelevance` field that explains WHY the product is a strategic fit for the campaign. When presenting products, you MUST:

1. **Lead with the "why"** — Don't just list product names and CPMs. For each product or product group, explain why it matters for THIS specific campaign. Use the `briefRelevance` text as your starting point but make it conversational.
2. **Connect products to the brief** — Reference the user's campaign goals, target audience, or brand context. Example: "These products from Acme target family audiences with parenting and food-related segments — a direct match for your Fanta Meals campaign aimed at families."
3. **Highlight strategic differentiators** — Call out what makes a product stand out: guaranteed delivery, best-value CPM, audience segment alignment, or estimated reach. Don't bury these in a list.
4. **Group-level insight** — When presenting a sales agent's products, summarize what that agent brings to the table for this campaign. Don't just say "Products from Acme Sales (5 products)" — say WHY Acme Sales matters here.
5. **Skip empty relevance** — If a product has no `briefRelevance`, present it normally with its attributes. Don't fabricate relevance that doesn't exist.

**Bad example** (too basic):
> Acme Sales has 5 products available: Americas Test Kitchen ($12 CPM, CTV), Rakuten TV ($8.50 CPM, CTV)...

**Good example** (explains why):
> Acme Sales is a strong fit here — they offer family-targeting segments like "Parenting Babies and Toddlers" and "With Children" that align directly with your Fanta Meals audience. Their Americas Test Kitchen inventory puts your ads in a food/cooking context, and at $8.50–$12.00 CPM you're getting competitive rates with guaranteed delivery on several products.

### Filtering Product Discovery Results

When discovering products, these filters narrow results before grouping and pagination:

- `publisherDomain`: Filter by publisher website. Use when a user mentions a specific publisher or website.
  - Example: "hulu" matches "hulu.com", "www.hulu.com" but "hul" does not
- `storefrontIds` (array of integers): **Highly encouraged.** Filter to these storefronts (IDs from `list_storefronts`).
- `storefrontNames` (array of strings): Filter to storefronts whose name matches (case-insensitive substring). Use when the user names a seller. Accepts multiple values.
- `pricingModel`: Filter by pricing model. Use when a user asks about specific pricing types.
  - Valid values: `cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`
- `proposalCode`: Not a filter — a **redemption handle**. Set this to a storefront-issued `PRP-XXXXXXXX` code to pull the seller's saved offer into discovery. When present, all other filters are ignored.

Filters can be combined. Multiple values within a filter use OR logic (match any); different filters use AND logic. Empty arrays for `storefrontIds` / `storefrontNames` are treated the same as omitting the field — both mean "no request-level filter," and the campaign-level pin is the fallback if a `campaignId` is also provided.

**How to communicate filtering to users:**

Do NOT mention filter parameter names. Respond naturally:
- User: "What do you have from [storefront name]?" → filter by `storefrontNames`
- User: "Show me [publisher] inventory" → filter by `publisherDomain`
- User: "What does [storefront] have on [publisher]?" → filter by both `storefrontNames` and `publisherDomain`
- User: "Show me inventory from [storefront 1] and [storefront 2]" → filter by `storefrontNames` (or `storefrontIds` if you have them)
- User: "Show me CPM inventory" → filter by `pricingModel=cpm`
- User: "What flat rate options are there?" → filter by `pricingModel=flat_rate`
- User: "Who sells CTV inventory?" → show unfiltered results, then offer to narrow by storefront

Each product group corresponds to a storefront's underlying sales agent. To focus on a specific storefront's inventory on subsequent requests, use the storefront filter.

**Step 2.5: Refine results (optional)**

If the user wants to iterate on the results, call `POST /discovery/discover-products` again with the same `discoveryId` plus a `refine` array. Each item in `refine` MUST be an object (not a string):

```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "discoveryId": "<<discoveryId from step 1>>",
  "refine": [
    { "scope": "request", "ask": "show me more video options" }
  ]
}
```

To omit a product:
```json
{ "refine": [{ "scope": "product", "id": "product_123", "action": "omit" }] }
```

To get more like a product:
```json
{ "refine": [{ "scope": "product", "id": "product_123", "action": "more_like_this" }] }
```

The response is the same shape as step 1 plus an optional `refinementApplied` array. Present updated results and let the user iterate or proceed to select.

**Step 3: Select products interactively**

Users can select products in two ways:
1. **Via the interactive card UI** — Users select product cards and click "Select". When this happens, you'll receive a message containing the discoveryId, productIds, and the exact `add_discovery_products` API call to execute. The message will also ask you to prompt the user about per-product budgets. **Ask the user if they'd like to set individual budgets before executing the API call, AND how they'd like to split the budget — by publisher then product, or just by product.** If they provide budgets, add a `budget` field to each product in the request body using their chosen split strategy. If they decline, execute the call as-is without budgets. Do not re-discover products or look up IDs.
2. **Via conversation** — Users describe which products they want in natural language. The productId, salesAgentId, groupId, and groupName are included in the product listing from the discovery response — extract them from there to build the API call. Do not re-discover products to obtain IDs.

**Per-product budget:** Each product supports an optional `budget` field (number, in dollars). Ask about budgets before adding products — don't assume a budget if the user hasn't specified one. When asking, ALWAYS ask how they want to split the budget: **by publisher then product** (allocate per publisher first, then split within each publisher's products — use when the user cares about publisher mix or has publisher-level commitments) or **just by product** (flat allocation across products regardless of publisher — use when the best products should win regardless of publisher). Apply their choice to the per-product `budget` values.

**Bid price (REQUIRED for non-fixed pricing):** When a product's selected pricing option has `isFixed: false`, you MUST include `bidPrice` in the request body. Use the `rate` or `floorPrice` from the product's `pricingOptions` (from the discovery response) as the `bidPrice` value — do NOT ask the user for this. If `isFixed: true`, omit `bidPrice`.

**Operation:** `add_discovery_products`
```http
POST /api/v2/buyer/discovery/{discoveryId}/products
{
  "products": [
    {
      "productId": "product_123",
      "salesAgentId": "agent_456",
      "groupId": "ctx_123-group-0",
      "groupName": "Publisher Name",
      "bidPrice": 12.50,
      "budget": 5000
    }
  ]
}
```
**As operation:**
```json
{ "operation": "add_discovery_products", "pathParams": { "discoveryId": "<id>" }, "body": { "products": [{ "productId": "product_123", "salesAgentId": "agent_456", "groupId": "ctx_123-group-0", "groupName": "Publisher Name", "bidPrice": 12.50, "budget": 5000 }] } }
```

**Meta Facebook Page, Pixel, Instagram actor, and welcome message flow selection:** Meta products sometimes require additional identity fields. Include them here at selection time so execution succeeds on the first attempt:
- `pageId` — Facebook Page ID. Required when the connected Meta ad account has more than one authorized Page. When there is exactly one authorized Page the server selects it automatically and `pageId` can be omitted.
- `pixelId` — Meta Pixel / Dataset ID for conversion tracking. Required for Meta Sales (OUTCOME_SALES) products. Unlike `pageId`, there is **no auto-select**: every Meta Sales buy must explicitly specify a pixel, even when the account has only one.
- `instagramActorId` — Meta Instagram account ID. Required for Instagram Direct destinations (Instagram Direct Conversations, Instagram-based Lead Generation) when the connected ad account has more than one authorized Instagram account. When there is exactly one authorized Instagram account the server selects it automatically and `instagramActorId` can be omitted. Unlike `pageId`, the ambiguous-selection error does not list candidate IDs — ask the user for the Instagram Business Account ID from Meta Business Settings rather than guessing.
- `welcomeMessageFlowId` — Meta welcome message flow ID. Required for lead-to-message products (Meta Leads — Messenger, Instagram Direct, and WhatsApp). There is no auto-select, even with a single candidate: the `welcome_message` readiness slot always reports `decision: "explicit_selection_required"`. Pass one of the welcome-message-flow candidates returned by account resource readiness in the discovery/`get_products` response — the `execute_campaign` error does not enumerate candidates. Candidates are only guaranteed to appear on a catalog-style `discover_products` call with no `brief` — a `buying_mode: "wholesale"` request that still includes a `brief` does not surface them; a natural-language brief call only attaches readiness to the one AI-selected product for that request, and drops it entirely whenever any clarification code is raised — if the response has no `ext.scope3_execution_readiness` on the product, re-run `discover_products` without a brief to get the candidate list. The read is best-effort per authorized Page/Instagram profile: a partial result still returns any real candidates the identities read in time did find, with `reason` naming how many weren't read. The slot reports `decision: "inventory_unavailable"` — instead of a false "no compatible flow" — whenever a read failure (an identity's own read, or the authorized-identity list itself) leaves zero candidates found; an account with zero authorized Pages or Instagram profiles to begin with is a different, successfully-confirmed case and still correctly reports no compatible flow. Only the first 50 authorized identities are ever read, so an account with more may be missing a candidate that exists on identity 51+ — there is no way to page past this today.

If `execute_campaign` already ran and failed with "page_id is required", "pixel_id is required", "instagram_actor_id is required", or "requires ext.meta_welcome_message_flow_id", a DRAFT buy already exists — **do not re-add via `add_discovery_products`**. Call `update_media_buy` on the stuck DRAFT buy with `products: [{ "productId": "...", "pageId": "..." }]` (or `pixelId`/`instagramActorId`/`welcomeMessageFlowId`), then retry `execute_campaign`. `pageId` and `pixelId` valid IDs are listed in the error message; `instagramActorId` and `welcomeMessageFlowId` are not — ask the user, or read the welcome-message-flow candidates from account resource readiness in the discovery response. See Common Mistake #16. **`pageId`, `pixelId`, `instagramActorId`, and `welcomeMessageFlowId` are all DRAFT-only** — `update_media_buy` rejects them once the buy has left DRAFT status. Any combination can be set in the same `products[]` entry when required together.

Show the updated selection with selected products and budget allocation.

**Step 4: Confirm readiness**

"You have selected X products with $Y allocated. Ready to create the campaign?"

**Step 5: Create the campaign**

> ⚠️ **Every campaign is created in the advertiser's currency; an advertiser is single-currency.** Pass the advertiser's currency in `body.budget.currency` or omit it — never a different currency, the server rejects mismatches (400, field `budget.currency`). Always confirm the currency with the user before calling `create_campaign`. Never assume USD — always use the currency the user specified.

**Operation:** `create_campaign`
```http
POST /api/v2/buyer/campaigns
{
  "advertiserId": "12345",
  "name": "Q1 2025 EU CTV Campaign",
  "brief": "CTV campaign with a 50,000 euro budget.",
  "discoveryId": "abc123-def456-ghi789",
  "flightDates": {
    "startDate": "2025-02-01T00:00:00Z",
    "endDate": "2025-03-31T23:59:59Z"
  },
  "budget": {
    "total": 50000,
    "currency": "EUR"
  }
}
```
**As operation:**
```json
{ "operation": "create_campaign", "body": { "advertiserId": "12345", "name": "Q1 2025 EU CTV Campaign", "brief": "CTV campaign with a 50,000 euro budget.", "discoveryId": "abc123-def456-ghi789", "flightDates": { "startDate": "2025-02-01T00:00:00Z", "endDate": "2025-03-31T23:59:59Z" }, "budget": { "total": 50000, "currency": "EUR" } } }
```

**Step 6: Launch**

**Operation:** `execute_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/execute
```
**As operation:**
```json
{ "operation": "execute_campaign", "pathParams": { "campaignId": "<id>" } }
```

### Discovery Management

- `list_discovery_products` operation — List selected products
- `add_discovery_products` operation — Add products
- `remove_discovery_products` operation — Remove products (body: `{ "productIds": ["..."] }`)

**Summary:** Discover products → Select products → Create campaign → Execute

**IMPORTANT:** Do NOT expose API details to the user. Communicate conversationally about campaigns, inventory, products, and budgets — not about endpoints or HTTP methods.

---

## Performance Optimization Workflow

**When to use:** User wants the system to optimize for business outcomes automatically.

**Prerequisites:** Advertiser exists (with brand set during creation) + Event source configured.

**Step 1: Verify advertiser**

**Operation:** `list_advertisers`
```http
GET /api/v2/buyer/advertisers?status=ACTIVE&name={advertiserName}
```
**As operation:**
```json
{ "operation": "list_advertisers", "params": { "status": "ACTIVE", "name": "<advertiserName>" } }
```

**Step 2: Check/create event source**

**Operation:** `list_event_sources`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/event-sources
```
**As operation:**
```json
{ "operation": "list_event_sources", "pathParams": { "advertiserId": "<id>" } }
```

If empty, register one or more via the upsert/sync endpoint:
```http
POST /api/v2/buyer/advertisers/{advertiserId}/event-sources/sync
{
  "account": { "account_id": "{advertiserId}" },
  "event_sources": [
    { "event_source_id": "website_pixel", "name": "Website Pixel", "event_types": ["purchase", "add_to_cart"] }
  ]
}
```

Save the `event_source_id` — it's required for optimization goals. Note the snake_case field names: this endpoint is ADCP-aligned (the only event-sources mutation route is `/sync`; there is no per-source POST/PUT/DELETE).

**Step 3: Gather required fields from the user**

Before calling the create endpoint, confirm you have:
- Campaign name (ask the user or confirm a suggested name)
- Flight dates (start and end — ask the user)
- Budget total and currency (ask the user — the campaign is created in the advertiser's single currency; pass that currency or omit it, never a different one)
- Optimization goals: at least one goal with `kind` ("event" or "metric"). For event goals: event source ID (from Step 2), event type (e.g. `purchase`, `lead`), and optionally a `target` (e.g. `kind: "per_ad_spend"` for ROAS or `kind: "cost_per"` for CPA). For metric goals: `metric` string and optional `target`.
- Optional: constraints, attribution window, priority

**Step 4: Create the campaign with performanceConfig**

> ⚠️ **Every campaign is created in the advertiser's currency; an advertiser is single-currency.** Pass the advertiser's currency in `body.budget.currency` or omit it — never a different currency, the server rejects mismatches (400, field `budget.currency`). Always confirm the currency with the user before calling `create_campaign`. Never assume USD — always use the currency the user specified.

**Operation:** `create_campaign`
```http
POST /api/v2/buyer/campaigns
{
  "advertiserId": "12345",
  "name": "Q1 EU ROAS Optimization",
  "brief": "Performance campaign with a 100,000 euro budget.",
  "flightDates": {
    "startDate": "2025-02-01T00:00:00Z",
    "endDate": "2025-03-31T23:59:59Z"
  },
  "budget": {
    "total": 100000,
    "currency": "EUR"
  },
  "performanceConfig": {
    "optimizationGoals": [{
      "kind": "event",
      "eventSources": [
        { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }
      ],
      "target": { "kind": "per_ad_spend", "value": 4.0 },
      "attributionWindow": { "clickThrough": "7d" },
      "priority": 1
    }]
  },
  "constraints": {
    "channels": ["ctv", "display"],
    "countries": ["US"]
  }
}
```
**As operation:**
```json
{ "operation": "create_campaign", "body": { "advertiserId": "12345", "name": "Q1 EU ROAS Optimization", "brief": "Performance campaign with a 100,000 euro budget.", "flightDates": { "startDate": "2025-02-01T00:00:00Z", "endDate": "2025-03-31T23:59:59Z" }, "budget": { "total": 100000, "currency": "EUR" }, "performanceConfig": { "optimizationGoals": [{ "kind": "event", "eventSources": [{ "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }], "target": { "kind": "per_ad_spend", "value": 4.0 }, "attributionWindow": { "clickThrough": "7d" }, "priority": 1 }] }, "constraints": { "channels": ["ctv", "display"], "countries": ["US"] } } }
```

`performanceConfig` must include `optimizationGoals` array with at least one goal. Each goal has a `kind` discriminator: `"event"` goals require `eventSources` array (each with `eventSourceId` and `eventType`); `"metric"` goals require `metric` string. Both kinds support an optional `target` object (`kind: "per_ad_spend"` for ROAS targets, `kind: "cost_per"` for CPA targets, each with a `value`), `attributionWindow`, and `priority`.

**Step 5: Auto-select products (optional)**

If the campaign has discovered products (from auto-discovery at creation), let the system pick the best ones:

**Operation:** `auto_select_products`
```http
POST /api/v2/buyer/campaigns/{campaignId}/auto-select-products
```
**As operation:**
```json
{ "operation": "auto_select_products", "pathParams": { "campaignId": "<id>" } }
```
Returns selected products with budget allocations and AI-generated rationale. Present results and let the user review before launching.

**Step 6: Launch**

**Operation:** `execute_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/execute
```
**As operation:**
```json
{ "operation": "execute_campaign", "pathParams": { "campaignId": "<id>" } }
```

---

## Account Management

Some account management tasks are handled in the web UI at [interchange.io](https://interchange.io). Direct users to these pages for:

| Task | URL | Capabilities |
|------|-----|--------------|
| **API Access** | [interchange.io/user-api-keys](https://interchange.io/user-api-keys) | Organization admins manage WorkOS API keys; secrets appear only once at creation |
| **Team Members** | [interchange.io/admin](https://interchange.io/admin) | Invite members, manage roles, manage advertiser access |
| **Billing** | Available from user menu in the UI | Manage payment methods, view invoices (via Stripe portal) |
| **Profile** | [interchange.io/user-info](https://interchange.io/user-info) | View and update user profile |

**Note:** Billing and member management require admin permissions.

Machine credentials require an organization admin and a human handoff. Organization
API keys are managed in **Settings → API Access**. M2M applications do not have a
management widget yet; a human admin or their developer uses the documented
`/api/v2/m2m-applications` lifecycle with an interactive human session. Never create,
retrieve, reveal, echo, copy, or transport an API key or M2M client secret in an MCP
conversation. Existing secrets cannot be retrieved. The admin must copy the one-time
secret directly into the workload's secret manager; if it is lost, replace it.
Prefer M2M `client_credentials` and short-lived access tokens for unattended
enterprise backends. Use a least-privilege organization API key for simpler scripts
and automation.

### Current Account

Get the customer the authenticated user is currently operating in:

**Operation:** `get_current_account`
```http
GET /api/v2/accounts/current
```
**As operation:**
```json
{ "operation": "get_current_account" }
```

Returns `{ "id", "company", "name", "role" }` where `role` is the user's normalized role (`ADMIN`, `MEMBER`, or `SUPER_ADMIN`).

### List Accounts

List all customers the authenticated user has active membership on:

**Operation:** `list_accounts`
```http
GET /api/v2/accounts
```
**As operation:**
```json
{ "operation": "list_accounts" }
```

Returns `{ "accounts": [{ "id", "company", "name", "role" }] }` where `role` is the user's normalized role (`ADMIN`, `MEMBER`, or `SUPER_ADMIN`).

### Switch Account

Switching the active customer context is a session-mutating operation and is not available through `api_call`. Use the dedicated `customer_switch` MCP tool instead:

```json
{
  "tool": "customer_switch",
  "arguments": { "customerId": 123 }
}
```

Any user can switch to a customer they have an active membership on. SuperAdmin users can additionally switch to any customer. Use the `list_accounts` operation above to discover available customer IDs before switching. After the switch, all subsequent operations in the session are performed on behalf of the selected customer.

If the account you switched into cannot transact, the response carries
`contractBlockReason` and the summary says so. Values:

| `contractBlockReason` | Meaning |
| --- | --- |
| `no_contract` | The account has no active contract with Scope3. |
| `tos_not_accepted` | The account is on standard terms but has not accepted the current Terms of Service. |
| `organization_contract_missing` | The account inherits its parent organization's contract, and the organization has no active one. |

The field is absent when the account is in good standing. It is reported even for
a SuperAdmin whose session bypasses the Terms of Service gate, so an operator can
see the state a real user of that account would hit. Call `get_billing_account`
for the current state and the next action.

### Browser Origins for Custom Agents

Account admins can self-serve exact browser origins for browser-based MCP/OAuth integrations. Teach this as a two-part setup:

1. **OAuth redirect URI**: the full callback URL, including path, for example `https://mcp.example.com/oauth/callback`. Register it with dynamic client registration at `/auth/register`; the response returns the public `client_id` to use with OAuth/PKCE.
2. **Browser origin**: the exact origin only, for example `https://mcp.example.com`. Register it with the browser-origin operations below so browser CORS is allowed for MCP/OAuth calls.

Do not confuse these values. Redirect URIs include a path and must match exactly during OAuth. Browser origins are only `scheme://host[:port]` and never include a path, query, fragment, or wildcard.

```json
{ "operation": "list_browser_origins" }
```

```json
{
  "operation": "create_browser_origin",
  "body": {
    "origin": "https://mcp.example.com",
    "label": "Example Agent"
  }
}
```

Use `delete_browser_origin` with `pathParams: { "id": "<origin-id>" }` to remove one.

Register only exact HTTPS origins (`scheme://host[:port]`). Each production, staging, and development browser host needs its own origin. Do not register redirect URLs here.

Security posture:
- Customer-managed origins are exact-match and HTTPS-only.
- They enable CORS only for `/authorize`, `/auth/register`, `/auth/token`, `/auth/mcp/authorize`, `/.well-known/*`, `/mcp`, and `/mcp/*`; they do not open arbitrary REST API paths to that origin.
- Dynamic redirect registration is public, but it does not by itself authorize a client for a customer. For self-serve clients, Interchange issues an authorization code only when the redirect origin is also approved for the authenticated customer's account.
- They do not receive credentialed cookie CORS. Browser agents should use OAuth access tokens or explicit bearer tokens.
- Do not tell users that registering an origin shares their Interchange login cookie with that site.
- If a browser request is blocked, check: full redirect URI registered through `/auth/register`; exact origin registered by an admin; request goes to a supported browser MCP/OAuth path rather than a REST endpoint; browser fetch does not use `credentials: "include"`.

### Plan & billing (consolidated commercial account)

Named operation: `get_billing_account` (no path params — scoped to the caller's organization by auth context).

```http
GET /api/v2/billing/account
```

```json
{ "operation": "get_billing_account" }
```

Returns `{ "data": { ... } }` — one consolidated commercial-account document: plan and contract status, ToS acceptance, effective pricing, intelligence usage, credit/prepay standing and balance, agreements, child accounts, and the single next action (if any) needed to become or remain paid. Org-admin only; a child-account request resolves to the parent organization. This is the same read model as the `get_billing_account` hand tool (which additionally accepts a `section` argument to return just one section); the operation exists so the Plan & Billing MCP-app widget can hydrate through `api_call` on external MCP-UI hosts.

### Organization IU Rate Card

When an administrator asks about IU prices or wants to choose a plan, call the
dedicated `open_iu_plan_task` tool. It owns the shared portable Task; do
not imitate acceptance with `api_call`. Its server-owned response contains the
bounded launch receipt for the organization's eligible workloads. The requesting
app receives the exact organization plan, corporate discount, rollover policy,
activity terms, governing agreement, current accepted plan, and history. The
activated social account term is read from the current offer—never quote its IU
amount from memory. Only the requesting MCP App may
invoke the app-only `accept_iu_rate_card_offer` after a direct human
organization administrator confirms. Connecting accounts remains available at
no cost. The activated-social-account rate is displayed for planning, but its
meter remains non-billing until separate charging ratification; never claim it
is currently charged. Existing accepted media pricing is separate; this IU
Rate Card does not amend those terms.

---

## Entity Hierarchy & Prerequisites

Before creating campaigns, you MUST understand the entity hierarchy:

```
Customer (your account)
  └── Advertiser (brand account - REQUIRED first)
        ├── Catalogs (sync product/offering data to partners)
        ├── Campaigns (advertising campaigns)
        │     └── Creatives
        ├── Event Sources (event data pipelines for optimization)
        └── Test Cohorts (for A/B testing)
```

### ⚠️ CRITICAL: Brand Domain Required for All Advertiser Actions

**Before performing ANY action on an advertiser** (creating campaigns, managing accounts, syncing catalogs, etc.), check that the advertiser has a brand domain configured (the `brand` field is not null/missing in the advertiser response).

If the advertiser does NOT have a brand domain:
1. **Do not proceed** with the requested action.
2. Inform the user: "This advertiser does not have a brand domain configured, which is required. Please provide a brand domain (e.g., `nike.com`) so I can update the advertiser."
3. Once the user provides one, update the advertiser via `update_advertiser` operation with `{ "brand": "nike.com" }`.
4. Only then continue with the original request.

### Setup Checklist

**Before you can run a campaign, you need:**

1. **Advertiser with brand domain** (REQUIRED)
   - First, check if one exists: `list_advertisers` operation
   - If not, create one: `create_advertiser` operation (requires `brand`)
   - An advertiser represents a brand/company you're advertising for
   - The brand is resolved automatically from the domain during creation
   - If the brand is not yet registered but enrichment succeeds, the API can create the advertiser with a `brandWarning`; show that warning/details and only use `saveBrand: true` when the user wants to persist the enriched brand to the registry
   - Set `sandbox: true` to create a sandbox advertiser — all ADCP operations will use sandbox-flagged accounts with no real spend. See **Sandbox Mode** below. Sandbox mode cannot be changed after creation.

   **Sandbox Mode**

   Sandbox mode lets you test the full media buying lifecycle — discovery, campaign creation, creatives, and delivery — without real platform calls or spending real money.

   - Sandbox is **account-level, not per-request**. The seller provisions a dedicated sandbox account, and every request using that `account_id` is automatically treated as sandbox. This eliminates the risk of accidentally mixing real and test traffic in a multi-step flow.
   - All discovered accounts for a sandbox advertiser are sandbox accounts — `list_accounts` is called with `sandbox: true`.
   - The correct sandbox `account_id` is automatically injected into `create_media_buy`, `get_media_buy_delivery`, and `get_products` — delivery and reporting data are fully scoped to the sandbox environment.
   - Responses contain simulated but realistic data.
   - Reference: https://docs.adcontextprotocol.org/docs/media-buy/advanced-topics/sandbox#sandbox-mode

2. **Event Sources** (REQUIRED for performance optimization)
   - Register event data pipelines: `POST /api/v2/buyer/advertisers/{advertiserId}/event-sources/sync` (ADCP-aligned snake_case body — see the Event Sources section below)
   - Referenced by `event_source_id` in optimization goals

3. **Creative Manifests** (REQUIRED)
   - Every campaign needs creative assets
   - **Small multipart uploads remain available in the UI** — call `GET /api/v2/buyer/creative-dashboard-url?advertiserId={advertiserId}&campaignId={campaignId}` (add `&manifestId={manifestId}` to deep-link to a specific creative) to get the full URL. This path has a published 50 MB per-file limit; do not direct a larger MP4 there.
   - **Portable upload is shared with V3** — for a local JPEG, PNG, or enabled large MP4, select only the buyer-owned advertiser, then open this connector's `upload_creative_asset`. The browser sends bytes directly to private storage through the same ingress as V3. After finalization, use V3 `save_creative`: `sourceAssetRef` adopts one image, while `sourceAssets` binds image and promoted-MP4 references to canonical campaign format slots. Never silently substitute the provider-bound V2 adoption write. A successful upload or composition confirms neither seller acceptance nor provider delivery; require exact provider readback.
   - **V2 Legacy handoff for an already-uploaded provider-bound video:** stop and use the source's matching V2 connection explicitly. `adopt_managed_video_source` still accepts its promoted `source_uid`, plus optional `campaign_id`; it cannot adopt a buyer-owned V3 source. Re-calling with the same `source_uid` and `name` is safe. See **Adopt a Managed Video Source** below and [Bring your own creative](/v2/buyer/creatives/bring-your-own-creative#legacy-provider-bound-video-adoption).
   - **Additional production-dark audio capability:** before attempting direct managed-audio adoption, retrieve [`v2/buyer/creatives/tasks/adopt-managed-audio-source`](/v2/buyer/creatives/tasks/adopt-managed-audio-source). It is the source of truth for the capability gate, request contract, and delivery boundary.
   - Otherwise via MCP you can only **list**, **get**, **update metadata**, and **delete** existing manifests — see the **Creative Manifests** section below
   - Delivery to sales agents is automatic at campaign execute and re-syncs on later creative updates. There is no separate manual `sync_creatives` step for the buyer to call. How each seller receives creatives (`sync_creatives` vs inline) is chosen by the platform from the seller's capabilities; see the [creative object guide](/v2/object-guides/creative#creative-transport-modes) for transport details.

---

## Core Concepts

| Concept | Description | Required For |
|---------|-------------|--------------|
| **Advertiser** | Top-level account representing a brand/company | Everything |
| **Catalog** | Product/offering data synced to partner platforms via ADCP | Catalog sync |
| **Campaign** | Advertising campaign with budget, dates, targeting | Running ads |
| **Creative Manifest** | Campaign-scoped container for creative assets (images, video, audio, URLs). This V2 connector and shared V3 expose the same `upload_creative_asset` Task for portable JPEG/PNG and eligible managed MP4 upload. Use V3 `save_creative` to adopt one image through `sourceAssetRef` or bind images and a promoted MP4 to canonical slots through `sourceAssets`. Legacy provider-bound V2 can still adopt a video uploaded through that matching provider scope; it is not a fallback for a V3 buyer-owned MP4. Retrieve the [managed-audio adoption reference](/v2/buyer/creatives/tasks/adopt-managed-audio-source) before considering its additional direct operation. | Ad delivery |
| **Event Source** | Event data pipeline (pixel, SDK, etc.) | Performance optimization |
| **Syndication** | Push audiences/events/catalogs to ADCP agents | Audience distribution |
| **Test Cohort** | A/B test configuration | Experimentation |
| **Media Buy** | Executed purchase record — reduce budgets, cancel, and archive via `update_campaign` | Campaigns, Reporting |

---

## First-Time Setup

If you're starting fresh with a new advertiser, follow these steps.

```
Step 1: Check if an advertiser already exists
Operation: list_advertisers
→ If advertisers exist, you can use one. If not, create one (see Step 1b).

Step 1b: Create an advertiser (if needed)
Operation: create_advertiser
Body: { "name": "Acme Corp", "brand": "acme.com" }
→ If brand is registered: advertiser is created with linked brand.
→ If brand is not registered but enrichment succeeds: advertiser is created with a
  brandWarning. Show the warning/enriched details to the user. Do not retry create.
  Use saveBrand: true only when the user wants the enriched brand persisted to the
  registry.

Step 2: Create an event source (for performance optimization)
POST /api/v2/buyer/advertisers/{advertiserId}/event-sources
{
  "eventSourceId": "website_pixel",
  "name": "Website Pixel"
}

Step 3: Now you can discover products and create campaigns!
```

---

## API Endpoints Reference

### Advertisers

#### List Advertisers

**Internal use only.** `list_advertisers` resolves an `advertiserId`, applies an explicit filter, or serves a headless/AdCP JSON client. It is not the answer to a bare human "list advertisers" / "show my advertisers" ask in chat — that goes to `open_advertisers_page` (see **Advertisers page** above).

**Operation:** `list_advertisers`
```http
GET /api/v2/buyer/advertisers?status=ACTIVE&search=Acme&linkedAccountPartnerId=snap&limit=10&offset=0&includeBrand=true
```
**As operation:**
```json
{ "operation": "list_advertisers", "params": { "status": "ACTIVE", "search": "Acme", "linkedAccountPartnerId": "snap", "limit": "10", "offset": "0", "includeBrand": "true" } }
```

**Query Parameters (Filters):**
- `status` (optional): Filter by status - `ACTIVE` or `ARCHIVED`
- `name` (optional): Filter by name (case-insensitive, partial match). Example: `name=Acme` matches "Acme Corp", "acme inc", etc.
- `search` (optional): Search by advertiser name or full/partial ID (case-insensitive). Search runs before pagination, and an exact ID match appears first.
- `linkedAccountPartnerId` (optional): Server-side filter for advertisers linked to at least one account from the given partner / sales agent ID (for example, `snap`).
- `limit` (optional): Maximum number of advertisers per page (default: 100, max: 100)
- `offset` (optional): Pagination offset (default: 0)
- `includeBrand` (optional): When `true`, include the linked brand summary/manifest details that Murph can use for creative assets before asking the buyer to upload logos or other brand files.

**Response:** each row is the advertiser **summary** shape — `id`, `name`, `status`, `brand`, `sandbox`, `linkedAccountCount`, `createdAt`, `updatedAt`. With `includeBrand=true`, summary rows may also include `linkedBrand` with resolved brand identity details such as logos, colors, tone, tagline, and attachable catalog/brand assets. `description`, `optimizationApplyMode`, linked partner accounts, UTM config, and frequency caps are NOT on the summary — call `get_advertiser` for the full resource.

**Response format:**
```json
{
  "items": [ ... ],
  "total": 42,
  "hasMore": true,
  "nextOffset": 10
}
```
Use `nextOffset` as the `offset` parameter for the next page. When `hasMore` is `false`, `nextOffset` is `null`.

**Display Requirements — ALWAYS include when listing advertisers:**

Present each advertiser as a structured entry (not prose). For every advertiser, show:
- **Name** and **ID**
- **Status** (ACTIVE, ARCHIVED)
- **Brand** — brand name or domain (show "No brand" if missing)
- **Sandbox** — Yes/No
- **Linked Accounts** — show `linkedAccountCount`. If `0`, say "No linked accounts" and offer to discover/link. For sandbox advertisers, do not mention linking accounts — sandbox accounts are provisioned automatically when the user has credentials for a sales agent. To enumerate the accounts (storefront/source the account is reachable through, account ID, status), call `get_advertiser`.

Never summarize into a sentence like "You have 13 advertisers." Always show the per-item details above for every advertiser in the response.

**Note:** `get_advertiser` returns the full advertiser — including resolved brand details, linked partner accounts, UTM config, and frequency caps.

#### Get Advertiser

**Operation:** `get_advertiser`
```http
GET /api/v2/buyer/advertisers/{id}
```
**As operation:**
```json
{ "operation": "get_advertiser", "pathParams": { "advertiserId": "<id>" } }
```

Returns the advertiser with full brand details, linked partner accounts, UTM config, and frequency caps.

**Response:**
```json
{
  "id": "34",
  "name": "Acme Corp",
  "description": null,
  "status": "ACTIVE",
  "createdAt": "2026-02-15T10:00:00Z",
  "updatedAt": "2026-02-15T10:00:00Z",
  "brand": "acme.com",
  "brandWarning": null,
  "linkedBrand": {
    "id": "brand_123",
    "name": "Acme Corp",
    "domain": "acme.com",
    "manifest": {
      "name": "Acme Corp",
      "logos": [{ "url": "https://acme.com/logo.png", "tags": ["primary"] }],
      "colors": { "primary": "#FF5733" },
      "industry": "Technology",
      "tagline": "Innovation for Everyone",
      "tone": "professional"
    },
    "logoUrl": "https://acme.com/logo.png",
    "industry": "Technology",
    "colors": { "primary": "#FF5733" },
    "tagline": "Innovation for Everyone",
    "tone": "professional"
  }
}
```

**linkedBrand fields:**
- `id`: Brand agent ID (prefixed with `brand_`)
- `name`: Resolved brand name
- `domain`: Brand domain
- `manifest`: Resolved brand identity data — includes logos, colors, fonts, tone, tagline, assets, product catalog, disclaimers, and more.
- `logoUrl`: Primary logo URL (convenience field extracted from manifest)
- `industry`: Brand industry (convenience field)
- `colors`: Brand colors (convenience field)
- `tagline`: Brand tagline (convenience field)
- `tone`: Brand tone (convenience field)

#### Create Advertiser

**⚠️ IMPORTANT: `brand` is required when creating an advertiser.**

When a user asks to create an advertiser:

1. **Research first when a domain is present** — if the user provides a brand domain or website URL, use available context and the API's brand enrichment before asking follow-up questions. Do not ask for the name or brand again when the user already provided them.
2. **Ask only for genuinely missing required choices** — `name` and `brand` are required. `sandbox` is optional but immutable, so ask real spend vs sandbox only if the user did not already say it.
3. **Confirm the advertiser's currency** — `primaryCurrency` is **required** (no default); it is the currency of every campaign under this advertiser, which is single-currency. Ask for the right ISO 4217 currency during setup; never assume USD. It can be corrected before the first campaign or seller-account binding, then it is locked.
4. **Do not ask about `optimizationApplyMode` during advertiser setup** — sub-campaign optimization is handled later in the campaign/media-buy flow. Leave it unset unless the user explicitly asks to configure advertiser/campaign approval mode or volunteers a clear `"AUTO"`/`"MANUAL"` preference.
5. **Create the advertiser** with the researched brand details and confirmed required fields.

The system resolves the brand via Addie (AdCP registry + Brandfetch enrichment). There are three possible outcomes:

**Outcome 1: Brand exists in the registry** — Advertiser is created successfully with the linked brand.

**Outcome 2: Brand found via enrichment only (not yet registered)** — The advertiser is created successfully, but the response includes `brandWarning` explaining that the brand was resolved by enrichment rather than an official registry entry. The response can include enriched brand data such as name, domain, manifest with logos, colors, industry, tagline, tone, etc.

**When this occurs, you MUST do BOTH of the following:**

1. **ALWAYS show the warning/enriched brand details.** Present `brandWarning` and any enriched brand fields returned — show the brand name, domain, industry, colors, logo URL, tagline, tone, and any other fields present. This lets the user review and confirm the right brand was found.
2. **Do not retry create.** The advertiser already exists. If the user wants the enriched brand persisted to the AdCP registry, tell them you can save it by updating the advertiser with `saveBrand: true` after they review the enrichment.

**Optional save to registry after review:**

**Operation:** `update_advertiser`
```http
PUT /api/v2/buyer/advertisers/{advertiserId}
{
  "brand": "acme.com",
  "saveBrand": true
}
```
**As operation:**
```json
{ "operation": "update_advertiser", "pathParams": { "advertiserId": "adv_123" }, "body": { "brand": "acme.com", "saveBrand": true } }
```
This saves the enriched brand to the AdCP registry and keeps the existing advertiser linked to the brand.

**Outcome 3: No registry/enrichment brand data found** — If the user confirms the advertiser name and brand domain are correct, retry with `saveBrand: true` instead of sending them away. This registers the brand identity from the confirmed domain/name so advertiser creation can continue. Do not invent logos, colors, industries, or other brand facts.

**Retry with confirmed brand identity:**

**Operation:** `create_advertiser`
```http
POST /api/v2/buyer/advertisers
{
  "name": "Acme Corp",
  "brand": "acme.com",
  "primaryCurrency": "USD",
  "saveBrand": true
}
```
**As operation:**
```json
{ "operation": "create_advertiser", "body": { "name": "Acme Corp", "brand": "acme.com", "primaryCurrency": "USD", "saveBrand": true } }
```
Only direct the user to external registration if there is no confirmed name/domain to save or the registry save fails.

**Initial request (without `saveBrand`):**

**Operation:** `create_advertiser`
```http
POST /api/v2/buyer/advertisers
{
  "name": "Acme Corp",
  "brand": "acme.com",
  "primaryCurrency": "USD",
  "description": "Global advertising account"
}
```
**As operation:**
```json
{ "operation": "create_advertiser", "body": { "name": "Acme Corp", "brand": "acme.com", "primaryCurrency": "USD", "description": "Global advertising account" } }
```

**Request fields:**
- `name` (required): Advertiser name
- `brand` (required): Brand domain (e.g., `"nike.com"`)
- `description` (optional): Description
- `primaryCurrency` (**required**, string, ISO 4217, no default): The advertiser's currency. An advertiser is single-currency: every campaign under it is created in this currency, and a campaign `budget.currency` must match it (the server rejects a mismatch). Contract rate cards do NOT affect campaign currency. Ask for this during setup; never assume USD. It can be corrected before the first campaign or seller-account binding, then it is locked.
- `channels` (optional, array): Editable planning preferences using canonical AdCP media-channel values. These do not qualify storefront inventory or constrain discovery by themselves.
- `saveBrand` (optional, boolean, default `false`): When `true`, saves the resolved or confirmed brand identity to the AdCP registry if the brand is not yet registered. Usually not needed for advertiser creation when enrichment succeeds; set this only after reviewing enrichment or when the user confirms registry persistence is desired.
- `linkedAccounts` (optional, array): Accounts to link at creation time. Each item: `{ storefrontId, sourceId, accountId, billingType? }`. Get `storefrontId` from `list_storefronts` and `sourceId` from `get_storefront_capabilities`. Use `list_available_accounts` operation to discover valid accountIds — never ask the user to provide one manually.
- `optimizationApplyMode` (optional, string): `"AUTO"` or `"MANUAL"` (default `"MANUAL"`). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval for campaigns under this advertiser. Do not ask for this during advertiser setup; only set it when the user explicitly asks to configure approval mode or volunteers a clear preference.
- `utmConfig` (optional, array, max 20): Default UTM parameters for this advertiser. These are appended to landing page URLs during clickthrough redirection. Each item: `{ paramKey, paramValue }`. `paramKey` is the query parameter name (e.g. `"utm_source"`, `"bg_campaign"`). `paramValue` is a macro (e.g. `"{CAMPAIGN_ID}"`) resolved dynamically at click time, or a static string (e.g. `"scope3"`). Available macros follow the ADCP universal macros spec: https://docs.adcontextprotocol.org/docs/creative/universal-macros#universal-macros. If omitted, defaults are applied: `utm_source=scope3`, `utm_medium=agentic`, `utm_campaign={CAMPAIGN_ID}`, `utm_content={CREATIVE_ID}`, `utm_media_buy={MEDIA_BUY_ID}`, `utm_package={PACKAGE_ID}`. Campaign-level UTM config can override these per param key.
- `frequencyCaps` (optional, array): Buyer-side frequency caps that apply across all campaigns/creatives for this advertiser. Each item: `{ maxExposure, window: { interval, unit } }`. `maxExposure` is a positive integer (max exposures per user). `window.interval` is a positive integer; `window.unit` is `"minutes"`, `"hours"`, `"days"`, `"weeks"`, or `"months"`. Multiple caps are combined as AND (all must hold). Example: `[{ "maxExposure": 3, "window": { "interval": 1, "unit": "days" } }, { "maxExposure": 10, "window": { "interval": 1, "unit": "weeks" } }]` = max 3/day AND max 10/week. See the Frequency Caps section below for replace semantics.

**Response** includes `brand`, `linkedBrand`, `primaryCurrency`, `channels`, `optimizationApplyMode`, optional `utmConfig` (advertiser-level UTM params, only present when configured), `frequencyCaps`, and optional `brandWarning` (e.g., if data came from Brandfetch enrichment rather than a well-known manifest).

#### Update Advertiser

**Operation:** `update_advertiser`
```http
PUT /api/v2/buyer/advertisers/{id}
{
  "name": "Acme Corporation",
  "description": "Updated description",
  "brand": "newbrand.com"
}
```
**As operation:**
```json
{ "operation": "update_advertiser", "pathParams": { "advertiserId": "<id>" }, "body": { "name": "Acme Corporation", "description": "Updated description", "brand": "newbrand.com" } }
```

**Optional fields:** `name`, `description`, `brand`, `brandCountries`, `primaryCurrency`, `preferredTimezone`, `channels` (full replacement of editable AdCP planning preferences; pass `[]` to clear), `linkedAccounts` (array of `{ storefrontId, sourceId, accountId, billingType? }` to add — does not remove existing links), `optimizationApplyMode` (`"AUTO"` or `"MANUAL"` — controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval for campaigns under this advertiser), `utmConfig` (array of `{ paramKey, paramValue }`, max 20 — replaces all existing advertiser-level UTM params; pass `[]` to clear), `frequencyCaps` (array of `{ maxExposure, window: { interval, unit } }` — **full replace semantics**: omit the field to leave caps unchanged; pass `[]` to clear all caps; pass the full array to replace the set). Through the direct v2 API, brand domain, country scope, currency, and reporting time zone may be re-keyed only before the first campaign or seller-account binding. `sandbox` is always fixed. Discover valid accountIds via `list_available_accounts` operation — never ask the user to provide an account ID.

If the same `brand` is provided with `saveBrand: true`, the system refreshes and persists that brand's registry resolution without changing advertiser identity. A different brand is accepted only before first use; after first use, create another advertiser instead.

#### Link Agent Account to Advertiser

Use this workflow to discover and link an inventory-source agent's account to a
specific advertiser.

**Official adapter storefronts are not this workflow.** Built-in adapters such
as Amazon, Google, Meta, Pinterest, Reddit, Snap, Spotify, TikTok, OpenAI,
Gemini, fal.ai, ElevenLabs, Veo, and AudioStack are storefront dispatchers. They
are not source rows under another storefront, and the buyer agent should
not search public storefronts for a "Snap source", "OpenAI source", or "Google
source" to connect. Use `list_storefronts` with `limit: 100` to discover adapter
storefronts. Rows expose `adapterSourceKind`; creative generators have
`adapterSourceKind: "creative"` plus `creativeCapabilities` describing
modalities (`image`, `audio`, `video`), transformer ids, representative format
ids when known, and whether exact formats require `list_creative_formats`.
For fal.ai, treat fal.ai as the connected provider/gateway and the transformer
ID (for example `fal_flux`) as the specific model family/rendering path.
For final production, ask or infer where the creative will run and which
placements/formats matter. For early concept exploration, a
`start_creative_session` call can omit `target_format_id` and let the
server-side creative planner choose a draft fallback target format and
synthesize required text assets from the plain-language brief. Do not make the
buyer provide `target_format_id`, `transformer_id`, or adapter-specific manifest
JSON unless they explicitly request a specific technical format.
For AudioStack creative sessions, preserve requested duration and language from
the buyer's plain-language prompt. For example, "15-second ad in Dutch" should
select or pass the 15-second AudioStack format and carry Dutch as the spoken
language; do not treat those details as optional copy suggestions.
Use ElevenLabs for voiceover/text-to-speech drafts. If the buyer asks for a
finished audio ad with background music, mastering, or sound design, prefer
AudioStack or clearly explain that ElevenLabs will only produce the spoken
voiceover in the current creative workflow.
Rows also expose `adapterConnection`, so you can say which providers are
already connected and which need auth. If the user asks to connect to an
official adapter in the chat surface, open `open_connections_page` (see
*Connections page* above) rather than narrating steps; the widget owns the
Connect handoff. Only for a headless/AdCP client with no chat surface, call
`connect_storefront` with the adapter storefront ID and give the returned
`connectionUrl` to the human. For OAuth providers that link
goes straight to the provider's consent screen; bearer-only providers land on
a first-party token-entry page. Do not ask the user to paste provider secrets
into chat. Use `list_storefront_connections` to check status
or `select_storefront_connection_account` if the provider returns multiple
accounts. `register_source_credentials`, `list_available_accounts`, and
advertiser `linkedAccounts` are for legacy or third-party external AdCP
inventory sources that appear in `get_storefront_capabilities`.

**Two-step process overview:**
1. **Register agent credentials** (customer-level) — done once per source via `register_source_credentials` operation
2. **Link account to advertiser** (advertiser-level) — discover available accounts for a specific agent and link one to an advertiser

**Prerequisites:** Agent credentials must already be registered for the relevant
inventory source via `register_source_credentials` operation (see Register
Agent Credentials in the Storefronts section). **Only external AdCP sources
where `get_storefront_capabilities` returns `requiresCredentials: true` support
account linking.** To find eligible sources, use `list_storefronts` for the
storefront ID, call `get_storefront_capabilities` for active source rows, and
inspect `requiresCredentials`. Do not use this scan to find official adapters
like Snap or Google; those are adapter storefronts, not nested sources.

**⚠️ CRITICAL: Multiple Credentials for the Same Agent**

A customer may register **multiple sets of credentials** for the same sales agent (e.g., two different Snap ad accounts with different API keys). This is fully supported. When this happens:
- Each set of credentials discovers its own set of ad accounts via `list_accounts`
- The `list_available_accounts` operation **requires a `credentialId` parameter** so the system knows which credential to use for discovery
- If the customer has multiple credentials and `credentialId` is omitted, the API returns a **validation error listing the available credential IDs** — present these to the user and ask them to pick
- Once the user picks a credential, re-call the discovery endpoint with `credentialId` to get that credential's accounts
- When the user links an account, all future operations for that advertiser+agent pair automatically use the credential associated with that account

**Workflow when multiple credentials exist:**
1. Use `list_agent_credentials` operation to list the customer's registered credentials
2. Present the credentials to the user (show `id` and `accountIdentifier` for each)
3. Ask the user which credential to use
4. Pass the chosen `credentialId` to the discovery endpoint

**Step 1 — Create advertiser with brand**

**Operation:** `create_advertiser`
```http
POST /api/v2/buyer/advertisers
{ "name": "Acme Corp", "brand": "acme.com", "primaryCurrency": "USD" }
```
**As operation:**
```json
{ "operation": "create_advertiser", "body": { "name": "Acme Corp", "brand": "acme.com", "primaryCurrency": "USD" } }
```

**Step 2 — Discover available accounts for the agent**

**⚠️ CRITICAL: Account IDs MUST come from the discovery endpoint — NEVER from user input.**
- You MUST call the discovery endpoint below and use ONLY the `accountId` values returned in the response.
- If the user provides an account ID or account name verbally (e.g., "the account ID is 06cd7033..."), do NOT use that value. Instead, call the discovery endpoint and match against the returned results.
- If no accounts are returned from discovery, tell the user no matching accounts were found. Do NOT pretend to link an account that was not returned by the API.
- **NEVER fabricate, guess, or use a user-provided account ID directly.** The only valid account IDs are those returned by this endpoint.

**Operation:** `list_available_accounts`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/accounts/available?storefrontId={storefrontId}&sourceId={sourceId}
GET /api/v2/buyer/advertisers/{advertiserId}/accounts/available?storefrontId={storefrontId}&sourceId={sourceId}&credentialId={credentialId}
```
**As operation:**
```json
{ "operation": "list_available_accounts", "pathParams": { "advertiserId": "<id>" }, "params": { "storefrontId": "1", "sourceId": "<sourceId>" } }
```

- **Identify the source via `storefrontId` + `sourceId`** (both required). Get `storefrontId` from `list_storefronts` and `sourceId` from `get_storefront_capabilities`.
- `credentialId` is **required when the customer has multiple credentials** registered for this agent. If omitted with multiple credentials, the API returns a validation error listing available credential IDs — present them to the user and ask which to use, then retry with `credentialId`.
- `credentialId` is optional when the customer has only one credential for the agent.
- Account discovery refreshes the source by default. Pass `refresh=false` only when the caller explicitly needs the currently cached account projection without remote discovery, mirroring, or `sync_accounts`; cached results may be stale or empty.
- Returns accounts filtered by the advertiser's brand domain for the specified agent and credential.
- Response includes `accounts` array with `accountId`, `name`, `house`, `advertiser`, `sources[]` (the `(storefrontId, sourceId, sourceName)` pairs that surface this account), and `billingOptions.supported`.
- **Show ALL discovered accounts to the user and let them pick.** Present each account's `name` (and `advertiser` if different from the brand).
- If `accounts` is empty, tell the user no matching accounts were found for that agent. Do NOT proceed with linking.
- **Do NOT ask about billing type.** Use `billingOptions.default` from the response if available. Only include `billingType` in the link request if the response shows multiple `billingOptions.supported` values AND no default — in that case, present the options and ask the user to choose.

**Step 3 — Link the selected account to the advertiser**

**Operation:** `update_advertiser`
```http
PUT /api/v2/buyer/advertisers/{advertiserId}
{
  "linkedAccounts": [
    { "storefrontId": 1, "sourceId": "src_main", "accountId": "acc_123" }
  ]
}
```
**As operation:**
```json
{ "operation": "update_advertiser", "pathParams": { "advertiserId": "<id>" }, "body": { "linkedAccounts": [{ "storefrontId": 1, "sourceId": "src_main", "accountId": "acc_123" }] } }
```
- Identify the source via `storefrontId` + `sourceId` on each entry (both required).
- The `accountId` here MUST be one returned from Step 2. Never use a value from any other source.
- `linkedAccounts` adds accounts — it does not remove existing links. Include `billingType` if the agent requires a specific billing arrangement.

---

### Campaigns

Campaigns use a single endpoint for creation and update. Configuration is done through action endpoints.

#### List Campaigns

**Operation:** `list_campaigns`
```http
GET /api/v2/buyer/campaigns?advertiserId=12345&status=ACTIVE
```
**As operation:**
```json
{ "operation": "list_campaigns", "params": { "advertiserId": "12345", "status": "ACTIVE" } }
```

**Query Parameters:**
- `advertiserId` (optional): Filter by advertiser
- `status` (optional): `DRAFT`, `ACTIVE`, `PAUSED`, `COMPLETED`, `ARCHIVED`

**Response:** each row is the campaign **summary** shape — `campaignId`, `advertiserId`, `name`, `status`, `flightDates`, `budget` (compact: `total` + `currency` only), `productCount`, `createdAt`, `updatedAt`. Brief, media buys, audiences, creative coverage, fees, pacing, performance config, frequency caps, and the full `budget` (with `dailyCap` / `pacing`) are NOT on the summary — call `get_campaign` for the full resource.

#### Get Campaign

**Operation:** `get_campaign`
```http
GET /api/v2/buyer/campaigns/{campaignId}
```
**As operation:**
```json
{ "operation": "get_campaign", "pathParams": { "campaignId": "<id>" } }
```

`get_campaign` returns the full campaign — including `brief`, `mediaBuys`, `audiences` (currently active audiences with `audienceId`, `name`, `status`, `type` (`"TARGET"` or `"SUPPRESS"`), `enabledAt`), `creativeFormats`, `frequencyCaps`, `pacingPeriods`, `performanceConfig`, `constraints`, `allocatedBudget`, `unallocatedBudget`, `products`, `storefronts` (hydrated storefront pin, see below), and the original `budget` object. There is no `fees` array and no `mediaBudget` object in the response — see below.

Each `mediaBuys[].products[]` entry includes an optional `formatOptions` field with the publisher's declared format requirements. Filter to `format_kind: "video_vast"` or `"video_hosted"` entries for video constraints; each `params` object may carry `duration_ms_exact`, `duration_ms_range`, `width`, `height`, and `sizes`. When present, use it to tell the buyer what rendition their creative must include: "This product requires a 30s VAST with a 640x480 rendition." We cannot inspect VAST renditions - the buyer must verify their creative meets the spec.

**Storefront pin (`storefronts`):** When the campaign was created or updated with `storefrontIds`, the response surfaces a `storefronts` array — each entry is `{ id, platformId, name }`, hydrated against `storefront_config` so the response is renderable without a follow-up lookup. Absent or empty means the campaign is not pinned to any specific storefronts. The input field on `create_campaign` / `update_campaign` is `storefrontIds` (just the IDs); the **response** uses the richer `storefronts` shape.

**Budget fields (returned by `get_campaign` whenever `budget.total` is set):**
- There is no `campaignType` or storefront routing classification. The deprecated `supportedRoutingTypes` v2 field is a behavior-free compatibility placeholder that returns both legacy values for every storefront; ignore it. Use explicit campaign mode, seller capabilities, authentication state, and BillingParty for their respective decisions.
- The campaign has no `feeType` field and no `fees` array — every campaign budget is GROSS, and the media/fee split is itemized per media buy in its `budget_breakdown` (below).
- `allocatedBudget` — Sum of the budgets of every media buy that still holds budget (`DRAFT`, `PENDING_APPROVAL`, `INPUT_REQUIRED`, `ACTIVE`, `PAUSED`) plus performance spend on media buys in a terminal status (`COMPLETED`, `CANCELED`, `FAILED`, `REJECTED`, `ARCHIVED`) or that have otherwise been archived, expressed in `budget.currency`. All gross — the same denomination as `budget.total`. **`DRAFT` buys are included** — a buy in `DRAFT` status holds budget exactly as a live buy does.
- `unallocatedBudget` — Budget remaining for allocation — a new media buy, or an increase to an existing one — computed server-side as `budget.total` minus `allocatedBudget` (both gross). Use it directly — **do not sum media buy budgets yourself**. May be negative if terminal-buy performance spend exceeds `budget.total`.
- **Checking headroom for a budget increase (buy still holds budget):** a buy in `DRAFT`, `PENDING_APPROVAL`, `INPUT_REQUIRED`, `ACTIVE`, or `PAUSED` is *already* counted in `allocatedBudget` at its current budget, so the headroom is `unallocatedBudget` and the largest new budget that buy can take is `currentBuyBudget + unallocatedBudget`. Never add the buy's existing budget to `allocatedBudget` — that double-counts it and makes a campaign with real room look over-allocated.
- **Reviving a terminal buy** (a `COMPLETED` buy — updating it stages a new `PENDING_APPROVAL` version): only the buy's *delivered spend* sits in `allocatedBudget`, not its previous budget. The ceiling for the revived buy's new budget is `unallocatedBudget + thatBuysDeliveredSpend`. Using `currentBuyBudget + unallocatedBudget` here overstates the ceiling by `currentBuyBudget − deliveredSpend` and the server will reject the update.
- `mediaBuys[].budget_denomination` — `"gross"`: the buy's budgets (per-product and package) are fee-inclusive amounts in the buyer currency. Present only on buys with fee terms locked at creation — legacy buys carry neither this field nor `budget_breakdown`.
- `mediaBuys[].budget_breakdown` — read-only `{ media_budget, fee_amount, fee_rate_percent, effective_gross_cpm }`, derived at the fee terms locked when the buy was created. `media_budget` is the portion that buys media; `fee_amount` is the Scope3 fee inside the gross budget; `fee_rate_percent` is the locked fee rate; `effective_gross_cpm` is the buy's gross budget ÷ impression goal × 1000 — the all-in price per thousand impressions, so "budget ÷ CPM = impressions" holds on gross numbers (for a buy priced at a single fixed seller CPM, it works out to `seller CPM ÷ (1 − fee rate)`) — `null` when the buy has no positive impression goal or no gross budget. Omitted (along with `budget_denomination`) on legacy buys without locked fee terms. Never an input — budgets are set and updated in gross only, and updates re-split at the locked rate. When presenting the fee to a user, say "fee rate" or "fee terms" — never "margin".

The list summary exposes only the compact `budget: { total, currency }` (a 2-field nested pick of the full budget) — call `get_campaign` for `allocatedBudget`, `unallocatedBudget`, and the full budget object (with `dailyCap` / `pacing`).

**DRAFT campaigns only:** The response includes `discoveryId`, `products`, and `productCount` fields representing the product selection from the discovery workflow. These fields are **only present while the campaign is in DRAFT status**. After execution, product data is represented through `mediaBuys` — do not look for `discoveryId` or `products` on executed campaigns.

**Pending changes, live vs what the buyer asked for:** Every field on every media-buy read surface describes the buy **as it is currently delivering**. When the buyer has submitted an update that has not yet been accepted upstream, it appears as a separate `pendingChange` object; it never overwrites the live values.

- `pendingChange` **absent** → nothing queued, everything you see is live.
- `pendingChange` **present** → the buy is still delivering the OLD values. `pendingChange.differences` lists only the fields that would change, each as `{ live, proposed }`.

A buy reporting `status: "ACTIVE"` alongside a `pendingChange` is **delivering the old values**. NEVER tell the user an update is live because the update call succeeded or because the buy reads ACTIVE. Say the change is awaiting approval and quote `pendingChange.differences` so they can see what is queued. Treat the change as applied only once `pendingChange` is gone. `get_media_buy` carries the full `differences`; `mediaBuyRefs[].pendingChange` carries status and layer only.

A buy awaiting its **first** approval is different: `status: "PENDING_APPROVAL"` with no `pendingChange`, because nothing is live behind it. That is a buy which has not started, not a pending change.

**Surviving response truncation (`mediaBuyRefs` + `mediaBuyId` filter):** Campaigns with many media buys produce large `get_campaign` responses where the nested `mediaBuys[]` tail can be truncated by your context window. To handle this:

- `Campaign.mediaBuyRefs` is a small array placed near the **top** of the `get_campaign` response (`[{ mediaBuyId, status, pendingChange? }, ...]`) covering every current or terminal media buy on the campaign. `status` is the current status for a substantive buy, including `COMPLETED` or `CANCELED` buys that remain in `mediaBuys[]`. Only `REJECTED`, `FAILED`, and `ARCHIVED` are terminal-reference-only statuses whose substantive details are omitted; an archived buy therefore remains discoverable here as `ARCHIVED` even though it is absent from `mediaBuys[]`. Use refs to enumerate IDs reliably even when the heavier nested array later in the response is truncated. **If the user asks for a specific media buy that you cannot find in `mediaBuys[]`, check `mediaBuyRefs` before claiming the buy doesn't exist.**
- `get_campaign` accepts a `mediaBuyId` query param (single value or repeated) that filters the embedded `mediaBuys[]` array to just the requested buys. The campaign object and `mediaBuyRefs` are unchanged: only the heavy nested array is narrowed. Use this to drill into specific buys without loading the full tree.
- When the question is specifically "what products or package IDs are on this campaign / this media buy?", prefer `get_campaign_products` over `get_campaign` (or `get_media_buy_packages` when the question is scoped to one media buy and the buyer cares which period a package covers). It's a much smaller response (no budget tree or creative coverage) and lists products with their human-readable `productName` / `publisherName` plus the complete `packageIds` on each media-buy reference, even when no discovery session is attached. Pass `mediaBuyId` to narrow it to a single buy.

```json
{ "operation": "get_campaign", "pathParams": { "campaignId": "<id>" }, "params": { "mediaBuyId": "mb_ETBn4gJ9Wu" } }
{ "operation": "get_campaign_products", "pathParams": { "campaignId": "<id>" }, "params": { "mediaBuyId": "mb_ETBn4gJ9Wu" } }
```

**Pacing periods (`pacingPeriods`):** When a campaign has `pacingPeriods` on the GET response, it defines one or more time-based segments — each with its own `label`, `start`, and `end` date — that together cover the campaign flight. Each period is typically executed as its own media buy (or set of packages). Media buy `start_time` and `end_time` MAY correspond to a specific pacing period's `start` / `end`, but pacing periods do not strictly govern media buy dates: a media buy can have any dates within the campaign flight, with or without pacing periods. A media buy's `start_time` can never be earlier than the campaign's `flightDates.startDate`, and `end_time` must fall within the campaign flight. When the user refers to a specific period or package name (e.g. "Package 1" or "the first flight"), match it to the `label` in `pacingPeriods` and use that period's `start` / `end` as the media buy dates.

#### Get Campaign Products

**Operation:** `get_campaign_products`
```http
GET /api/v2/buyer/campaigns/{campaignId}/products
GET /api/v2/buyer/campaigns/{campaignId}/products?mediaBuyId=mb_X
GET /api/v2/buyer/campaigns/{campaignId}/products?mediaBuyId=mb_X&mediaBuyId=mb_Y
```
**As operation:**
```json
{ "operation": "get_campaign_products", "pathParams": { "campaignId": "<id>" } }
{ "operation": "get_campaign_products", "pathParams": { "campaignId": "<id>" }, "params": { "mediaBuyId": "mb_ETBn4gJ9Wu" } }
```

Returns every ad product attached to the campaign with its human-readable `productName`, `publisherName`, `salesAgentName`, and the media buys and package IDs it lives on. The source of truth is `media_buy_products` plus the live buy's `packages` rows, with discovery context layered on when a discovery session is attached. Products staged via discovery but not yet executed onto a media buy are folded in as `pending` entries (skipped when `mediaBuyId` narrows the query to specific buys).

**Use this instead of paging through `get_campaign`'s nested `mediaBuys[]` tree** when a campaign has many products and the full payload exceeds your context window. With `mediaBuyId`, you can pull the products for a single buy without loading the rest of the campaign.

**Response shape:**
- `products[]`: one entry per product with `productId`, `productName`, `salesAgentId`, `salesAgentName`, `publisherDomain`, `publisherName`, `bidPrice`, `budget`, `pricingOptionId`, `pricingModel`, `selectedAt`, `searchContext` (`{ id, brief }` of the discovery run that surfaced it, when available), and `mediaBuys[]` (each `{ mediaBuyId, name, status, packageIds }`; empty array means the product is staged but not yet executed). `packageIds` is the complete lean package-ID list for that product and buy; use it to construct `update_campaign` package updates. Multi-strategy campaigns may have the same product on multiple media buys, listed together here.
- `searchContexts[]`: one entry per distinct discovery run on this campaign with `id`, `brief`, `channels`, `countries`, `createdAt`, `productCount`. Empty when the campaign has no discovery session attached.
- `summary`: `{ totalProducts, productsOnMediaBuys, productsPending }`

#### Get or Cancel an Update Proposal

After `update_campaign` returns `PENDING_SELLER_APPROVAL`, retain the returned proposal ID. Poll it with `get_update_proposal`. If the buyer asks to stop a mistaken pending update locally, confirm that exact proposal ID and call `cancel_update_proposal`; never cancel the whole media buy to clear one update proposal.

```json
{ "operation": "get_update_proposal", "pathParams": { "proposalId": "uprop_abc123" } }
{ "operation": "cancel_update_proposal", "pathParams": { "proposalId": "uprop_abc123" } }
```

Only a `PENDING` proposal can be cancelled. Cancellation is always human-confirmed against the exact proposal ID. A successful cancellation returns an empty 204 response, retains the proposal as terminal `REJECTED` with `rejectionReason: "cancelled_by_buyer"`, prevents a later proposal callback from being applied locally, and allows a corrected update to proceed. It does **not** recall a request already dispatched to the seller; if upstream withdrawal is required, escalate to the seller/operator with the proposal and media-buy IDs. Never create a real update as a validation probe: `update_campaign` rejects any package ID that is not attached to the named live media buy, and the validation error directs you to `get_campaign_products` for the complete IDs.

#### Create Campaign

**⚠️ BEFORE creating a campaign: verify the advertiser has a brand domain.**
Use `get_advertiser` operation and check the `brand` field is not null/missing.
If it is missing, do NOT proceed — tell the user: "This advertiser doesn't have a brand domain configured. Please provide one (e.g. `nike.com`) so I can update it first." Then use `update_advertiser` operation with `{ "brand": "..." }` before continuing.

**⚠️ Every campaign is created in the advertiser's currency; an advertiser is single-currency.** Pass the advertiser's currency in `body.budget.currency` or omit it — never a different currency, the server rejects mismatches (400, field `budget.currency`). Always confirm the currency with the user before calling `create_campaign`. Never assume USD — always use the currency the user specified.

**Operation:** `create_campaign`
```http
POST /api/v2/buyer/campaigns
{
  "advertiserId": "12345",
  "name": "Q1 Campaign",
  "flightDates": {
    "startDate": "2025-02-01T00:00:00Z",
    "endDate": "2025-03-31T23:59:59Z"
  },
  "budget": {
    "total": 50000,
    "currency": "EUR"
  },
  "brief": "<<< ALWAYS include the ENTIRE brief from the client — never summarize or truncate >>>",
  "constraints": {
    "channels": ["ctv", "display"],
    "countries": ["US"]
  },
  "discoveryId": "optional-existing-discovery-id",
  "productIds": ["prod_123", "prod_456"],
  "audienceConfig": {
    "targetAudienceIds": ["aud_001", "aud_002"],
    "suppressAudienceIds": ["aud_003"]
  },
  "performanceConfig": {
    "optimizationGoals": [{
      "kind": "event",
      "eventSources": [
        { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }
      ],
      "target": { "kind": "per_ad_spend", "value": 4.0 },
      "priority": 1
    }]
  }
}
```

**Minimum required fields:**
- `advertiserId`: Advertiser ID
- `name`: Campaign name (1-255 chars)
- `flightDates`: Start and end dates
- `budget`: Total and currency

**Creation choices and optional fields:**
- There is no storefront routing type and no `campaignType` input. `DECISIONED` / `ROUTED` are deprecated billing observations, never buyer choices or execution classifications. Treat every storefront through the canonical media-buy lifecycle; derive differences only from explicit capabilities, authentication requirements, and BillingParty.
- Budget model: every campaign is GROSS. `budget.total` is the all-in amount the customer pays and the Scope3 fee is carved out of it server-side — there is no `mediaBudget.total` or `fees` field in the response to read the carve-out from directly; use `unallocatedBudget` (see **Get Campaign**) to know what's left for new media buys. There is no `feeType` field on the campaign — there is no fee-model choice to send or ask the buyer about.
- `brief`: Campaign brief. **MUST be the ENTIRE brief from the client — never summarize or truncate.**
- `constraints.channels`: Target channels (display, olv, ctv, social)
- `constraints.geo_countries` / `geo_countries_exclude`: ISO 3166-1 alpha-2 country codes (`countries` is accepted as a deprecated alias and normalized to `geo_countries`)
- `constraints.geo_regions` / `geo_regions_exclude`: ISO 3166-2 subdivisions (e.g. `"US-CA"`)
- `constraints.geo_metros` / `geo_metros_exclude`: metro targeting, `{ system, values }` objects (e.g. Nielsen DMAs)
- `constraints.geo_postal_areas` / `geo_postal_areas_exclude`: postal-area targeting, preferably `{ country, system, values }` objects. Each registered country requires its own `system` value (e.g. `{ country: "AU", system: "postcode", values: ["2000"] }` — NOT `system: "postal_code"`, which only works for `ZA` or an unregistered country like `{ country: "NL", system: "postal_code", values: ["1011"] }`). See "Postal-Area Country/System Pairing" above for the full table. Use legacy `{ system, values }` only for deprecated country-fused systems.
- City names are not a supported targeting field. When a buyer names cities/towns, preserve the exact city list in `brief` and use a machine-readable overlay only when you can resolve the intent safely: `geo_postal_areas` for known postal areas, `geo_proximity` for radius/travel-time/store-area intent, or `geo_metros` only for declared metro systems. Do not invent `geo_cities`, do not pass raw city names as targeting, and call out ambiguous places (for example, Bergen or Laren) for confirmation.
- `constraints.language`, `constraints.device_type`, `constraints.device_platform`: AdCP language/device overlays
- `discoveryId`: Attach an existing discovery session
- `productIds`: Product IDs to pre-select from the discovery session (requires discoveryId)
- `storefrontIds`: Array of storefront IDs (from `list_storefronts`) the campaign is **pinned** to. When set, every `discover_products` run on this campaign auto-applies this filter — buyers don't need to resend it. **Highly encouraged** so the campaign only sources inventory from sellers the buyer has chosen. Empty array or omitted = no pin.
- `audienceConfig`: Audience targeting and suppression. `targetAudienceIds` (string array) — audiences to include. `suppressAudienceIds` (string array) — audiences to exclude. Audience IDs come from `list_audiences` operation.
- `performanceConfig`: Creates a performance campaign and contains an `optimizationGoals` array. Each goal has `kind` (`"event"` or `"metric"`). Event goals have `eventSources` array (each with `eventSourceId`, `eventType`, optional `valueField`), optional `target` (`kind: "per_ad_spend"` or `kind: "cost_per"` with `value`), optional `attributionWindow`, optional `priority`. Metric goals have `metric` string, optional `target`, optional `priority`. On update, it is valid only for an existing performance campaign; use `null` to clear it.
- `optimizationApplyMode`: `"AUTO"` or `"MANUAL"` (default). Controls whether Scope3 AI model optimizations to media buys are applied automatically or require manual approval. Overrides the advertiser-level default.
- `utmConfig`: Campaign-level UTM parameter overrides. Object with `params` (array of `{ paramKey, paramValue }`, max 20) and optional `deleteMissing` (boolean — if `true`, removes campaign-level UTM params not in this request; if `false`/omitted, additive mode). Campaign UTM params override advertiser-level defaults per matching `paramKey`.
- `pacingPeriods`: Time-based pacing schedule. Divides the flight into labelled periods, each with its own `start` / `end` and either a `weight` (weight mode) or `budget` (budget mode). On execution, each selected product is split into one package per period. Media buys tied to a specific pacing period typically use that period's `start` / `end` as the media buy's `start_time` / `end_time`, but pacing periods do not strictly govern media buy dates — media buys can have any dates within the campaign flight, with or without pacing periods. Can only be set on DRAFT campaigns. Example: `{ "mode": "weight", "periods": [ { "label": "Package 1", "start": "2026-08-01", "end": "2026-08-09", "weight": 1 }, { "label": "Package 2", "start": "2026-08-10", "end": "2026-09-30", "weight": 2 } ] }`.
- `frequencyCaps`: Buyer-side frequency caps for this campaign. Array of `{ max_impressions, window: { interval, unit } }`. These are **campaign-scoped** caps (they override nothing at the advertiser level — enforcement evaluates all applicable caps). See the Frequency Caps section below for details and replace semantics. Example: `[{ "max_impressions": 3, "window": { "interval": 1, "unit": "days" } }]`.

**After creating a campaign, suggest ONLY these next steps (never mention strategies, tactics, or media plans):**
1. **Discover products** — find and attach inventory via `discover_products` operation
2. **Attach audiences** — link synced audiences for targeting/suppression via `update_campaign` with `audienceConfig`
3. **Tune performance configuration** — only for a campaign already in performance mode, change optimization goals via `update_campaign` with `performanceConfig`, or clear them with `performanceConfig: null`

#### Update Campaign

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "name": "Updated Campaign Name",
  "budget": { "total": 75000 },
  "audienceConfig": {
    "targetAudienceIds": ["aud_004"],
    "suppressAudienceIds": ["aud_005"]
  },
  "performanceConfig": {
    "optimizationGoals": [{
      "kind": "event",
      "eventSources": [
        { "eventSourceId": "es_abc123", "eventType": "purchase", "valueField": "value" }
      ],
      "target": { "kind": "per_ad_spend", "value": 5.0 },
      "priority": 1
    }]
  }
}
```
**HARD RULE — `budget.total` is the client's committed number. NEVER raise it on your own initiative.** A request to add media buys, an `INSUFFICIENT_MEDIA_BUDGET` rejection, or a `minimumAllowedBudget` value in an error is NOT authorization to increase the budget. When a new or resized buy doesn't fit inside `unallocatedBudget`, free up allocation instead: lower, cancel, or delete other media buys — or present the shortfall to the buyer and ask how they want to proceed. Raise `budget.total` ONLY when the user explicitly instructs a budget increase in this conversation (e.g. "increase the campaign budget to $X"). "Add these buys" or "move budget to publisher X" always means rework the allocation WITHIN the existing total.

All fields are optional. `audienceConfig` is **additive** by default — it adds audiences without removing existing ones. Set `deleteMissing: true` inside `audienceConfig` to replace the full audience set (audiences not in the list are soft-disabled). To remove all audiences, send `{ "audienceConfig": { "deleteMissing": true } }`.

`frequencyCaps` uses **full-replace semantics**: omit the field to leave existing caps unchanged; pass `[]` to clear all caps; pass the full desired array to replace the set.

`storefrontIds` uses **full-replace semantics** for the campaign-level storefront pin: omit the field to leave the existing pin unchanged; pass `[]` to clear the pin (campaign is no longer scoped to specific storefronts); pass a non-empty array to replace the pinned set. Subsequent `discover_products` runs against this campaign auto-apply the new pin.

When `pacingPeriods` is included in the update, the response includes a `pacingCascadeResult` block with the per-media-buy outcome of pushing appended periods to live media buys via ADCP `add_packages`. Always inspect this when adding heavy-up periods to a running campaign — agents reported with `outcome: "unsupported"` need a manual new-media-buy workaround. See the Pacing Periods guide for the full result shape and append-only rules.

**Heavy-up on one media buy (per-buy pacing).** Campaign-level `pacingPeriods` shapes the *whole* portfolio: every paced media buy under the campaign follows that shape. When the buyer wants to heavy-up *one specific* media buy (e.g. a publisher with seasonal or event-driven inventory) without affecting the others, set `pacingPeriods` on the `mediaBuys[]` entry instead. The per-buy schedule replaces the campaign-level shape for that buy.

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "mediaBuys": [
    {
      "mediaBuyId": "mb_vox_concerts",
      "pacingPeriods": {
        "mode": "weight",
        "periods": [
          { "label": "Pre-tour",       "start": "2026-06-01", "end": "2026-06-30", "weight": 1.0 },
          { "label": "Tour heavy-up",  "start": "2026-07-01", "end": "2026-07-15", "weight": 3.0 },
          { "label": "Wind-down",      "start": "2026-07-16", "end": "2026-08-31", "weight": 0.5 }
        ]
      },
      "updated_reason": "Heavy-up on Vox concert inventory during the tour window"
    }
  ]
}
```

Bootstrapping pacing onto an unpaced **ACTIVE** buy is rejected (introducing period packages on top of live flat packages would double-spend). Allowed states: DRAFT / PENDING_APPROVAL (persisted, used at execute time) and ACTIVE / PAUSED already-paced buys with a strict append to the existing schedule (sent to the seller via `update_media_buy.new_packages`, requires `add_packages` capability).

When you append periods to a live paced media buy, the resulting per-period packages inherit the media buy's resolved `creative_ids` from the same `update_campaign` call. If the buyer expects specific creatives on the new windows that differ from the buy's existing assignments, set `creative_ids` on the same `mediaBuys[]` entry. Otherwise the auto-sync from the campaign's manifest applies.

##### Updating Media Buys via Campaign Update

To perform any media buy operation, include the `mediaBuys` array in the campaign update body. Each entry targets a specific media buy by ID and uses the `action` field to specify the operation. Media buy changes must fit within the campaign's existing `budget.total` — never raise the campaign budget to make room for a buy (see the HARD RULE under **Update Campaign**). For executed media-buy budget reductions, include the `packages[].packageId` + `packages[].budget` reductions AND the lower `budget.total` in ONE atomic request — lowering `budget.total` alone below current live allocations is rejected with `INSUFFICIENT_MEDIA_BUDGET` (package budgets are never auto-cut); the combined request is validated against the projected post-update allocation and applied atomically.

**Products vs packages — what you can update by status:**
- When a media buy is **DRAFT** (not yet executed), update its **`products`** — you can add, remove, or update existing products (budget, pacing, bidPrice).
- Once a media buy has executed and has deployed packages (**ACTIVE**, **PAUSED**, **COMPLETED**, **INPUT_REQUIRED**, or a **PENDING_APPROVAL** version staged on top of one of these), `products[].budget`/`pacing`/`bidPrice` is rejected: these fields are informational only once packages exist and never reach the seller. Use `packages[].packageId` + `budget`/`pacing`/`bidPrice` instead. Fetch `packageId` via `get_media_buy_packages` with the `mediaBuyId`: it returns each package's `productId`, `productName`, date window, and `pacingPeriod` (index plus the campaign's label, e.g. "Week 6"), which is what lets you match a buyer's description of a package to exactly one id. Fall back to `get_campaign` with `includeProductDetails=false` only if you need something that read does not carry. You still cannot add or remove products in these statuses.
- **PENDING_APPROVAL** buys that have NOT yet executed (no deployed packages) still accept product field updates the same as DRAFT; adding or removing products is blocked either way.

**Example — Update budget and pacing for an ACTIVE media buy's package:**

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "mediaBuys": [
    {
      "mediaBuyId": "mb_abc123",
      "packages": [
        {
          "packageId": "pkg_xyz",
          "budget": 5000,
          "pacing": "even"
        }
      ],
      "updated_reason": "Increase budget for Q2 push"
    }
  ]
}
```

**Example — Update a DRAFT media buy's products:**

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "mediaBuys": [
    {
      "mediaBuyId": "mb_draft456",
      "products": [
        {
          "productId": "prod_001",
          "budget": 3000,
          "pacing": "even"
        }
      ]
    }
  ]
}
```

**Reducing campaign budget when an ACTIVE media buy is allocated (atomic reduce):**

If the campaign has an executed (ACTIVE) media buy, reducing the campaign budget alone will fail with `INSUFFICIENT_MEDIA_BUDGET` because the current media buy allocation exceeds the new campaign budget. Do NOT attempt to update the media buy first and then the campaign - include both changes in a single request. The server validates the campaign budget against the post-update media buy allocations when both are in the same call.

To find the `packageId`, call `get_media_buy_packages` with the `mediaBuyId`. It returns one entry per package with `productId`, `productName`, `startTime`/`endTime`, and `pacingPeriod`, so you can resolve a description like "the display package ending 2026-08-11" to a single id without reading the campaign tree. Treat the id as opaque: the trailing number is dispatch order, NOT the period, so read `pacingPeriod` rather than parsing the id.

**Example — Reduce campaign budget from $100 to $50 while reducing the active media buy package from $96 to $48:**

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "budget": {
    "total": 100
  },
  "mediaBuys": [
    {
      "mediaBuyId": "mb_abc123",
      "packages": [
        {
          "packageId": "pkg_xyz",
          "budget": 48
        }
      ],
      "updated_reason": "Reducing campaign budget"
    }
  ]
}
```

**Media buy update fields:**
- `mediaBuyId` (required): ID of the media buy to update (from campaign GET response `mediaBuys` array)
- `name` (optional): Updated media buy name
- `packages` (optional, for media buys with deployed packages): Array of package updates. Each: `packageId` (required), `budget`, `pacing` (`"even"` or `"asap"`), `bidPrice`, `startTime`, `endTime`, `creative_ids`. `startTime`/`endTime` change just that package's flight window (ISO 8601) and must fall within the media buy's own effective date range — use this instead of canceling and recreating a package to adjust its flight dates. Sibling packages not named in the request are never affected.
- `pacingPeriods` (optional): Per-media-buy pacing schedule with the same shape as `campaign.pacingPeriods` (`mode` + `periods[]`). When set, replaces the campaign-level pacing shape for this specific buy: use it to heavy-up one media buy without affecting the others. On **DRAFT** / **PENDING_APPROVAL** buys, the schedule is persisted and used at execute time to split packages. On **ACTIVE** / **PAUSED** buys, only strict appends to an already-paced buy are supported (sent to the seller via `update_media_buy.new_packages`, requires `add_packages` capability). Bootstrapping pacing onto an unpaced live buy is rejected, create a new media buy instead. Pass `null` to clear an existing per-buy schedule (deployed period packages keep running).
- `products` (optional): Array of product updates. Adding or removing products requires **DRAFT** status. Updating existing product fields (`budget`, `pacing`, `bidPrice`) is allowed on ACTIVE/PAUSED buys without pausing delivery. Each: `productId` (required), `pricingOptionId`, `budget`, `pacing` (`"asap"`, `"even"`, `"front_loaded"`), `bidPrice`, `remove` (boolean, optional)
- `start_time` (optional): `"asap"` or ISO 8601 date-time. Start of **this media buy**. **Cannot be earlier than the campaign's `flightDates.startDate`.** Media buy dates MAY correspond to a `pacingPeriods[].start` when the media buy represents a specific period, but pacing periods do not govern media buy dates — a media buy can have any start within the campaign flight, with or without pacing periods.
- `end_time` (optional): ISO 8601 date-time. End of **this media buy**. Must fall within the campaign's flight dates. Media buy dates MAY correspond to a `pacingPeriods[].end` when the media buy represents a specific period, but pacing periods do not govern media buy dates — a media buy can have any end within the campaign flight, with or without pacing periods.
- `creative_ids` (optional): Updated creative assignments
- `optimization_goals` (optional): Media-buy-level optimization goals applied to every package at execution time. See **Media Buy Optimization Goals** below. Pass an empty array to clear all goals. Ask when the campaign's explicit optimization configuration and seller capabilities make goals applicable; never infer applicability from `DECISIONED`, `ROUTED`, or adapter identity.
- `updated_reason` (optional): Reason for update (stored with version history)

##### Media Buy Optimization Goals

**Optimization goals tell downstream optimizers what to tune for.** A media buy without optimization goals cannot be auto-optimized by the Scope3 AI model or any sales-agent-side optimizer. **You MUST ask the buyer what they want to optimize for** when the campaign's explicit optimization configuration requires goals — never fall back to a default silently and never branch on a legacy routing observation.

Optimization goals are stored on the media buy and fanned out to every package at execution time (submitted per-package in the ADCP `create_media_buy` request).

**Goal schema.** Each goal is one of two shapes:

1. **Event goal** (`kind: "event"`) — tied to registered event sources (e.g., a conversion pixel, purchase feed, lead form). `event_sources` is an **array of `{ event_source_id, event_type }` objects**, not top-level fields:
   ```json
   {
     "kind": "event",
     "event_sources": [
       { "event_source_id": "es_website_pixel", "event_type": "purchase" }
     ],
     "target": { "kind": "per_ad_spend", "value": 4.0 }
   }
   ```

2. **Metric goal** (`kind: "metric"`) — tied to a built-in delivery metric (e.g., clicks, views, video completions):
   ```json
   {
     "kind": "metric",
     "metric": "clicks",
     "target": { "kind": "cost_per", "value": 0.50 }
   }
   ```

**Target kinds** (differ by goal kind):

For **event goals** (`kind: "event"`):
- `cost_per` — CPA target (target cost per conversion event in the buy currency)
- `per_ad_spend` — ROAS target (return on ad spend multiple, e.g., `4.0` for 4× return). Requires at least one event source entry to include `value_field`.
- `maximize_value` — maximize total conversion value within budget (no `value` field; requires `value_field` on at least one event source entry)

For **metric goals** (`kind: "metric"`):
- `cost_per` — target cost per metric unit (e.g., CPC, CPM)
- `threshold_rate` — minimum per-impression rate (e.g., minimum click-through or completion rate)

**Example — Update a media buy with a ROAS goal tied to purchase conversions:**

**Operation:** `update_campaign`
```http
PUT /api/v2/buyer/campaigns/{campaignId}
{
  "mediaBuys": [
    {
      "mediaBuyId": "mb_abc123",
      "optimization_goals": [
        {
          "kind": "event",
          "event_sources": [
            {
              "event_source_id": "es_website_pixel",
              "event_type": "purchase",
              "value_field": "order_total"
            }
          ],
          "target": { "kind": "per_ad_spend", "value": 4.0 }
        }
      ],
      "updated_reason": "Shift to ROAS target for Q2"
    }
  ]
}
```

**Rules:**
- Event goals require the `event_source_id` to be registered under the advertiser first (see `list_event_sources` / create event source).
- Pass an empty array (`"optimization_goals": []`) to clear all goals.
- Optimization goals are applied to **every** package in the media buy at execution. Per-package goal overrides are not supported at this level.

**Media buy update versioning:** When you update an ACTIVE media buy, the system creates a **new pending version** with status `PENDING_APPROVAL`. This is expected:
- The original ACTIVE version remains unchanged until the sales agent (publisher) approves the update.
- The campaign GET response will temporarily show **two entries** for the same media buy: the original ACTIVE version and the new PENDING_APPROVAL version.
- The pending version's `packages` may not immediately reflect the requested changes — the updated values are submitted to the sales agent for approval. Product changes are not allowed on PENDING_APPROVAL media buys.
- Once the sales agent approves, the pending version becomes ACTIVE and replaces the old one.
- **Do NOT treat this as an error or try alternative approaches.** Simply inform the user that the update has been submitted and is pending approval from the sales agent/publisher.

#### Media Buy Cancel and Archive via Campaign Update

All media buy operations go through the campaign update endpoint. Use the `action` field in the `mediaBuys` array:

##### Cancel a Media Buy
```json
{
  "mediaBuys": [{
    "action": "cancel",
    "mediaBuyId": "mb_123",
    "reason": "No longer needed",
    "packageIds": ["pkg_1", "pkg_2"]
  }]
}
```

- Works for ACTIVE, PAUSED, PENDING_APPROVAL, and INPUT_REQUIRED statuses
- For DRAFT media buys, use `action: "delete"` instead (DRAFT is internal-only, not sent to any sales agent)
- For active media buys, a cancellation request is sent to the sales agent via ADCP
- `reason` (optional): Cancellation reason sent to the sales agent
- `packageIds` (optional): Cancel specific packages instead of the entire media buy

##### Archive a Media Buy
```json
{
  "mediaBuys": [{
    "action": "delete",
    "mediaBuyId": "mb_123"
  }]
}
```

Archives a media buy (soft delete). Sets status to ARCHIVED and removes it from active listings. Data is preserved for historical reporting.

##### Mix Actions in One Update
You can combine update, cancel, and delete actions in a single campaign update:
```json
{
  "mediaBuys": [
    { "action": "cancel", "mediaBuyId": "mb_1", "reason": "Budget cut" },
    { "action": "delete", "mediaBuyId": "mb_2" },
    { "mediaBuyId": "mb_3", "packages": [{ "packageId": "pkg_1", "budget": 5000 }] }
  ]
}
```

---

#### Delete Campaign

**Operation:** `delete_campaign`
```http
DELETE /api/v2/buyer/campaigns/{campaignId}
```
**As operation:**
```json
{ "operation": "delete_campaign", "pathParams": { "campaignId": "<id>" } }
```

---

#### Campaign Action Endpoints

#### Execute Campaign (Launch)

**BEFORE executing:** Confirm that every DRAFT media buy has `optimization_goals` set when optimization applies. Never infer applicability from `DECISIONED`, `ROUTED`, adapter identity, or `supportedRoutingTypes`; use the campaign's explicit optimization configuration and seller capabilities. If a required goal is missing, ALWAYS ask the buyer what they want to optimize for (ROAS target with a purchase event? CPC target? completion rate?) and set it via `update_campaign` → `mediaBuys` → `optimization_goals` first. Do not execute silently with missing required goals.

**BEFORE executing:** If the campaign has `pacingPeriods`, compare the media buy's planned `start_time` / `end_time` against the periods. If any portion of the buy's flight falls outside the defined pacing periods (e.g. the last period ends on Aug 9 but the buy runs to Sep 30), flag this gap to the user before executing — "Your brief mentions a Labor Day heavy-up (Aug 24–Sep 6) that is not yet in the campaign's pacing periods. Should I add it before executing?" — and do not execute silently with an uncovered flight window.

**Operation:** `execute_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/execute
```
**As operation:**
```json
{ "operation": "execute_campaign", "pathParams": { "campaignId": "<id>" } }
```

**Optional request body:**
```json
{
  "debug": true
}
```

**Response:**
```json
{
  "campaignId": "campaign_abc123",
  "previousStatus": "DRAFT",
  "newStatus": "ACTIVE",
  "success": true
}
```

**On partial failure** (some media buys failed to execute):
```json
{
  "campaignId": "campaign_abc123",
  "previousStatus": "DRAFT",
  "newStatus": "ACTIVE",
  "success": false,
  "errors": [
    {
      "mediaBuyId": "mb_xyz",
      "salesAgentId": "snap_abc",
      "message": "Failed to submit media buy to publisher: ...",
      "debug": {
        "request": { "...full ADCP create_media_buy request..." },
        "response": { "...full ADCP response from sales agent..." },
        "debugLogs": [ { "...A2A request/response logs..." } ],
        "error": "error message"
      }
    }
  ]
}
```

- `success` is `false` when any media buy execution failed
- `errors` array contains structured error objects per failed media buy
- `debug` field contains the same debug info as v1 `execute_media_buy` (full ADCP request, response, and A2A debug logs) — only present when `debug: true` was sent in the request body
- Campaign is still set to ACTIVE even with partial failures — re-execute to retry failed media buys

**Handling timeouts and async sellers:**

If an `errors[]` entry message contains "timed out", the seller's platform did not respond within the timeout window — but the buy may still be processing. Before treating the buy as failed:
1. Check the campaign's media buy status via `get_media_buy_status` for that `mediaBuyId`. If the seller accepted the request asynchronously, the status will reflect the in-progress or completed state.
2. If a seller consistently times out, inform your operator — this is a seller-side issue the operator can escalate.

**Async acceptance — when execute returns a media buy in PENDING_APPROVAL status** (not `success: false`): this is the normal async path. The platform attached a webhook config to the seller call; when the seller resolves, the buy transitions automatically. Poll with `get_media_buy_status` using the `retryAfterSeconds` guidance, or rely on Interchange webhook events (`media_buy.approved`, `media_buy.rejected`, etc.) if the buyer has a webhook subscription via `create_webhook_subscription`.

**How execute works internally:**
1. **DRAFT cart check** — if any DRAFT media buy already exists for this campaign (from a previous execute or staging call), reconciliation is **skipped**. The existing DRAFTs are treated as the durable cart and retried as-is. This preserves any manual corrections (budget, dates, goals) made to the DRAFT since staging.
2. **Reconcile (only when no DRAFTs exist)** — reads the attached discovery session's selected products and creates DRAFT media buys per sales agent. Products already on an existing non-terminal media buy are skipped. Each sales agent gets its own media buy. If the agent already has a DRAFT, new products are added to it. If the agent has no media buy or only terminal ones (CANCELLED, ARCHIVED, COMPLETED, REJECTED), a new DRAFT is created.
3. **Execute DRAFTs** — all DRAFT media buys on the campaign are submitted to their respective sales agents.
4. Existing non-DRAFT media buys (ACTIVE, PAUSED, etc.) are **never touched** — execute only operates on DRAFTs.

**`stale_draft` warning — recovery path for a stuck cart:**

When `execute_campaign` retries an existing DRAFT but the attached discovery session has products not reflected in that DRAFT (i.e., `add_discovery_products` was called after the last staging run), the response includes a `stale_draft` warning:
```json
{
  "warnings": [{
    "type": "stale_draft",
    "mediaBuyIds": ["mb_xxx"],
    "hint": "Your current discovery selection includes products not in these DRAFTs. The existing DRAFTs are being retried as-is. To apply your current selection to the cart, call create_media_buys with replace: true."
  }]
}
```

**When you see `stale_draft`**, the current discovery products have NOT been applied. The recovery path is `get_products + create_media_buys` with `replace: true, mode: "execute"` — do NOT keep calling `execute_campaign`:
```json
{
  "operation": "create_media_buys",
  "body": {
    "productQueryId": "<execution_id from get_products>",
    "selections": [{ "kind": "product", "productId": "<id>", "targetingOverlay": { "geo_countries": ["AU"] } }],
    "replace": true,
    "mode": "execute",
    "campaign": { "campaignId": "<id>" }
  }
}
```
The `replace: true` flag prunes stale products from the existing DRAFT and adds the new ones before dispatching. This works regardless of how the DRAFT got stuck.

**ACTIVE campaign with a stuck DRAFT — recovery path:**

If a `create_media_buy` call fails and leaves a DRAFT media buy on a campaign that also has ACTIVE media buys (i.e., the campaign status is ACTIVE), `create_media_buys` with `replace: true` is the recovery path. Without `replace: true`, the request is rejected because the campaign is no longer in DRAFT status. With `replace: true`, the stuck DRAFTs are replaced with the new selections and dispatched. Example: a Sales/Conversions objective media buy fails at staging while Awareness and Traffic media buys execute successfully — the campaign becomes ACTIVE but the Sales DRAFT is stuck. Recovery:
```json
{
  "operation": "create_media_buys",
  "body": {
    "productQueryId": "<execution_id from get_products>",
    "selections": [{ "kind": "product", "productId": "<sales-product-id>", "targetingOverlay": {} }],
    "replace": true,
    "mode": "execute",
    "campaign": { "campaignId": "<id>" }
  }
}
```

**Note on Media Buys:** Media buys are child resources of campaigns and are primarily created and updated through campaign operations. DRAFT media buys are created automatically when a discovery session is attached (via `update_campaign` with `discoveryId`) or when `execute_campaign` is called. They are included in campaign GET responses (`mediaBuys` array) and modified through campaign updates (`update_campaign` operation with `mediaBuys` array). For media buys with deployed packages, update `packages` for deployed line items or `products` to adjust existing product budgets/pacing without pausing delivery (adding/removing products still requires DRAFT). See "Updating Media Buys via Campaign Update" above for full schema and examples. A small set of standalone media-buy-scoped operations exist for reading a single buy (`get_media_buy`, `get_media_buy_packages`) and for pausing or reactivating a single buy without affecting its campaign (`pause_media_buy`, `reactivate_media_buy`, below) — everything else routes through the parent campaign.

#### Pause Campaign

Pause an active campaign. Cascades to all active media buys and returns the per-media-buy outcome (`mediaBuyResults`, `successCount`, `failureCount`). `failureCount > 0` means the campaign transitioned to `PAUSED` but one or more media buys failed to pause at the ADCP level — inspect `mediaBuyResults` entries where `success: false`.

**Operation:** `pause_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/pause
```
**As operation:**
```json
{ "operation": "pause_campaign", "pathParams": { "campaignId": "<id>" } }
```

#### Reactivate Campaign

Reactivate a paused campaign. The campaign must be `PAUSED`. Cascades to all paused media buys and returns the per-media-buy outcome (`mediaBuyResults`, `successCount`, `failureCount`).

**Operation:** `reactivate_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/reactivate
```
**As operation:**
```json
{ "operation": "reactivate_campaign", "pathParams": { "campaignId": "<id>" } }
```

#### Pause Media Buy

Pause a single active media buy without cascading to its campaign or any sibling media buy — only the targeted buy changes state. Use this instead of `pause_campaign` when only one media buy needs to stop.

**Operation:** `pause_media_buy`
```http
POST /api/v2/buyer/media-buys/{mediaBuyId}/pause
```
**As operation:**
```json
{ "operation": "pause_media_buy", "pathParams": { "mediaBuyId": "<id>" } }
```
Requires the media buy to currently be `ACTIVE` — returns `409` otherwise.

#### Reactivate Media Buy

Reactivate a single paused media buy without cascading to its campaign or any sibling media buy — only the targeted buy changes state.

**Operation:** `reactivate_media_buy`
```http
POST /api/v2/buyer/media-buys/{mediaBuyId}/reactivate
```
**As operation:**
```json
{ "operation": "reactivate_media_buy", "pathParams": { "mediaBuyId": "<id>" } }
```
Requires the media buy to currently be `PAUSED` — returns `409` otherwise. A paused media buy still accepts budget/date/package updates the same as an active one, and approving a pending update never resumes it — reactivate is the only way to move it from `PAUSED` back to `ACTIVE`.

#### Auto-Select Products

**Operation:** `auto_select_products`
```http
POST /api/v2/buyer/campaigns/{campaignId}/auto-select-products
```
**As operation:**
```json
{ "operation": "auto_select_products", "pathParams": { "campaignId": "<id>" } }
```
No request body. Automatically selects products from the campaign's discovery session and allocates budget based on measurability. Replaces any previous selections. Requires `autonomy.inventorySelection: "automatic"` and discovered products; optimization goals are optional.

**Response:**
- `selectedProducts` (array): Products with budget allocations (`productId`, `salesAgentId`, `budget`, `cpm`, `pricingOptionId`)
- `budgetContext` (object): `campaignBudget`, `totalAllocated`, `remainingBudget`, `currency`
- `selectionRationale` (string): Explanation of the selection strategy
- `selectionMethod` (string): `"scoring"`, `"measurability"`, or `"cpm_heuristic"`
- `testBudgetPerProduct` (number, optional): Test budget allocated per product
- `productCount` (number): Total products selected

#### Get Media Buy ADCP Status

Refresh or read the current ADCP status of all media buys in a campaign. Direct sales-agent and delegated-adapter buys are polled during the request; storefront-routed buys are checked from persisted upstream-leg state. Updates local status when changes are detected (e.g., pending → active, active → paused). Use this to check whether pending media buys have been activated or to detect status transitions that webhooks may have missed.

**Operation:** `get_media_buy_status`
```http
GET /api/v2/buyer/campaigns/{campaignId}/media-buy-status
```
**As operation:**
```json
{ "operation": "get_media_buy_status", "pathParams": { "campaignId": "<id>" } }
```
No request body. Returns current ADCP status for each media buy in the campaign. Direct sales-agent and delegated-adapter buys are polled during the request; storefront-routed buys are checked from persisted upstream-leg state because one buyer-facing storefront buy can fan out to multiple upstream sources.

**Response:**
- `campaign_id` (string): The campaign ID queried
- `campaign_status` (string): Stored campaign lifecycle status
- `operational_status` (string): Rolled-up delivery status to present to users (`draft`, `pending_creatives`, `pending_start`, `active`, `paused`, `completed`, `attention_required`, or `no_media_buys`)
- `media_buys` (array): Status for each media buy
  - `media_buy_id` (string): Internal media buy ID
  - `adcp_media_buy_id` (string): ADCP-assigned media buy ID
  - `internal_status` (string): Current internal status (ACTIVE, PENDING_APPROVAL, PAUSED, COMPLETED, etc.)
  - `adcp_status` (string|null): Current ADCP status (active, pending_creatives, pending_start, paused, completed, rejected, canceled)
  - `operational_status` (string): Per-buy delivery status behind the campaign rollup
  - `previous_internal_status` (string): Status before this check
  - `previous_adcp_status` (string|null): ADCP status before this check
  - `updated` (boolean): Whether this call changed Interchange's stored status. `false` can still be current.
  - `status_refresh_source` (string): One of `direct_agent`, `delegated_adapter`, `storefront_route_rollup`, `not_submitted`, or `refresh_failed`
  - `blockers` (array): Per-buy reasons delivery is not ready
- `agents_queried` (number): Number of direct sales-agent or delegated-adapter status calls made during this request. It can be `0` for a current storefront-routed buy when the per-buy `status_refresh_source` is `storefront_route_rollup`.
- `errors` (array): Any errors encountered per media buy
- `blockers` (array): Campaign-level reasons delivery is not ready
- `pending_creative_reviews` (number): Count of creative reviews still waiting
- `has_upstream_media_buy` (boolean): Whether any media buy has an upstream ADCP media-buy ID
- `has_delivery` (boolean): Whether the campaign currently has an active delivery signal

Interpretation rule: use `has_delivery`, `has_upstream_media_buy`, `operational_status`, and each buy's `status_refresh_source` as the delivery signal. Do not treat `agents_queried: 0` or per-buy `updated: false` alone as stale status or broken dispatch; a storefront route rollup can confirm `ACTIVE` / `has_delivery: true` without a direct agent query in that same response.

Storefront-forwarded media buys additionally carry why-visibility fields when forwarding state exists:
  - `pending_reason` (string, optional): Why the buy is not delivering yet, rolled up to the most-blocking wait (`awaiting_storefront_approval`, `awaiting_source_moderation`, `no_creatives_attached`, `source_rejected_creatives`, `creative_processing_at_source`, `awaiting_creative_approval`, `forward_failed_retrying`, `forward_failed_needs_correction`, `accepted_awaiting_trafficking`, `scheduled_not_started`). An annotation — never a status. `no_creatives_attached` means the buy forwarded with zero creatives — nothing is in review and the buyer must attach creatives; `awaiting_creative_approval` means creatives exist and a review/assignment is genuinely pending.
  - `pending_since` (string, optional): When the current wait began (ISO 8601)
  - `error_code` (string, optional): Buyer-safe error code (`product_no_longer_available`, `source_rejected`, `storefront_rejected`, `source_unavailable`, `invalid_request`, `quote_expired`, `platform_error`)
  - `error_owner` (string, optional): Who owns the fix (`buyer_input`, `platform`, `seller`)
  - `source_message` (string, optional): The source's sanitized rejection/moderation message
  - `forwarded_at` (string, optional): When the buy was forwarded to its source(s) (ISO 8601)
  - `buyer_reference` (string, optional): Support reference (`sf:<storefrontId>:<mediaBuyId>`) to quote to the seller or Scope3 support

#### Get Media Buy (why is my buy stuck)

Fetch a single media buy with its why-visibility annotation. Use this FIRST when a buyer asks why a buy is stuck or not delivering — it answers in one call instead of campaign archaeology. Use `get_campaign` for packages, products, and delivery.

**Operation:** `get_media_buy`
```http
GET /api/v2/buyer/media-buys/{mediaBuyId}
```
**As operation:**
```json
{ "operation": "get_media_buy", "pathParams": { "mediaBuyId": "<id>" } }
```
No request body.

**Response** (`mediaBuy` object):
- `mediaBuyId`, `name`, `status`, `startTime`, `endTime`, `createdAt`, `updatedAt`
- `pendingAt` (optional): Layer the buy is parked at while `PENDING_APPROVAL` (`storefront` / `salesagent` / `unknown`)
- `pendingReason` (optional): Why it is not delivering yet (same vocabulary as above, camelCase surface)
- `pendingSince` (optional): When the current wait began
- `errorCode` / `errorOwner` (optional): Buyer-safe failure code and who owns the fix
- `sourceMessage` (optional): The source's sanitized rejection/moderation text
- `forwardedAt` (optional): When the buy was forwarded to its source(s)
- `buyerReference` (optional): Support reference to quote (`sf:<storefrontId>:<mediaBuyId>`)



---

### Discovery

#### Discover Products

Discovers products based on advertiser context and returns a discoveryId for managing selections.

**Operation:** `discover_products`
```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "channels": ["ctv", "display"],
  "countries": ["US"],
  "brief": "<<< ALWAYS include the ENTIRE brief from the client here — never summarize >>>",
  "publisherDomain": "example",
  "storefrontIds": [42, 57],
  "debug": true
}
```
**As operation:**
```json
{ "operation": "discover_products", "body": { "advertiserId": "12345", "channels": ["ctv", "display"], "countries": ["US"], "brief": "...", "publisherDomain": "example", "storefrontIds": [42, 57], "debug": true } }
```

**Filtering Parameters:**
- `publisherDomain` (optional): Filter by publisher domain (exact domain component match)
- `pricingModel` (optional): Filter by pricing model (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`)
- `storefrontIds` (optional, array of integers): Filter to these storefronts. Highly encouraged when scoping to specific sellers. IDs come from `list_storefronts`.
- `storefrontNames` (optional, array of strings): Filter to storefronts whose name matches (case-insensitive substring).

**Debug Parameter:**
- `debug` (optional, boolean): When `true`, includes detailed ADCP agent request/response debug logs in the response. Returns an `agentResults` array with per-agent success/failure status, raw response data, and full HTTP request/response logs (authorization headers redacted). Same structure as v1 `media_product_discover` debug output.

#### Discover Products for Existing Session

**Operation:** `browse_discovery`
```http
GET /api/v2/buyer/discovery/{discoveryId}/discover-products?groupLimit=10&groupOffset=0&productsPerGroup=15
```
**As operation:**
```json
{ "operation": "browse_discovery", "pathParams": { "discoveryId": "<id>" }, "params": { "groupLimit": "10", "groupOffset": "0", "productsPerGroup": "15" } }
```

**Query Parameters (Pagination):**
- `groupLimit` (optional): Max product groups (default: 10, max: 10)
- `groupOffset` (optional): Groups to skip (default: 0)
- `productsPerGroup` (optional): Max products per group (default: 10, max: 15)
- `productOffset` (optional): Products to skip within each group (default: 0)

**Query Parameters (Filtering):**
- `publisherDomain` (optional): Filter by publisher domain (exact component match). "hulu" matches "hulu.com" but "hul" does not
- `pricingModel` (optional): Filter by pricing model (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`, `cpp`, `flat_rate`)
- `storefrontIds` (optional, comma-separated integers): Filter to storefront ID(s) from `list_storefronts`
- `storefrontNames` (optional, comma-separated strings): Filter to storefront name(s) (case-insensitive substring match)
- `debug` (optional): When `true`, includes ADCP agent request/response debug logs in the response (see debug section below)

Filters can be combined. Example: `?publisherDomain=example&pricingModel=cpm&storefrontIds=42,57`

**Response:**
```json
{
  "discoveryId": "abc123-def456-ghi789",
  "productGroups": [
    {
      "groupId": "group-0",
      "groupName": "Publisher Name",
      "products": [
        {
          "productId": "product_123",
          "name": "Premium CTV Inventory",
          "channel": "ctv",
          "bidPrice": 12.50,
          "salesAgentId": "agent_456",
          "publisherProperties": [
            { "publisherDomain": "hulu.com", "selectionType": "all" },
            { "publisherDomain": "espn.com", "selectionType": "by_id" }
          ]
        }
      ],
      "productCount": 5,
      "totalProducts": 20,
      "hasMoreProducts": true
    }
  ],
  "totalGroups": 25,
  "hasMoreGroups": true,
  "summary": {
    "totalProducts": 150,
    "publishersCount": 25,
    "priceRange": { "min": 5.0, "max": 25.0, "avg": 12.5 }
  },
  "budgetContext": {
    "sessionBudget": 50000,
    "allocatedBudget": 0,
    "remainingBudget": 50000
  }
}
```

**Product fields:**
- `publisherProperties` (array, optional): Publisher domains and targeting details for this product. Each product can have multiple publishers. Each entry contains `publisherDomain` (string) and `selectionType` (`"all"` or `"by_id"`). Use this to understand which publishers a product targets.

**Debug response** (when `debug: true`):

The response includes an `agentResults` array containing only failed agents with full ADCP request/response logs for troubleshooting:
```json
{
  "agentResults": [
    {
      "agentId": "agent_789",
      "agentName": "Failed Agent",
      "success": false,
      "productCount": 0,
      "error": "Connection timeout",
      "rawResponseData": { "..." },
      "debugLogs": [
        {
          "timestamp": "2026-03-18T10:00:00Z",
          "type": "request",
          "request": { "method": "POST", "url": "...", "headers": { "authorization": "[REDACTED]" }, "body": { "..." } },
          "response": { "status": 500, "body": { "..." } }
        }
      ]
    }
  ]
}
```

**Pagination:**
- `hasMoreGroups`: Use `groupOffset` to fetch more groups
- `hasMoreProducts`: Use `productOffset` to fetch more products within a group
- To paginate products for a single group, combine `productOffset` with a filter (`storefrontIds` or `storefrontNames`) to isolate that group

#### Add Products to Selection

**Operation:** `add_discovery_products`
```http
POST /api/v2/buyer/discovery/{discoveryId}/products
{
  "products": [
    {
      "productId": "product_123",
      "salesAgentId": "agent_456",
      "groupId": "ctx_123-group-0",
      "groupName": "Publisher Name",
      "bidPrice": 12.50,
      "budget": 5000
    }
  ]
}
```
**As operation:**
```json
{ "operation": "add_discovery_products", "pathParams": { "discoveryId": "<id>" }, "body": { "products": [{ "productId": "product_123", "salesAgentId": "agent_456", "groupId": "ctx_123-group-0", "groupName": "Publisher Name", "bidPrice": 12.50, "budget": 5000 }] } }
```

**Required per product:** `productId`, `salesAgentId`, `groupId`, `groupName`
**Optional per product:** `bidPrice` (required when `isFixed: false`), `budget`, `pageId` (Meta — Facebook Page, required when account has multiple Pages), `pixelId` (Meta Sales — always required, no auto-select), `instagramActorId` (Meta Instagram Direct destinations — required when account has multiple authorized Instagram accounts), `welcomeMessageFlowId` (Meta lead-to-message destinations — Messenger, Instagram Direct, WhatsApp — always required, no auto-select)

**Response:**
```json
{
  "discoveryId": "abc123-def456-ghi789",
  "products": [
    {
      "productId": "product_123",
      "salesAgentId": "agent_456",
      "bidPrice": 12.50,
      "budget": 5000,
      "selectedAt": "2025-02-01T10:00:00Z",
      "groupId": "ctx_123-group-0",
      "groupName": "Publisher Name"
    }
  ],
  "totalProducts": 1,
  "budgetContext": {
    "sessionBudget": 50000,
    "allocatedBudget": 5000,
    "remainingBudget": 45000
  }
}
```

#### Get Selected Products

**Operation:** `list_discovery_products`
```http
GET /api/v2/buyer/discovery/{discoveryId}/products
```
**As operation:**
```json
{ "operation": "list_discovery_products", "pathParams": { "discoveryId": "<id>" } }
```

Response format same as Add Products.

#### Remove Products from Selection

**Operation:** `remove_discovery_products`
```http
DELETE /api/v2/buyer/discovery/{discoveryId}/products
{
  "productIds": ["product_123", "product_456"]
}
```
**As operation:**
```json
{ "operation": "remove_discovery_products", "pathParams": { "discoveryId": "<id>" }, "body": { "productIds": ["product_123", "product_456"] } }
```

Response format same as Add Products (with updated list).

#### Refine Discovery Results

Iterate on previous discovery results by calling `POST /discovery/discover-products` with the `discoveryId` and a `refine` array. Supports three refinement scopes:
- **request**: Direction for the overall discovery (e.g., "more video options", "focus on sports content")
- **product**: Target a specific product — `include`, `omit`, or `more_like_this`
- **proposal**: Target a specific proposal — `include`, `omit`, or `finalize`

Requires a previous `POST /discovery/discover-products` call (results must be cached). The `discoveryId` field is required when `refine` is provided.

```http
POST /api/v2/buyer/discovery/discover-products
{
  "advertiserId": "12345",
  "discoveryId": "abc123-def456-ghi789",
  "refine": [
    { "scope": "request", "ask": "show me more video options" },
    { "scope": "product", "id": "product_123", "action": "more_like_this" },
    { "scope": "product", "id": "product_456", "action": "omit" },
    { "scope": "proposal", "id": "proposal_789", "action": "include", "ask": "increase budget allocation" }
  ],
  "groupLimit": 10,
  "productsPerGroup": 10,
  "debug": false
}
```

**Refine item fields:**
- `scope` (required): `"request"`, `"product"`, or `"proposal"`
- `ask` (required for request-scope, optional for product/proposal): Natural language direction
- `id` (required for product/proposal-scope): The product or proposal ID from the previous discovery response
- `action` (required for product-scope): `"include"`, `"omit"`, or `"more_like_this"`
- `action` (required for proposal-scope): `"include"`, `"omit"`, or `"finalize"`

**Pagination parameters:** Same as browse (`groupLimit`, `productsPerGroup`, `groupOffset`, `productOffset`).

**Response:** Same structure as discover-products, plus an optional `refinementApplied` array showing what the sales agents did with each refinement instruction:
```json
{
  "discoveryId": "abc123-def456-ghi789",
  "productGroups": [ ... ],
  "totalGroups": 10,
  "hasMoreGroups": false,
  "summary": { ... },
  "refinementApplied": [
    { "scope": "request", "status": "applied", "notes": "Added video content filter" },
    { "scope": "product", "id": "product_456", "status": "applied", "notes": "Removed from results" }
  ]
}
```

**Workflow:** Call discover-products with refine, present updated results, let the user iterate or proceed to select products. Each refine call replaces the cached results, so subsequent browse/pagination operates on the refined set.

---

### Event Sources

Event data pipelines (website pixels, mobile SDKs, warehouse syncs, CRM feeds, etc.) registered at the advertiser level. Referenced by `eventSourceId` in campaign optimization goals.

Event sources are managed through sync (bulk upsert following the ADCP spec). There is no per-source REST mutation.

#### Sync Event Sources

Sync is the preferred way to manage event sources — it uses upsert semantics (creates or updates as needed).

**Operation:** `sync_event_sources`
```http
POST /api/v2/buyer/advertisers/26/event-sources/sync
{
  "account": { "account_id": "26" },
  "event_sources": [
    {
      "event_source_id": "website_pixel",
      "name": "Website Pixel",
      "event_types": ["purchase", "add_to_cart"],
      "allowed_domains": ["shop.example.com"]
    },
    {
      "event_source_id": "mobile_sdk",
      "name": "Mobile App SDK",
      "event_types": ["app_install", "purchase"]
    }
  ],
  "delete_missing": false
}
```

**URL path:** `/advertisers/{advertiserId}/event-sources/sync` — the `{advertiserId}` is the numeric advertiser ID (e.g. `26`). Also include it in the request body as `account.account_id`.

**`account` (required in body):**
- `account_id` (string): The advertiser ID — same value as the path `{advertiserId}` (e.g. `"26"`).

**`event_sources` array (required, 1–50 items). Each object:**
- `event_source_id` (string, required): Buyer-assigned identifier, referenced by optimization goals
- `name` (string, optional): Human-readable label
- `event_types` (array, optional): ADCP/IAB ECAPI event types this source handles. When omitted, accepts all types. Values: `page_view`, `view_content`, `select_content`, `select_item`, `search`, `share`, `add_to_cart`, `remove_from_cart`, `viewed_cart`, `add_to_wishlist`, `initiate_checkout`, `add_payment_info`, `purchase`, `refund`, `lead`, `qualify_lead`, `close_convert_lead`, `disqualify_lead`, `complete_registration`, `subscribe`, `follow`, `content_view`, `watch_milestone`, `start_trial`, `app_install`, `app_launch`, `contact`, `schedule`, `donate`, `submit_application`, `custom`
- `allowed_domains` (array, optional): Domains authorized to send events
- `integration_platform` (string, optional): Scope3 extension for the source system, such as Hightouch, CRM, MMP, or server API
- `mapping` (object, optional): Scope3 extension that records source fields used to produce `log_event` payloads. Useful keys include `eventIdField`, `eventTypeField`, `eventTimeField`, `userMatchFields`, `valueField`, `currencyField`, `orderIdField`, `contentIdsField`, `consentField`, `dedupeStrategy`, and `notes`
- `test_event_code` (string, optional): Default test code to use while validating the integration

**Other optional fields:**
- `delete_missing` (boolean): Archive event sources not included in this request (default: false)

**Response (200):**
```json
{
  "data": {
    "event_sources": [
      { "event_source_id": "website_pixel", "action": "created" },
      { "event_source_id": "mobile_sdk", "action": "updated" }
    ]
  }
}
```

Actions: `created`, `updated`, `unchanged`, `failed`, `deleted`

#### List Event Sources

**Operation:** `list_event_sources`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/event-sources
```
**As operation:**
```json
{ "operation": "list_event_sources", "pathParams": { "advertiserId": "<id>" } }
```

**Query Parameters:**
- `take` / `skip` (optional): Pagination

**Response fields to use:**
- `eventSources[].eventSourceId`, `name`, `eventTypes`, `allowedDomains`
- `eventSources[].integrationPlatform` — Scope3 metadata such as `Hightouch`, `CRM`, `MMP`, or `server API` when it has been synced/backfilled
- `eventSources[].mapping` — source-to-`log_event` field contract, including event ID/type/time, user match fields, value/currency/order/content fields, consent field, dedupe strategy, and notes
- `eventSources[].testEventCode` — default test code when present
- `eventSources[].health` — `status`, latest accepted event timestamps/type, and latest source error when known

#### Create / Update / Delete Event Sources

There is no per-source REST mutation. Use **`POST .../event-sources/sync`** (documented above) — it accepts an array of event sources and treats each as upsert-by-`event_source_id`. Pass `delete_missing: true` to archive sources omitted from the request.

---

### Event Summary

Get hourly-aggregated event counts for an advertiser. Use this to verify that events (impressions, clicks, conversions, etc.) are being ingested before setting up optimization goals.

**Important:** Event data is aggregated hourly. Newly reported events may take up to 1 hour to appear in this summary. If the user has just started reporting events, let them know to wait before checking.

#### Get Event Summary

**Operation:** `get_event_summary`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/events/summary
```
**As operation:**
```json
{ "operation": "get_event_summary", "pathParams": { "advertiserId": "<id>" } }
```

**Query Parameters:**
- `eventType` (string, optional): Filter by event type — one of `impression`, `click`, `conversion`, `measurement`, `mmp`. When omitted, returns all types.
- `startHour` (string, optional): Start of query range (inclusive), hour-aligned ISO 8601 (e.g. `2026-03-27T14:00:00Z`). Defaults to start of last completed UTC hour.
- `endHour` (string, optional): End of query range (exclusive), hour-aligned ISO 8601. Defaults to end of last completed UTC hour.

**Response (200):**
```json
{
  "data": {
    "periodStart": "2026-03-27T14:00:00.000Z",
    "periodEnd": "2026-03-27T15:00:00.000Z",
    "entries": [
      {
        "eventHour": "2026-03-27T14:00:00.000Z",
        "eventType": "impression",
        "eventCount": 1500
      },
      {
        "eventHour": "2026-03-27T14:00:00.000Z",
        "eventType": "conversion",
        "eventCount": 25
      }
    ],
    "totalEventCount": 1525
  }
}
```

The response includes both advertiser-specific events and customer-level shared events (events not tied to a specific advertiser but shared across all advertisers under the same customer).

---

### Log Event

Log conversion and marketing events for attribution. Events are forwarded to the tracking endpoint (CAPI). Requires an event source registered via sync_event_sources.

#### Log Events

**Operation:** `log_event`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/log-event
{
  "event_source_id": "website_pixel",
  "events": [
    {
      "event_id": "txn_abc123",
      "event_type": "purchase",
      "event_time": "2026-03-15T14:30:00-05:00",
      "action_source": "website",
      "event_source_url": "https://example.com/checkout",
      "user_match": {
        "hashed_email": "a1b2c3d4e5f6...",
        "click_id": "abc123",
        "click_id_type": "gclid"
      },
      "custom_data": {
        "value": 99.99,
        "currency": "USD",
        "order_id": "order_456",
        "content_ids": ["prod_789"],
        "num_items": 2
      }
    }
  ],
  "test_event_code": "TEST123"
}
```

**Request body:**
- `event_source_id` (string, required): Event source registered via sync_event_sources
- `events` (array, required, 1–10,000 items): Events to log
- `test_event_code` (string, optional): Test code for validation without affecting production data

**Each event object:**
- `event_id` (string, required): Unique identifier for deduplication
- `event_type` (enum, required): ADCP/IAB ECAPI event type. Values: `page_view`, `view_content`, `select_content`, `select_item`, `search`, `share`, `add_to_cart`, `remove_from_cart`, `viewed_cart`, `add_to_wishlist`, `initiate_checkout`, `add_payment_info`, `purchase`, `refund`, `lead`, `qualify_lead`, `close_convert_lead`, `disqualify_lead`, `complete_registration`, `subscribe`, `follow`, `content_view`, `watch_milestone`, `start_trial`, `app_install`, `app_launch`, `contact`, `schedule`, `donate`, `submit_application`, `custom`
- `event_time` (string, required): When the event occurred (ISO 8601 with timezone)
- `action_source` (enum, optional): `website`, `app`, `in_store`, `phone_call`, `system_generated`, `other`
- `event_source_url` (string, optional): URL where the event occurred
- `custom_event_name` (string, optional): Name for custom events (when event_type is `custom`)
- `user_match` (object, optional): User identity for attribution matching
  - `uids` (array): Universal ID values (`{type, value}` — rampid, id5, uid2, euid, pairid, maid)
  - `hashed_email`: SHA-256 of lowercase trimmed email
  - `hashed_phone`: SHA-256 of E.164 phone number
  - `click_id` / `click_id_type`: Platform click identifier
  - `client_ip` / `client_user_agent`: For probabilistic matching
- `custom_data` (object, optional): Event-specific data
  - `value`: Monetary value
  - `currency`: ISO 4217 code (e.g. `USD`)
  - `order_id`: Transaction identifier
  - `content_ids`: Product identifiers
  - `content_type`: Category (product, service, etc.)
  - `num_items`: Item count
  - `contents`: Array of `{id, quantity, price, brand}`

**Response (200):**
```json
{
  "data": {
    "events_received": 1,
    "events_processed": 1,
    "partial_failures": [],
    "warnings": [],
    "match_quality": 0.85
  }
}
```

---

### Measurement Data

Sync advertiser performance measurement data as an alternative to CAPI. Accepts time-series metric data over date ranges keyed by campaign, media buy, package, and/or creative. Uses upsert semantics — re-submitting the same data is safe and idempotent.

#### Sync Measurement Data

**Operation:** `sync_measurement_data`
```http
POST /api/v2/buyer/advertisers/26/measurement-data/sync
{
  "measurements": [
    {
      "start_time": "2026-03-01T00:00:00-05:00",
      "end_time": "2026-03-07T23:59:59-05:00",
      "metric_id": "incremental_revenue",
      "metric_value": 8450.75,
      "unit": "currency",
      "currency": "USD",
      "campaign_id": "camp_456"
    }
  ]
}
```

**URL path:** `/advertisers/{advertiserId}/measurement-data/sync` — the `{advertiserId}` is the numeric advertiser ID (e.g. `26`).

**`measurements` array (required, 1–1000 items). Each object:**
- `start_time` (string, required): Start of the measurement period (ISO 8601 with timezone)
- `end_time` (string, required): End of the measurement period (ISO 8601 with timezone, must be after start_time)
- `metric_id` (enum, required): `revenue`, `incremental_revenue`, `conversions`, `incremental_conversions`, `page_view_count`, `add_to_cart_count`, `purchase_count`, `ltv_1d`, `ltv_7d`, `ltv_30d`
- `metric_value` (number, required): Measured value for this metric
- `unit` (enum, required): `currency`, `count`, `ratio`, `percentage`
- `currency` (string, conditional): 3-letter uppercase ISO 4217 code (e.g. `"USD"`) — required when `unit` is `"currency"`
- `advertiser_id` (string, optional): Advertiser identifier
- `campaign_id` (string, optional): Campaign identifier
- `media_buy_id` (string, optional): Media buy identifier
- `package_id` (string, optional): Package identifier
- `creative_id` (string, optional): Creative identifier
- `source` (string, optional): Source of the measurement data
- `source_platform` (string, optional): Platform the data originates from
- `external_row_id` (string, optional): External row identifier for idempotency

**Constraint:** At least one of `advertiser_id`, `campaign_id`, `media_buy_id`, `package_id`, or `creative_id` must be provided.

**Response (200):**
```json
{
  "data": {
    "measurements": [
      { "index": 0, "action": "created" }
    ]
  }
}
```

Actions: `created`, `updated`, `unchanged`, `failed`

---

### Measurement Engine

Configure measurement sources, upload measurement and context records, and check freshness of incoming outcome data.

#### List Measurement Sources

**Operation:** `list_measurement_sources`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/measurement-sources
```
**As operation:**
```json
{ "operation": "list_measurement_sources", "pathParams": { "advertiserId": "<id>" } }
```

Optional query params: `outcomeType`, `status`, `take`, `skip`

#### Create Measurement Source

**Operation:** `create_measurement_source`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/measurement-sources
{
  "sourceKey": "mmm_sales",
  "name": "MMM Sales Lift",
  "outcomeType": "sales_volume",
  "granularity": "dma_week",
  "cadence": "weekly",
  "provider": "analytic-partner"
}
```
**As operation:**
```json
{ "operation": "create_measurement_source", "pathParams": { "advertiserId": "<id>" }, "body": { "sourceKey": "mmm_sales", "name": "MMM Sales Lift", "outcomeType": "sales_volume", "granularity": "dma_week", "cadence": "weekly", "provider": "analytic-partner" } }
```

Required: `sourceKey`, `name`, `outcomeType`, `granularity`, `cadence` (continuous, daily, weekly, biweekly, monthly, quarterly), `provider`

Optional: `lagWeeks` (default 1), `ingestionMethod`, `attributionConfig`, `signalWeight` (0-1, default 1.0), `status` (pending, active, paused), `notes`

#### Upload Measurement Records

**Operation:** `upload_measurement_records`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/measurement-records
{
  "records": [
    {
      "outcomeType": "sales_volume",
      "geo": "Atlanta",
      "timeWindowStart": "2026-01-06",
      "timeWindowEnd": "2026-01-12",
      "value": 1250,
      "source": "mmm_sales"
    }
  ]
}
```
**As operation:**
```json
{ "operation": "upload_measurement_records", "pathParams": { "advertiserId": "<id>" }, "body": { "records": [{ "outcomeType": "sales_volume", "geo": "Atlanta", "timeWindowStart": "2026-01-06", "timeWindowEnd": "2026-01-12", "value": 1250, "source": "mmm_sales" }] } }
```

Each record requires: `outcomeType`, `geo`, `timeWindowStart` (YYYY-MM-DD), `timeWindowEnd` (YYYY-MM-DD), `value`, `source`

Optional per record: `baselineValue`, `confidenceInterval`, `lagDays`

Accepts 1-5000 records per request. Uses upsert semantics (safe to re-submit).

#### List Measurement Records

**Operation:** `list_measurement_records`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/measurement-records
```

Optional query params: `outcomeType`, `geo`

#### Upload Context Records

**Operation:** `upload_context_records`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/context-records
{
  "records": [
    {
      "geo": "Atlanta",
      "timeWindowStart": "2026-01-06",
      "timeWindowEnd": "2026-01-12",
      "promoActive": true,
      "promoType": "BOGO"
    }
  ]
}
```

Each record requires: `geo`, `timeWindowStart`, `timeWindowEnd`

Optional: `promoActive` (default false), `promoType`, `temperatureAvg`, `competitorActivity`, `seasonalityIndex`, `flightStatus` (active, dark, pre_flight, post_flight)

#### Get Measurement Freshness

**Operation:** `get_measurement_freshness`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/measurement-freshness?flightStart=2026-01-06&geos=Atlanta,Richmond
```

Required query params: `flightStart` (YYYY-MM-DD), `geos` (comma-separated)

Optional: `flightEnd` (YYYY-MM-DD)

Returns per-source, per-geo freshness windows showing which time periods have data and which are missing.

---

### Test Cohorts

For A/B testing campaign variations.

#### List Test Cohorts

**Operation:** `list_test_cohorts`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/test-cohorts
```
**As operation:**
```json
{ "operation": "list_test_cohorts", "pathParams": { "advertiserId": "<id>" } }
```

#### Create Test Cohort

**Operation:** `create_test_cohort`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/test-cohorts
{
  "name": "Q1 Creative Test",
  "description": "Testing new vs old creatives",
  "splitPercentage": 50
}
```
**As operation:**
```json
{ "operation": "create_test_cohort", "pathParams": { "advertiserId": "<id>" }, "body": { "name": "Q1 Creative Test", "description": "Testing new vs old creatives", "splitPercentage": 50 } }
```

---

### Creatives (Advertiser-Owned or Campaign-Attached)

Creatives can be owned by an advertiser without belonging to any campaign (advertiser library), or attached to one or more campaigns. Campaign attachment is a many-to-many association — one creative can serve multiple campaigns.

**Dashboard URL**: Call `GET /api/v2/buyer/creative-dashboard-url?advertiserId={advertiserId}&campaignId={campaignId}` to get a fully-resolved creative dashboard URL. Add `&manifestId={manifestId}` to deep-link directly to a specific creative manifest. The response contains a single `url` field — use it directly. Never construct URLs manually. The URL varies by environment and includes the customer ID automatically.

---

#### Bring Your Own Creative (in-chat creative-intent prompts)

When the buyer **brings or uploads a finished creative they already have** — they attach an image/video/tag, or say things like "add this as a creative", "I have creative for {advertiser}", "add this creative to a campaign", or "attach these" — your FIRST action is the creative-intent card. Do **NOT** call `get_my_campaigns`, `get_my_advertisers`, or `list_campaigns` to enumerate campaigns and then ask in prose — that is the wrong move; the `get_creative_intent` card IS the campaign picker and lists the campaigns for you. Do NOT jump to `start_creative_session` either (that flow is for *generating* new creative from a brief, not for placing creative the buyer already has). Route to the Buyer Creative prompts:

- **No campaign chosen yet** (the buyer named an advertiser but not a campaign): call `buyer_api_call` with `operation: "get_creative_intent"` and the `advertiserId`. It returns the in-chat "which campaign are these for?" card — recent campaigns to pick from and a "save to {advertiser} for now" option — plus a referenceable saved-as name. The card owns the commit: choosing a campaign calls `attach_creatives_to_campaign`, while choosing a reusable role calls `save_creatives_to_library`, and the resulting confirmation replaces the picker in place. Let the card collect and complete the choice; do not list campaigns yourself, replay its choice, or expect a synthetic follow-up user message. If the buyer needs a new campaign, the card directs them to create it through the Campaigns task or conversation and then reopen the saved creative; it does not pretend to create one.
- **Campaign chosen (existing or just-created) AND the buyer brought creative**: call `buyer_api_call` with `operation: "attach_creatives_to_campaign"`, the `campaignId`, and a `body.assets` array — one entry per brought attachment, each `{ "data_url": "murph-attachment://N" }` using the current turn's placeholder(s) (add `"name"` with the filename when you know it). This **actually attaches** the creatives to the campaign and returns the "here's what I added to {campaign}" card (creatives + format coverage). This is the operation that makes the attach real — do not just show the card.
- **Re-showing the card without attaching** (e.g. the buyer asks "show me {campaign}'s creatives again"): call `operation: "get_creative_confirmation"` with the `campaignId`. This is **read-only** — it lists what's already on the campaign and attaches nothing. Never use it as the way to attach brought creative; use `attach_creatives_to_campaign` for that.
- **Finding a creative brought or uploaded on an earlier turn, or on the web platform** (e.g. "attach the creative I uploaded earlier", "use the one from the library"): call `operation: "list_advertiser_creatives"` with the `advertiserId`. It returns every creative saved on that advertiser — evergreen/reference creatives promoted to the library ("the shelf") AND flight-specific creatives not yet attached to a campaign, so a web-uploaded creative always shows up here even before it's promoted. Pass `queryParams.promoted=true` only when the buyer explicitly wants just the reusable library, not a specific brought-but-unattached upload.
- Only tell the buyer a creative was attached if the card actually lists it. If the card is empty, the attach did not happen — say so and retry `attach_creatives_to_campaign` rather than claiming success.
- If any of these operations returns `NOT_FOUND`, the requested creative or campaign is unavailable — do not describe Buyer Creative as feature-gated.

When the creative-confirmation card shows placement coverage, its placement picker owns mapping edits. The buyer stages any number of row changes and **Save** commits the complete batch once through `remap_creative_placements`; removal also commits directly and refreshes the card. Do not ask the buyer to restate row choices, replay them as `remap_creative_placement` calls, or manufacture a follow-up chat turn. Rows without enough context explain what is missing without presenting an action that cannot finish.

Bringing an existing/finished creative → `get_creative_intent` (no campaign yet) / `attach_creatives_to_campaign` (campaign chosen) / `get_creative_confirmation` (read-only re-show) / `list_advertiser_creatives` (find a creative brought earlier or uploaded on the web). Generating new creative from a brief → creative session (below).

---

#### Agent-Native Creative Build Flow

For provider setup and funding questions, retrieve the public
[Generative creative guide](https://docs.interchange.io/v2/buyer/creatives/generative-creative).
Clients that also expose V3 engine discovery should follow the
[Creative Engines guide](https://docs.interchange.io/v2/setup/v3/creative-engines)
and the V3 buyer guide for setup, then use the session tools below where
offered. Keep the selected engine, connection and advertiser account explicit;
never request provider keys in chat or silently change funding after a failure.
Use documented prices and returned estimates, never a model's remembered rate.
Resume an existing task after a timeout before considering another paid build.

The MCP interface can create and manage campaign-scoped creative manifests. Prefer creative sessions when the buyer asks to generate, iterate, upload, attach, preview, or refine creatives. Creative sessions are designed for the real human workflow: draft gallery → pick a direction → natural-language refinement → evaluator checks → final campaign creative.

1. Discover the campaign creative formats/templates with `list_creative_templates`.
2. Handle references before the session. If the buyer's request already signals references — explicitly ("use references"), or by naming a catalog / past creative / brand asset / "like the {campaign} campaign" — open the reference picker directly. If the request gives NO reference signal, ask once, in plain text, whether to ground on references ("want to ground this on references from your library, or jump straight to concepts?"): on yes, open the picker; on no, skip straight to the concept count. Open the picker with `get_creative_references` (`pathParams.campaignId` — its advertiser is resolved server-side) before calling `start_creative_session`. The picker renders as the `reference-picker` view of the `creativeMcpui` artifact (`kind: 'creative-references'`) and shows "sourced materials" (role=reference library items, any media type) and "past creatives" (every creative the advertiser has, filterable by campaign). Saving or skipping writes the resolved asset/catalog choice through the host's non-prompting model-context channel; it does not synthesize a user turn. Use that recorded choice on the buyer's next turn and do not reopen the picker unless they ask to change it. An advertiser with no references yet is NOT a text fallback: the picker still opens in an empty state that explains how to add durable references and offers a continue-without-references action. Surface it and let the card carry the choice; do not tell the buyer the library is empty or offer attach/skip as a text option yourself. If it returns `FEATURE_NOT_ENABLED`, the buyer-generative-creative surface is off for this account — say nothing about it and fall back to `start_creative_session` directly, same as when it's on.
3. Start a reviewable draft gallery with `start_creative_session`.
4. Use `refine_creative_session_variant` when the buyer says "make the book more prominent", "more like this one", "add people in the background", or similar.
5. Use `evaluate_creative_session` for draft advisory checks or final blocking checks.
6. Use `finalize_creative_session` after the buyer approves a direction; it saves the selected/refined leaf as a normal campaign creative manifest.
7. Do not use `preview_creative` for draft session variants. Creative-session responses already include `variants[].preview.url`, `format_renders[]`, and the MCPUI gallery; use `get_creative_session` if you need to refresh them.
8. Use lower-level `build_creative_variants`, `create_creative_manifest`, `preview_creative`, and `duplicate_creative` only when the user asks for raw adapter/debug behavior or when a creative session is not appropriate.

For real product/package/logo/book-cover assets supplied by the user, preserve the exact asset:
- Before asking for logos or brand assets, inspect the advertiser's linked brand data (`list_advertisers` with `includeBrand=true` or `get_advertiser`). The resolved brand identity may already contain logos, colors, tone, taglines, and catalog assets.
- If the linked advertiser brand conflicts with the user's explicit product, attached asset, catalog item, or stated website/domain, the user-provided product/site wins. Surface the mismatch plainly and do not use unrelated brand-library logos, industries, colors, or descriptions in the creative brief.
- Put the primary product/package/book image in `source_asset`. Put logos and other additional brand assets in `assets[]` with `role`, `source`, `locked_asset`, `can_transform`, rights, and preservation notes.
- In generation briefs: put the primary product image in `request.creative_brief.product.product_shot.url` and set `locked_asset: true`, `asset_role: "product"`, `can_transform: false`, plus specific `preservation_notes`.
- If an image generator cannot ingest exact logos, keep the logo in `assets[]`; the creative-session renderer/finalizer can composite the exact locked asset instead of asking the buyer to use an external design tool.
- In saved manifests: put the asset in `linked_assets[]` with `locked_asset: true`, `asset_role: "product"`, `can_transform: false`, and the same preservation notes.
- Do not redraw, reinterpret, or replace locked product assets. Only scene, lighting, composition, copy, and background may vary.

Creative asset lifecycle:
- **Source assets** are buyer inputs: uploaded files, DAM URLs, product-catalog images, brand-library assets, logos, audio, video, copy, or references. Track them in `source_asset` / `asset_store.assets[]` with `source`, `role`, `locked_asset`, `can_transform`, `rights.status`, dimensions/checksum when known, and optional crop/mask metadata. A source asset is not automatically the final deliverable.
- **Draft variants** are generated or composited directions returned in `variants[]`. They have preview URLs, evaluator checks, quality/status, and lineage (`parent_build_variant_id` after refinement). They are for human exploration and should remain editable until the buyer approves one.
- **Final products** are campaign creative manifests. `finalize_creative_session` turns the selected/refined leaf into a normal campaign creative with `creative_id`, `format_id`, linked assets, text assets, preview, and sync status.
- Keep provenance separate from rights. `source: "upload"` or `source: "dam"` explains where an asset came from; `rights.status` explains whether it is cleared. `unknown` rights are a non-blocking final review warning. `restricted` or `expired` rights block finalization.
- If a product photo includes studio background/whitespace, prefer a DAM/catalog transparent cutout, mask, or subject bounds. `render_crop` is an acceptable preservation-safe fallback, but it is not as clean as a real cutout.
- To decide whether a finished creative can launch, attach or create the manifest and deliver it through the launch path. The launch path runs local manifest/format checks and then sends creatives through `sync_creatives` or inline creative payloads based on the seller's declared capabilities. `sync_creatives` with `dry_run: true` is the appropriate non-mutating check for the submission path when the seller supports it.

Seller-specific creative coverage:
- Required seller formats come from the campaign's selected products. Use `list_creative_templates` and the campaign's `creativeFormats.required/covered/missing` to know what still needs coverage.
- A single final direction can need multiple seller-specific executions. Example: one final creative direction may require square image, portrait story, audio, video, and/or HTML manifests depending on the products selected.
- `format_renders[]` previews the approved leaf in canonical publisher/format renderers. It is a review surface, not a substitute for saving the right campaign creative manifest for each required format.
- After finalizing, re-fetch the campaign or creative coverage. If `creativeFormats.missing` is not empty, generate/refine additional manifests for those missing formats while preserving the shared source-asset lineage and direction.

Performance loop:
- Creative iteration stops at a saved manifest unless the campaign is launched, measured, and compared.
- For performance campaigns, configure `performanceConfig`, execute once required creative formats are covered, collect reporting and measurement data, compare performance by campaign/media buy/package/creative where available, then start a new creative session or duplicate/refine a winner.
- Do not claim a creative "won" unless that conclusion comes from reporting or measurement data. The draft/final evaluators check readiness and brand/format risk; they do not prove market performance.

Evaluator strategy:
- Treat evaluators as selected checks, not one universal score.
- Core readiness evaluators answer whether previews/manifests are renderable and structurally valid.
- Asset evaluators answer whether locked source assets, provenance, rights, crop/mask/cutout, and fidelity are acceptable.
- Brand/brief evaluators answer whether the concept fits campaign and brand guidance.
- Format/seller evaluators answer whether the final execution satisfies the selected seller or publisher format.
- Policy/safety evaluators answer whether there are legal, claims, regulated-category, or platform-policy risks.
- Performance evaluators answer whether the creative worked after launch; they require reporting or measurement data.
- Draft evaluator warnings should guide refinement. Final hard/blocking failures prevent finalization. Non-blocking final warnings should be summarized as review notes, not hidden and not called clean passes.

**Note:** Template detection and format matching happen automatically when assets are uploaded via the UI. The response includes `auto_detected_template` with the detected `template_id` and detection `method`. There is no need to call a separate templates endpoint.

##### 1. List Creative Manifests

**Operation:** `list_creatives`
```
GET /api/v2/buyer/campaigns/{campaignId}/creatives
```
**Query parameters:**
- `quality` (optional): Filter by quality level
- `search` (optional): Case-insensitive name search
- `take` (optional): Page size, default 50
- `skip` (optional): Pagination offset

**Response:** `{ "manifests": [...], "total": <number> }`. Each manifest is the **summary** shape — `creative_id`, `campaign_id`, `name`, `template_id`, `format_id`, `brand_domain`, `preview_url`, `asset_count`, `sync_status` (`{ synced, agent_count }`), `created_at`, `updated_at`. `assets[]`, `html_processing`, `creative_manifest`, `tracking`, `frequencyCaps[]`, `message`, `target_format_ids[]`, `format_previews[]`, and `auto_detected_template` are NOT on the summary — call `get_creative` for the full manifest.

##### 2. Get Creative Manifest

**Operation:** `get_creative`
```
GET /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId}
```
Optional query: `?preview=true`

**Response:** Single manifest with all fields including `preview_url`, `format_previews[]`, `auto_detected_template`, `html_processing` (macros injected, unresolved refs), and assets array.

##### 3. Update Creative Manifest (metadata only — NO file uploads)

**Operation:** `update_creative`
```
PUT /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId}
```
**Body (all fields optional):**
- `name` (string): Manifest name
- `message` (string): Creative brief
- `tag` (string): Tag
- `quality` (string): Quality level
- `format_id` (object): `{ agent_url: string, id: string }` — the **legacy** (v1 named-format) format reference. The canonical 3.1 alternative is `format_kind` (a bare enum like `image`, `video_vast`, `video_hosted` — **no `agent_url`**). They are mutually exclusive; never put one inside the other. For time-based formats, video/audio duration is a **canonical param** (`params.duration_ms_exact` / `duration_ms_range`), not a field on `format_id`. Note `video_standard`/`display_300x250_html` are legacy ids, not `format_kind` values. See `docs/creatives/formats-canonical-vs-legacy.md`.
- `template_id` (string): ADCP format template ID (e.g. `"display_300x250_html"`, `"video_standard"`, `"vendor_dcm_tag"`)
- `click_url` (string): Canonical click-through URL. Use this to update the landing page that powers `click_tracker_url`.
- `url_asset` (object): `{ url: string, url_type: string }` — add a URL-based asset
- `url_assets` (array): Slot-assigned URL assets. Use only when you need a specific format slot; prefer `click_url` for landing page/click-through changes.
- `delete_asset_ids` (string[]): Asset IDs to soft-delete
- `reclassify_assets` (array): `[{ asset_id: string, asset_type: string }]` — change asset type
- `frequencyCaps` (array): Buyer-side frequency caps scoped to this creative. Array of `{ max_impressions, window: { interval, unit } }`. **Full-replace semantics**: omit to leave existing caps unchanged, pass `[]` to clear, pass the full array to replace. See the Frequency Caps section below.

**Note:** `update_creative` is metadata-only. For an existing small-file workflow, the Interchange UI accepts multipart uploads up to its published 50 MB per-file limit. Do not direct a large MP4 there. For a local JPEG, PNG, or enabled large MP4, select only the buyer-owned advertiser and open this connector's `upload_creative_asset`; it is the same Task and ingress exposed on V3. The browser sends bytes directly to private storage. File bytes never enter model or chat context; the signed PUT URL is returned only to the embedded Task app and is excluded from model-visible results, chat/activity, logs, and persistence. After finalization, use V3 `save_creative`: `sourceAssetRef` for one image, or `sourceAssets` for a canonical multi-slot image/video composition. A video composition stops at `destination_required`; upload or composition does not prove destination delivery, so require exact provider readback. The provider-bound V2 adoption operation is never a silent fallback.

##### 4. Bulk Update Creative Manifests

**Operation:** `bulk_update_creatives`
```
POST /api/v2/buyer/campaigns/{campaignId}/creatives/bulk-update
```

Use this for requests like "bulk update the click-through URL for all April creatives" or "change all image creatives matching this folder/name/date range." Do **not** loop `update_creative` unless the buyer explicitly selected one creative.

**Body:**
```json
{
  "selection": {
    "creative_ids": ["creative_1"],
    "name_contains": "2026april",
    "created_after": "2026-04-01T00:00:00.000Z",
    "created_before": "2026-05-01T00:00:00.000Z",
    "updated_after": "2026-04-01T00:00:00.000Z",
    "updated_before": "2026-05-01T00:00:00.000Z",
    "template_id": "display_300x250_html",
    "format_kind": "image",
    "asset_type": "IMAGE"
  },
  "update": {
    "click_url": "https://www.chime.com/join/campaigns/debit/?ad=scope3_debit",
    "format_id": {
      "agent_url": "https://api.openads.ai/adcp/creative",
      "id": "display_300x250_nongenerative"
    },
    "target_format_ids": [
      {
        "agent_url": "https://api.openads.ai/adcp/creative",
        "id": "display_300x250_nongenerative"
      }
    ]
  },
  "dry_run": true,
  "exclude_creative_ids": [],
  "limit": 100
}
```

At least one selector and at least one `update` field are required. The `update` fields are: `click_url` (landing page), `format_id` (set the primary format on every matched creative, e.g. to retype creatives to a sales agent's required format like `display_300x250_nongenerative`), and `target_format_ids` (set the additional formats each matched creative covers). Set these per size: only match creatives whose dimensions fit the target format. `dry_run` defaults to `true`; first call it in dry-run mode and show the matched set (`creative_id`, name, asset count, status) for buyer confirmation. After the buyer confirms, call the same operation with `dry_run: false`, preserving any excludes they requested.

**Response:** `{ dry_run, matched_count, updated_count, updates, creatives: [...] }`. In dry-run, each creative has `status: "matched"`. In execution, each creative has `status: "updated"` or `status: "failed"` with an `error`.

##### 5. Sync Creatives to Storefronts

**Operation:** `sync_creatives`
```
POST /api/v2/buyer/campaigns/{campaignId}/creatives/sync
```

**As operation:**
```json
{
  "operation": "sync_creatives",
  "pathParams": { "campaignId": "campaign_abc123" }
}
```

Pushes all campaign creatives (with full name and asset payloads) to every connected storefront's review queue. Use when the buyer asks to re-send or re-sync creatives after an update, or when a storefront reports it never received the creative assets. No request body required.

**Response:** `{ synced: [{ storefront_id, adcp_media_buy_id, status: "synced" | "error", message? }] }`

##### 6. Delete Creative Manifest

**Operation:** `delete_creative`
```
DELETE /api/v2/buyer/campaigns/{campaignId}/creatives/{creativeId}
```
Detaches the manifest from this campaign and unlinks it from that campaign's
draft buys. It does NOT archive the manifest and does NOT set `archived_at`, so
the creative and its name survive — use `archive_creative` for permanent
removal at the advertiser level.

**Response:** `204 No Content`

##### Adopt a Managed Video Source

**Operation:** `adopt_managed_video_source`
```
POST /api/v2/buyer/creatives/adopt-managed-video-source
```
Turns a video the buyer already uploaded — one that reached `promoted` — into a
creative, without the UI. Body: `source_uid`, `advertiser_id`, `storefront_id`,
`name`, and optional `campaign_id`. The hosted HTTPS URL is issued server-side;
the buyer never supplies one.

**Response:** `201` with `creative_id`, `source_uid`, `publication_uid`,
`delivery_url`, `name`, optional `campaign_id`, and `created` — true only when
this call made the creative. A `201` that named a `campaign_id` means the
creative is attached to that campaign — **NOT that sellers have accepted it into
their media-buy packages, which sync in the background** — so do not report the
creative as live in a media buy on the strength of this response. Attachment is
idempotent, so a retry after a failed attachment is safe and needs no follow-up
call.

**Refusals, and what to tell the buyer:**

- Reusing a `name` another *advertiser-scoped* creative of that advertiser holds → `CONFLICT`. Adoption always creates advertiser-scoped and dedupes within that set. A creative made directly under a campaign is outside the set while it stays attached — but `delete_creative` NULLs its campaign, which moves it IN and makes it start reserving that name. Detaching frees nothing; it claims a name.
  Retry with a different name. Or free the conflicting name on the older
  creative: attaching it to a campaign does NOT free its name, but archiving it
  does, and so does renaming it with `update_creative`. Prefer the rename when
  that older creative is already attached to a campaign — it frees the name
  without removing something live in a media buy. `update_creative` is
  campaign-scoped (`PUT /campaigns/{campaignId}/creatives/{creativeId}`), so a
  creative attached to no campaign cannot be renamed — free its name with
  `archive_creative` (`DELETE /advertisers/{advertiserId}/creatives/{creativeId}`)
  instead. Do NOT reach for `delete_creative`: it only detaches the creative
  from a campaign and leaves the name held.
- Re-adopting the same source under a *different* name → `CONFLICT`. Adoption
  never renames an existing creative; use the name it already has.
- Database fences back these refusals, so they hold under concurrency: one live
  creative per `sourceUid`, and advertiser-scoped names serialized across both
  adoption and ordinary creative creation. Issuing creations concurrently is
  safe.
- Every `CONFLICT` below carries a machine-readable `details.reason`. Branch on
  it, NOT on the message text: `adoption_in_progress` and
  `name_creation_in_progress` are the only retryable ones. `name_taken`,
  `name_creation_abandoned`, `adoption_abandoned`,
  `adoption_asset_unavailable`, `adoption_name_mismatch`,
  `source_delivery_revoked`, and `source_deletion_requested` must not be retried
  unchanged; follow the reason-specific action below.
- Another adoption of the same source is still completing, or the blocking
  adoption changed while this request was classifying it → `CONFLICT` on
  `sourceUid`, `details.reason = adoption_in_progress`. This is the only
  source-identity conflict in this group you should retry automatically: wait a
  moment and re-issue the same call. Do NOT archive anything in response to it. A separate
  `name_creation_in_progress` conflict is also retryable, as described above.
- An earlier adoption of this source left its creation marker uncleared and has
  been stuck long enough (about fifteen minutes) that nothing is still writing
  it. An asset row may already have landed, but the creative never finalized →
  `CONFLICT` on `sourceUid`, with
  `details.reason = adoption_abandoned`. Archive that creative and adopt
  again. Do NOT retry this one automatically, unlike the still-completing case
  above.
- Source already held by a creative, but adoption can no longer confirm that
  claim (its video asset was replaced or removed directly on that creative) →
  `CONFLICT` on `sourceUid`, with `details.reason = adoption_asset_unavailable`.
  Archive that creative and adopt again, or upload the video again.
- Retention window ended, or delivery was revoked with nothing live replacing
  it (either even if the source was never adopted) → `CONFLICT`/
  `INVALID_REQUEST` on `sourceUid`. Revocation carries
  `details.reason = source_delivery_revoked`. Fix for both: upload the video
  again and adopt the new source.
- Deletion requested for the source → the source leaves `promoted` for
  `deleting`, so adoption refuses it as a non-promoted source
  (`INVALID_REQUEST` on `sourceUid`, "only a promoted source can be adopted").
  Same fix — upload the video again. This also refuses a replay that was only
  trying to finish an interrupted attachment; do NOT retry it.
- Account not in the managed-video alpha → `CAPABILITY_NOT_SUPPORTED`.

##### Adopt a Managed Audio Source

**Operation:** `adopt_managed_audio_source`
```
POST /api/v2/buyer/creatives/adopt-managed-audio-source
```

For managed audio, retrieve [Adopt a managed audio
source](/v2/buyer/creatives/tasks/adopt-managed-audio-source) before discussing
or invoking direct adoption.

##### 6. List Creative Templates / Required Formats

**Operation:** `list_creative_templates`
```
GET /api/v2/buyer/campaigns/{campaignId}/creatives/templates
```
**As operation:**
```json
{
  "operation": "list_creative_templates",
  "pathParams": { "campaignId": "campaign_abc123" }
}
```

Use this before build/save when you need the target `format_id`, available `template_id`, or slot asset IDs.

##### 6. Start Creative Session

**Operation:** `start_creative_session`
```
POST /api/v2/buyer/campaigns/{campaignId}/creative-sessions
```

Starts a human-reviewable creative iteration session and returns an MCPUI gallery payload. The response includes stable `creative_session_id`, draft `variants[]`, evaluator badges, selected/refined variant IDs, and finalization state.

The session is the preferred contract for non-technical creative production. It carries:
- `asset_store`: source assets with provenance, role, lock, rights, and checksum metadata.
- `renderer_capabilities`: session review renderers available for the target creative format or modality.
- `evaluation[]`: staged checks. Draft checks are advisory; final hard/blocking failures prevent finalization.
- `format_renders[]`: publisher/format previews for the approved leaf.

**As operation:**
```json
{
  "operation": "start_creative_session",
  "pathParams": { "campaignId": "campaign_abc123" },
  "body": {
    "storefrontId": 99,
    "providerType": "openai",
    "brand": "The Shift",
    "objective": "<one-line campaign objective from the brief>",
    "prompt": "<what to generate, from the buyer's brief — never a stock scene>",
    "source_asset": {
      "label": "The Shift book cover",
      "url": "murph-attachment://0",
      "source": "upload",
      "role": "product",
      "locked_asset": true,
      "can_transform": false,
      "rights": { "status": "unknown" },
      "preservation_notes": "Preserve the book cover typography, portrait, proportions, and artwork exactly."
    },
    "assets": [
      {
        "label": "Primary brand logo",
        "url": "https://brand.example/logo.png",
        "source": "brand_library",
        "role": "logo",
        "locked_asset": true,
        "can_transform": false,
        "rights": { "status": "unknown" },
        "preservation_notes": "Preserve the logo exactly; do not redraw, recolor, re-letter, or restyle it."
      }
    ],
    "request": {
      "idempotency_key": "creative-run-123",
      "creative_brief": {
        "name": "<creative exploration name from the brief>",
        "prompt": "<creative concept summary derived from the buyer's brief>",
        "audience": "<target audience from the brief>",
        "objective": "Draft product-safe creative variants, then finalize one campaign-ready execution.",
        "product": {
          "name": "The Shift",
          "category": "coffee-table book",
          "product_shot": {
            "url": "murph-attachment://0",
            "locked_asset": true,
            "asset_role": "product",
            "can_transform": false,
            "preservation_notes": "Preserve the book cover typography, portrait, proportions, and artwork exactly."
          }
        }
      },
      "quality": "draft",
      "variant_axis": {
        "dimension": "theme",
        "values": [
          "<concept direction 1 from the brief>",
          "<concept direction 2 from the brief>",
          "<concept direction 3 from the brief>"
        ]
      }
    }
  }
}
```

##### 7. Refine Creative Session Variant

**Operation:** `refine_creative_session_variant`
```
POST /api/v2/buyer/campaigns/{campaignId}/creative-sessions/{sessionId}/variants/{variantId}/refine
```

**As operation:**
```json
{
  "operation": "refine_creative_session_variant",
  "pathParams": {
    "campaignId": "campaign_abc123",
    "sessionId": "cs_123",
    "variantId": "bv_123"
  },
  "body": {
    "feedback": "Make the book more prominent and add the uploaded logo exactly.",
    "assets": [
      {
        "label": "Uploaded logo",
        "url": "murph-attachment://0",
        "role": "logo",
        "locked_asset": true,
        "can_transform": false
      }
    ]
  }
}
```

##### 8. Evaluate Creative Session

**Operation:** `evaluate_creative_session`
```
POST /api/v2/buyer/campaigns/{campaignId}/creative-sessions/{sessionId}/evaluate
```

Use `stage: "draft"` for advisory checks while exploring. Use `stage: "final"` before final approval. Final-stage checks include `severity` and `blocking`; a hard/blocking failure means the creative needs another refinement before `finalize_creative_session`.

##### 9. Finalize Creative Session

**Operation:** `finalize_creative_session`
```
POST /api/v2/buyer/campaigns/{campaignId}/creative-sessions/{sessionId}/finalize
```

Saves the selected/refined leaf as a normal campaign creative manifest and returns the updated MCPUI session payload with `finalization.creative_id`. The service re-runs final checks first; hard/blocking failures return a validation error and do not create the campaign creative.

##### 10. Build Creative Variants

**Operation:** `build_creative_variants`
```
POST /api/v2/buyer/storefronts/{storefrontId}/creative/build
```
**Critical body shape:** the body has exactly two top-level fields: `providerType` and `request`. Do not send top-level `brief`, `assets`, `variants`, `quality`, `channel`, or `format`.

**As operation:**
```json
{
  "operation": "build_creative_variants",
  "pathParams": { "storefrontId": "99" },
  "body": {
    "providerType": "openai",
    "request": {
      "idempotency_key": "creative-run-123",
      "creative_brief": {
        "name": "<creative exploration name from the brief>",
        "prompt": "<creative concept summary derived from the buyer's brief>",
        "audience": "<target audience from the brief>",
        "objective": "Draft product-safe creative variants, then finalize one campaign-ready execution.",
        "product": {
          "name": "The Shift",
          "category": "coffee-table book",
          "product_shot": {
            "url": "murph-attachment://0",
            "locked_asset": true,
            "asset_role": "product",
            "can_transform": false,
            "preservation_notes": "Preserve the book cover typography, portrait, proportions, and artwork exactly."
          }
        }
      },
      "quality": "draft",
      "variant_axis": {
        "dimension": "theme",
        "values": [
          "<concept direction 1 from the brief>",
          "<concept direction 2 from the brief>",
          "<concept direction 3 from the brief>"
        ]
      }
    }
  }
}
```

If the user attached an image in Murph, use the `murph-attachment://N` placeholder shown in the current turn. The tool layer resolves it to the upload bytes.

##### 11. Create Creative Manifest

**Operation:** `create_creative_manifest`
```
POST /api/v2/buyer/campaigns/{campaignId}/creatives/create
```
**As operation:**
```json
{
  "operation": "create_creative_manifest",
  "pathParams": { "campaignId": "campaign_abc123" },
  "body": {
    "name": "<final creative name from the brief>",
    "message": "<final creative selection summary from the brief>",
    "format_kind": "image",
    "linked_assets": [
      {
        "data_url": "murph-attachment://0",
        "asset_type": "IMAGE",
        "label": "The Shift book cover locked product asset",
        "slot_asset_id": "image",
        "make_primary": true,
        "locked_asset": true,
        "asset_role": "product",
        "can_transform": false,
        "preservation_notes": "Preserve the book cover typography, portrait, proportions, and artwork exactly."
      }
    ],
    "text_assets": [
      { "asset_id": "headline", "content": "<headline copy derived from the brief>" },
      { "asset_id": "cta", "content": "Preview the book" },
      { "asset_id": "brand_name", "content": "The Shift" }
    ]
  }
}
```

For a Murph chat attachment, always pass the current turn's
`murph-attachment://N` placeholder as `linked_assets[].data_url`. Never put the
attachment filename in `assets[]`: that field only describes files supplied by
a multipart REST client, while `buyer_api_call` has no multipart bytes to send.
Do not add `format_id` or `agent_url` for a Scope3-created creative; use the
canonical `format_kind` and, when a specific product option is required,
`format_option_ref`.

If `build_creative_variants` returns a generated image data URL for the selected variant, save that generated image as the primary `linked_assets[].data_url`. Also retain locked product-asset controls when the final render contains a preserved product asset.

**Refusals on `name`:** a duplicate name in this scope → `CONFLICT` on field
`name`, with `details.reason` telling you which case and whether to retry:
`name_creation_in_progress` (another writer is mid-create — retry the identical
request), `name_creation_abandoned` (that writer died; archive the incomplete
creative with `archive_creative`, then retry), or `name_taken` (the name belongs
to a finished creative — pick a different one). Never auto-rename on the first
two. Branch on `details.reason`, not on the message text.

##### 12. Preview Creative

**Operation:** `preview_creative`
```json
{
  "operation": "preview_creative",
  "pathParams": {
    "campaignId": "campaign_abc123",
    "creativeId": "creative_123"
  }
}
```
Returns rendered preview payloads, including HTML/image/audio/video renderers when available.

##### 13. Duplicate Creative for Refinement

**Operation:** `duplicate_creative`
```json
{
  "operation": "duplicate_creative",
  "pathParams": {
    "campaignId": "campaign_abc123",
    "creativeId": "creative_123"
  }
}
```

No request body — the copy is automatically named "Copy of {original name}". To rename it afterward, call `update_creative` with the new `name`.

If the original came from `adopt_managed_video_source`, the duplicate does NOT
inherit its adoption record: it keeps the video but is not the adoption of record
for that source, so it neither blocks re-adopting that source nor lets you
re-adopt through it.

For managed-audio provenance, retrieve [Adopt a managed audio
source](/v2/buyer/creatives/tasks/adopt-managed-audio-source).

Use this when the buyer says "make a variation", "refine this", "try people in the background", or similar.

---

##### 14. Upgrade Creative Manifest (set format_kind)

Use this when a creative has `requires_upgrade: true` — meaning it was created with a legacy `format_id` and the canonical `format_kind` has not been set. Call this to pin `format_kind` so storefront agents can correctly classify the format.

```http
POST /api/v2/buyer/campaigns/:campaignId/creatives/:creativeId/upgrade
```

**Via `api_call`:**
```json
{
  "method": "POST",
  "path": "/api/v2/buyer/campaigns/campaign_abc123/creatives/creative_123/upgrade",
  "body": {
    "format_kind": "image"
  }
}
```

**Body fields:**
- `format_kind` (string, optional): AdCP 3.1 canonical format kind. Valid values: `image`, `html5`, `display_tag`, `image_carousel`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast`, `sponsored_placement`, `native_in_feed`, `responsive_creative`, `agent_placement`, `custom`. When omitted, the server infers from `format_id` (e.g. `display_300x250_html` → `html5`). Must be supplied when the `format_id` is ambiguous (e.g. `display_html`).
- `brandAgentId` (string of digits, optional): Numeric brand agent ID to associate with the manifest. The server backfills from the campaign when omitted; supply only to override the campaign default.

**Response:** the updated creative manifest, with `requires_upgrade: false` once set.

---

#### Display Requirements — When listing manifests (`list_creatives`):
- **Name** and **Creative ID**
- **Template** and **Format ID** (if set) — these are ADCP format IDs (e.g. `display_300x250_html`, `video_standard`)
- **Asset count** — show `asset_count`
- **Sync status** — show `sync_status.synced` and `sync_status.agent_count`
- **Created/Updated timestamps**

For per-asset details (filename, type, `asset_source`, URL), `auto_detected_template`, `message`, `tracking`, `html_processing`, or `frequencyCaps`, call `get_creative`.

---

### Reporting

**V3:** Use `get_delivery({ report: "campaign_delivery", filters: { advertiserId, campaignId }, range: { startDate, endDate } })` on `/mcp/v3` instead of `get_reporting_metrics`. Date ranges are capped at 90 inclusive days; use `range: { lifetime: true }` for full campaign lifetime data.

#### Get Reporting Metrics

**Operation:** `get_reporting_metrics`
```http
GET /api/v2/buyer/reporting/metrics?view=summary&days=7&advertiserId=12345&campaignId=campaign_abc
```
**As operation:**
```json
{ "operation": "get_reporting_metrics", "params": { "view": "summary", "days": "7", "advertiserId": "12345", "campaignId": "campaign_abc" } }
```

Returns reporting data in one of two views: **summary** (hierarchical breakdown) or **timeseries** (flat rows broken out by hierarchy × day).

All `spend` values (and the money metrics derived from them: `ecpm`, `cpc`) are GROSS — fee-inclusive, stated at the fee terms locked on each media buy — so delivered spend compares directly against campaign and media buy budgets with no conversion. The exception: legacy media buys created before fee terms were locked report spend net, exactly as the seller reported it (these buys also carry neither `budget_denomination` nor `budget_breakdown`) — present those numbers as-is. Never restate spend as "net" or subtract a fee from it when presenting numbers to the buyer.

**🛑 REQUIRED QUESTION — ASK BEFORE CALLING**

This endpoint is SERVER-ENFORCED. If you call `get_reporting_metrics` without a date range, the server returns a 400 error telling you to ask the user. Do NOT guess defaults.

**Date range** — always required. Ask the user which period they want (e.g. "last 7 days", "last 30 days", or specific `startDate`/`endDate` in YYYY-MM-DD). Pass as `params.startDate` + `params.endDate` OR `params.days`. Pick `view="timeseries"` if the user wants daily breakdown; otherwise leave it as `summary` (default).

Validation summary:
- date range missing → error
- Both `view="summary"` and `view="timeseries"` honor `days` up to 90, or any explicit `startDate`/`endDate` window. For wider ranges or a file the user can download, pass `download=true` to get a CSV export.

**Query Parameters:**
- `view` (optional): Response format — `summary` (default) or `timeseries`
  - `summary`: Hierarchical breakdown by advertiser → campaign → media buy → package
  - `timeseries`: Flat rows — one entry per (advertiser → campaign → media buy → package) × day. Same hierarchy fields as `summary` plus a `date` column.
- `days` (optional): Number of days to include (default: 7, max: 90). Pass `0` for "all time" — the server resolves the start date to the customer's first reporting date. Ignored if both startDate and endDate are provided.
- `startDate` (optional): Start date in ISO format (YYYY-MM-DD)
- `endDate` (optional): End date in ISO format (YYYY-MM-DD)
- `advertiserId` (optional): Filter by advertiser ID
- `campaignId` (optional): Filter by campaign ID
- `demo` (optional, boolean): When `true`, returns auto-generated demo data instead of querying real data sources. Default: `false`. Useful for testing and previewing the reporting UI without live campaign data.

**⚠️ CRITICAL: Disambiguating "demo" in user requests**

The word "demo" can mean two different things in reporting requests. You MUST distinguish between them:

| User intent | Example phrases | Action |
|-------------|----------------|--------|
| **Demo flag** (synthetic demo data) | "show reporting (demo)", "demo show reporting", "show reporting with demo flag", "show reporting demo mode" | Set `demo=true` query parameter |
| **Name filter** (advertiser/campaign containing "demo") | "show reporting for demo advertiser", "show reporting for % demo %", "show campaigns named demo", "reporting for 'demo brand'" | Use `advertiserId` or `campaignId` filters to match entities whose names contain "demo" — do NOT set `demo=true` |

**How to tell the difference:**
- If "demo" appears as a **modifier or flag on the reporting request itself** (in parentheses, as "demo mode", "demo flag", or as a standalone qualifier adjacent to "reporting"), the user wants `demo=true`.
- If "demo" appears as a **value describing an advertiser, campaign, or entity name** (preceded by "for", "named", "called", or wrapped in quotes/wildcards), the user is filtering by name — do NOT set `demo=true`.

**Summary Response** (`view=summary`, default):
```json
{
  "advertisers": [
    {
      "advertiserId": "12345",
      "advertiserName": "Acme Corp",
      "metrics": { "impressions": 15000, "spend": 750, "clicks": 300, "views": 1500, "completedViews": 1200, "conversions": 75, "leads": 30, "videoCompletions": 1125, "ecpm": 50, "cpc": 2.5, "ctr": 0.02, "completionRate": 0.8 },
      "campaigns": [
        {
          "campaignId": "campaign_abc",
          "campaignName": "Summer Campaign",
          "metrics": { "..." : "..." },
          "mediaBuys": [
            {
              "mediaBuyId": "mb_1",
              "name": "Media Buy One",
              "status": "ACTIVE",
              "metrics": { "..." : "..." },
              "packages": [
                { "packageId": "pkg_1", "metrics": { "..." : "..." } }
              ]
            }
          ]
        }
      ]
    }
  ],
  "totals": { "impressions": 35000, "spend": 1750 },
  "periodStart": "2025-01-01",
  "periodEnd": "2025-01-31"
}
```

**Timeseries Response** (`view=timeseries`):

Flat rows — one entry per (advertiser → campaign → media buy → package) × day. Same hierarchy fields as the summary view, plus a `date` column. When a media buy has no packages, `packageId`, `productId`, and `productName` are empty strings.

```json
{
  "timeseries": [
    {
      "date": "2025-01-01",
      "advertiserId": "12345",
      "advertiserName": "Acme Corp",
      "campaignId": "campaign_abc",
      "campaignName": "Summer Campaign",
      "mediaBuyId": "mb_1",
      "mediaBuyName": "Media Buy One",
      "mediaBuyStatus": "ACTIVE",
      "packageId": "pkg_1",
      "productId": "prod_a",
      "productName": "Product A",
      "metrics": { "impressions": 5000, "spend": 250, "clicks": 100, "views": 500, "completedViews": 400, "conversions": 25, "leads": 10, "videoCompletions": 375, "ecpm": 50, "cpc": 2.5, "ctr": 0.02, "completionRate": 0.8 }
    },
    {
      "date": "2025-01-01",
      "advertiserId": "12345",
      "advertiserName": "Acme Corp",
      "campaignId": "campaign_abc",
      "campaignName": "Summer Campaign",
      "mediaBuyId": "mb_1",
      "mediaBuyName": "Media Buy One",
      "mediaBuyStatus": "ACTIVE",
      "packageId": "pkg_2",
      "productId": "prod_b",
      "productName": "Product B",
      "metrics": { "..." : "..." }
    }
  ],
  "totals": { "impressions": 35000, "spend": 1750 },
  "periodStart": "2025-01-01",
  "periodEnd": "2025-01-07"
}
```

**Metrics included:** impressions, spend, clicks, views, completedViews, conversions, leads, videoCompletions, ecpm, cpc, ctr, completionRate

**FX conversion disclosure:** When a media buy settles in a currency that differs from the buyer's account currency (e.g. a USD-settling storefront for a ZAR buyer), the API converts spend automatically and includes a `deliveryFxConversion` field on the affected media buy:

```json
{
  "fromCurrency": "USD",
  "rate": 16.4336,
  "asOfDate": "2026-07-02",
  "source": "booked"
}
```

`source` is `"booked"` (rate locked at buy creation time, deterministic) or `"snapshot"` (historical daily rate, used for buys created before the conversion feature shipped).

**When `deliveryFxConversion` is non-null on any media buy, always mention it without being asked.** Buyers need this for reconciliation. Example phrasing:

> "Spend was converted from USD to ZAR at a rate of 16.43 (booked at buy time). The R 1,134 shown reflects approximately $69 USD."

If `source` is `"snapshot"`, include the date: "...using the historical rate from 2026-07-02."

#### Export / Download Reporting Metrics as CSV

To export or download reporting data as a CSV file, use the same reporting metrics endpoint with `?download=true`. This generates a CSV and returns a signed download URL (valid 7 days) instead of JSON data.

**IMPORTANT:** When a user asks to "export", "download", "save as CSV", or "get a spreadsheet" of their reporting data, use this endpoint with `download=true`.

**Operation:** `get_reporting_metrics`
```http
GET /api/v2/buyer/reporting/metrics?days=30&download=true
```
**As operation:**
```json
{ "operation": "get_reporting_metrics", "params": { "days": "30", "download": "true" } }
```

All the same query parameters apply (`view`, `days`, `startDate`, `endDate`, `advertiserId`, `campaignId`). The only addition is `download=true`. This means `view=timeseries` is fully supported with `download=true` — use it when the user wants a date-by-date CSV export.

**Response when `download=true`:**
```json
{
  "downloadUrl": "https://storage.googleapis.com/...",
  "expiresAt": "2025-02-20T12:00:00.000Z",
  "fileName": "reporting-metrics-2025-02-06-to-2025-02-13.csv",
  "rowCount": 42
}
```

**CSV columns — summary view (default, 20 columns):** Advertiser ID, Advertiser Name, Campaign ID, Campaign Name, Media Buy ID, Media Buy Name, Media Buy Status, Package ID, Impressions, Spend, Clicks, Views, Completed Views, Conversions, Leads, Video Completions, eCPM, CPC, CTR, Completion Rate. One row per package (media buys with no packages get one row with empty Package ID).

**CSV columns — timeseries view (`view=timeseries`, 21 columns):** Same 20 columns as summary, plus a **Date** column (YYYY-MM-DD). One row per (package × day); media buys with no packages get one row per day with empty Package ID.

**CRITICAL: NEVER generate your own CSV, Excel, or spreadsheet files.** Always use this `?download=true` endpoint to produce reporting exports. The endpoint handles proper formatting, escaping, and data integrity. Do not use artifacts, code execution, or any other mechanism to create files — use the API.

When the download response is received, present the `downloadUrl` to the user as a clickable download link. Include the `fileName` and note that the link expires after 7 days (`expiresAt`).

---

### Data Delivery

Data Delivery ships log-level data (LLD) — impressions, clicks, conversions, MMP postbacks, VAST events, media-buy delivery — from Scope3 into a buyer-owned destination (GCS bucket, S3 bucket, or Azure Blob container) on a recurring cadence. It is the **push counterpart to `get_reporting_metrics`**: reporting metrics are pulled aggregates; Data Delivery writes row-level files into the buyer's own storage.

When the user says "ship impressions to our S3 bucket", "send daily clicks to GCS", "set up MMP postback exports", "deliver log-level data to our warehouse landing zone", or anything similar, this is the capability they want.

#### Concept model

Two objects, both **advertiser-scoped**:

| Object | What it does | Where it lives |
|---|---|---|
| **Data Delivery Credential** | Named handle that tells Scope3 *where* to write and how to authenticate. Probed asynchronously. | `dataDelivery.credentials[]` on the advertiser body |
| **Data Delivery Output** | Standing subscription: "ship `<dataDeliveryType>` to `<credentialName>` every `<cadence>` as `<format>` under `<pathPrefix>`". | `dataDelivery.outputs[]` on the advertiser body (default) or on the campaign body (override) |

Limits: up to **20 credentials per advertiser**, and **20 Outputs per scope** (the advertiser default array, plus up to 20 per campaign override). One Output per `(dataDeliveryType, credentialName)` pair per scope — to fan a data type out to two destinations, list one Output per destination.

#### How to set it up — use `update_advertiser`

There is **no separate `create_data_delivery_credential` tool**. Credentials and Outputs are inline arrays on the advertiser body, set via `update_advertiser` (or `create_advertiser` on first create). Both arrays are **full-replace** — the request body is the new desired state.

**Always fetch the current state with `get_advertiser` first** so you don't accidentally archive existing credentials or clear existing Outputs. A credential cannot be archived while any live Output (advertiser- or campaign-scoped) still references it — remove or repoint the Output first or the request will fail validation.

```json
{
  "operation": "update_advertiser",
  "pathParams": { "advertiserId": "12345" },
  "body": {
    "dataDelivery": {
      "credentials": [
        {
          "name": "primary-gcs",
          "config": { "type": "GCS", "bucket": "acme-scope3-data" }
        }
      ],
      "outputs": [
        {
          "dataDeliveryType": "IMPRESSIONS",
          "cadence": "HOURLY",
          "credentialName": "primary-gcs",
          "deliveryConfig": { "type": "GCS", "pathPrefix": "scope3/impressions/", "format": "PARQUET" }
        }
      ]
    }
  }
}
```

Credentials are applied before Outputs in a single transaction, so a brand-new credential can be referenced by a brand-new Output in the same request.

#### Field reference

| Field | Type | Values |
|---|---|---|
| `dataDelivery.credentials[].name` | string | Unique per advertiser among live credentials. Outputs reference it by this name. |
| `dataDelivery.credentials[].config.type` | enum | `GCS` \| `S3` \| `AZURE_BLOB` |
| `dataDelivery.credentials[].config.bucket` | string | GCS or S3 bucket name |
| `dataDelivery.credentials[].config.region` | string | **S3 only** — e.g., `us-east-1`, `us-gov-east-1` |
| `dataDelivery.credentials[].config.storageAccountName` | string | **Azure only** — 3–24 lowercase alphanumerics |
| `dataDelivery.credentials[].config.containerName` | string | **Azure only** — 3–63 chars, lowercase + hyphens |
| `dataDelivery.credentials[].config.auth` | object | **Azure only** — `{ "mode": "SAS_TOKEN", "sasToken": "<sas>" }`. SAS is write-only — never echoed back on `get_advertiser`. |
| `dataDelivery.outputs[].dataDeliveryType` | enum | `MB_DELIVERY` \| `IMPRESSIONS` \| `CLICKS` \| `VAST_EVENTS` \| `CAPI_ATTRIBUTION` \| `MMP_POSTBACKS` |
| `dataDelivery.outputs[].cadence` | enum | `HOURLY` (minute 0) \| `DAILY` (00:00 UTC) \| `WEEKLY` (00:00 UTC on `syncWeeklyDay`) |
| `dataDelivery.outputs[].syncWeeklyDay` | int | **Required when cadence=WEEKLY.** `0`=Sunday … `6`=Saturday. |
| `dataDelivery.outputs[].enabled` | boolean | Default `true`. Set `false` to pause the schedule without removing the Output. |
| `dataDelivery.outputs[].credentialName` | string | Must reference a live credential on the same advertiser whose `destinationType` matches `deliveryConfig.type`. |
| `dataDelivery.outputs[].deliveryConfig.type` | enum | Must match the credential's `type` (`GCS` \| `S3` \| `AZURE_BLOB`). |
| `dataDelivery.outputs[].deliveryConfig.pathPrefix` | string | Used verbatim — leading slashes are not stripped, not templated. End with `/` for a directory-like layout. |
| `dataDelivery.outputs[].deliveryConfig.format` | enum | `JSONL` (default) \| `PARQUET` \| `CSV` |

#### Probe lifecycle

When a credential is created or its config changes, Scope3 asynchronously writes a sentinel object to the destination, deletes it, and updates the credential row:

| `status` | Meaning |
|---|---|
| `PENDING` | Just created or rotated. Probe hasn't completed yet — typically clears within seconds. |
| `VALIDATED` | Probe wrote and deleted a sentinel object successfully. `validatedAt` reflects the run. |
| `FAILED` | Probe could not write. `statusError` carries a human-readable reason — present it to the user verbatim. |

Re-fetch with `get_advertiser` to see the current `status`. Outputs can reference a credential in any status; the shipping workflow re-checks at run time.

To force a fresh Probe (e.g., the buyer fixed an IAM grant out-of-band and wants to revalidate without resubmitting the SAS token):

```json
{
  "operation": "revalidate_data_delivery_credential",
  "pathParams": { "advertiserId": "12345", "name": "primary-gcs" }
}
```

#### Common Probe failures to recognize

When `statusError` matches one of these patterns, surface the cause and the fix verbatim — the buyer's next step is on their cloud, not in the API.

| `statusError` contains | Destination | What to tell the buyer |
|---|---|---|
| `does not have storage.objects.create`, `403` | GCS | Grant `roles/storage.objectCreator` to `report-delivery@swift-catfish-337215.iam.gserviceaccount.com` on the bucket, then revalidate. |
| `AccessDenied`, `403` | S3 | Confirm the bucket policy grants `s3:PutObject` to `arn:aws:iam::948454267882:user/scope3-data-sync-service` on `arn:aws:s3:::<bucket>/*`. |
| `PermanentRedirect` | S3 | `config.region` doesn't match the bucket's actual region. |
| `Azure SAS token expired at ...` | Azure | The `se=` claim is in the past. Mint a new SAS and submit a fresh credential under the same `name` to rotate. |
| `AuthorizationPermissionMismatch` | Azure | SAS is valid but missing required permissions. Confirm `sp` includes `c`, `w`, `d` (`sp=cwd`). For Account SAS, also confirm `ss=b`. |
| `AuthorizationResourceTypeMismatch` | Azure | **Account SAS only** — `srt` is missing `o` (Object). The Probe performs blob-level operations (`PUT Blob`, `DELETE Blob`), so `srt` must include `o` (e.g., `srt=co`). `srt=s` or `srt=c` alone won't work. Ask the buyer to regenerate with `--resource-types co`. |
| `AuthorizationFailure` on `_scope3-probe/<uuid>.txt` | Azure | Service SAS was scoped to a single blob (`sr=b`) instead of the container (`sr=c`). The Probe writes to a randomized path, so a blob-scoped SAS will never match. Ask the buyer to regenerate with `az storage container generate-sas` so the SAS is scoped to the container. |
| `ContainerNotFound` | Azure | `containerName` doesn't exist on the storage account, or the SAS was issued at a different scope. |

After the buyer applies the fix, call `revalidate_data_delivery_credential` to re-run the Probe — they don't have to resubmit the credential body for GCS/S3 cases (no secret to re-paste).

#### Campaign-scoped overrides — use `update_campaign`

For a single campaign that needs a different cadence, format, or destination than the advertiser default, set `dataDelivery.outputs` on the campaign. The override replaces the advertiser-scoped Output for the matching `dataDeliveryType` on this campaign only.

```json
{
  "operation": "update_campaign",
  "pathParams": { "campaignId": "99999" },
  "body": {
    "dataDelivery": {
      "outputs": [
        {
          "dataDeliveryType": "IMPRESSIONS",
          "cadence": "HOURLY",
          "credentialName": "primary-gcs",
          "deliveryConfig": { "type": "GCS", "pathPrefix": "scope3/campaign-99999/impressions/", "format": "PARQUET" }
        }
      ]
    }
  }
}
```

In `get_campaign` and `get_advertiser` responses, resolved Outputs are tagged with `source: "advertiser"` or `source: "campaign"` so the user can see which level produced each entry — surface that distinction when listing Outputs.

#### What to present

After setting up credentials or Outputs, **show the user the resolved state from the response**, not a summary:

- For each credential: `name`, `destinationType`, `status`, `validatedAt` (if VALIDATED), `statusError` (if FAILED), `expiresAt` (Azure only).
- For each Output: `dataDeliveryType`, `cadence`, `credentialName`, `deliveryConfig.pathPrefix`, `deliveryConfig.format`, `enabled`, `source` (advertiser vs campaign).

If a credential is `PENDING` after the call, tell the user the Probe is running and they can re-check via `get_advertiser`. If `FAILED`, surface the `statusError` exactly and offer to revalidate after they fix the underlying access grant.

#### Buyer-side setup not done via the API

Before a credential will validate, the buyer must grant Scope3 access on their cloud. **Do not attempt this for the user** — point them at the [Data Delivery guide](https://docs.interchange.io/v2/guides/data-delivery) for the exact GCP service account, AWS IAM principal, and Azure SAS permission list.

---

### Catalogs

Catalogs are managed entirely through sync — there is no separate create/update/delete.

The Catalogs Page uses this same contract. **Add feed** opens a URL/upload form; **Replace file** parses a row-based file locally and syncs it back to the selected catalog id. Progress, errors, item approval counts, and field-level issues stay in the Page. Compatible hosts do not need a Murph-specific prompt handoff.

**Before calling sync, you MUST collect from the user:**
1. Which advertiser — the advertiser ID goes in the URL path AND in `account.account_id` in the request body
2. The catalog(s) to sync — each needs a `catalog_id`, `type`, and either a feed `url` or inline `items`

#### Sync Catalogs

**Option A — Remote feed URL (multiple catalogs in one call):**

**Operation:** `sync_catalogs`
```http
POST /api/v2/buyer/advertisers/26/catalogs/sync
{
  "account": { "account_id": "26" },
  "catalogs": [
    {
      "catalog_id": "products-2026",
      "type": "product",
      "name": "2026 Product Catalog",
      "url": "https://example.com/products.xml",
      "feed_format": "google_merchant_center",
      "update_frequency": "daily"
    },
    {
      "catalog_id": "promotions-q1",
      "type": "promotion",
      "name": "Q1 Promotions",
      "url": "https://example.com/promotions.xml",
      "feed_format": "custom",
      "update_frequency": "hourly"
    }
  ]
}
```

**Option B — Inline items:**

**Operation:** `sync_catalogs`
```http
POST /api/v2/buyer/advertisers/26/catalogs/sync
{
  "account": { "account_id": "26" },
  "catalogs": [
    {
      "catalog_id": "my-catalog-1",
      "type": "product",
      "name": "Q1 Products",
      "items": [
        {
          "item_id": "sku-001",
          "title": "Blue Widget",
          "description": "A sturdy blue widget for everyday use",
          "price": "19.99 USD",
          "link": "https://example.com/products/blue-widget",
          "image_link": "https://example.com/images/blue-widget.jpg",
          "availability": "in stock",
          "brand": "Acme",
          "google_product_category": "Hardware > Tools"
        },
        {
          "item_id": "sku-002",
          "title": "Red Widget",
          "description": "A sturdy red widget for everyday use",
          "price": "24.99 USD",
          "link": "https://example.com/products/red-widget",
          "image_link": "https://example.com/images/red-widget.jpg",
          "availability": "in stock",
          "brand": "Acme",
          "google_product_category": "Hardware > Tools"
        }
      ]
    }
  ]
}
```

> Items are free-form key/value objects — the fields depend on the catalog `type`. The examples above use common product fields. For `job` catalogs use fields like `job_id`, `title`, `company`, `location`; for `hotel` use `hotel_id`, `name`, `address`, `star_rating`; etc.

**URL path:** `/advertisers/{advertiserId}/catalogs/sync` — the `{advertiserId}` is the numeric advertiser ID (e.g. `26`). Also include it in the request body as `account.account_id`.

**`account` (required in body):**
- `account_id` (string): The advertiser ID — same value as the path `{advertiserId}` (e.g. `"26"`).

**`catalogs` array (required, 1–50 items). Each object:**
- `catalog_id` (string, required): Buyer-assigned identifier
- `type` (string, required): `offering`, `product`, `inventory`, `store`, `promotion`, `hotel`, `flight`, `job`, `vehicle`, `real_estate`, `education`, `destination`
- `name` (string, optional): Display name
- `url` (string): Remote feed URL — provide this OR `items`, not both
- `items` (array): Inline catalog items — provide this OR `url`, not both
- `feed_format` (string, optional): `google_merchant_center`, `facebook_catalog`, `shopify`, `linkedin_jobs`, `custom`
- `update_frequency` (string, optional): `realtime`, `hourly`, `daily`, `weekly`
- `conversion_events` (array, optional): Conversion event IDs

**Other optional fields:**
- `catalog_ids` (array): Filter which catalog_ids from the `catalogs` array to process
- `delete_missing` (boolean): Archive catalogs not included in this request (default: false)
- `dry_run` (boolean): Preview changes without persisting (default: false)
- `validation_mode` (string): `strict` (default) or `lenient`

**Response:**
```json
{
  "data": {
    "results": [
      {
        "catalog_id": "my-catalog-1",
        "action": "created",
        "name": "Q1 Products",
        "type": "offering"
      }
    ]
  }
}
```

Actions: `created`, `updated`, `unchanged`, `failed`, `deleted`

#### List Catalogs

**Operation:** `list_catalogs`
```http
GET /api/v2/buyer/advertisers/26/catalogs
```
**As operation:**
```json
{ "operation": "list_catalogs", "pathParams": { "advertiserId": "26" } }
```

**URL path:** `/advertisers/{advertiserId}/catalogs` — the `{advertiserId}` is the numeric advertiser ID.

**Query Parameters:**
- `type` (optional): Filter by catalog type (`offering`, etc.)
- `take` / `skip` (optional): Pagination

**Response (200):**
```json
{
  "data": {
    "account": { "account_id": "26" },
    "catalogs": [
      {
        "catalogId": "my-catalog-1",
        "type": "offering",
        "name": "Q1 Products",
        "url": "https://example.com/feed.xml"
      }
    ]
  }
}
```

---

### Storefronts

Browse available storefronts, register credentials for inventory sources, and
link accounts to advertisers.

**Official adapter storefronts — CRITICAL:** Built-in adapters such as Amazon,
Google, Meta, Pinterest, Reddit, Snap, Spotify, TikTok, and AudioStack are
storefronts whose buyer-facing dispatcher calls the adapter directly. They are
not inventory sources inside another storefront. If a user asks to "connect to
Snap", "connect Google", or use any other official adapter, do not page through
public storefronts looking for a matching source row. Use the specific adapter
storefront MCP endpoint surfaced by discovery/configuration and let that
storefront return its OAuth/BYOK auth challenge. The per-source credential
registration flow below applies only to external AdCP source rows returned by
`get_storefront_capabilities`.

**CRITICAL — Account Status Awareness:**
For Chef/composed storefronts, the list returns each storefront's `sourceCount`
and `connectedSourceCount`. When `connectedSourceCount < sourceCount`, the
storefront has unconnected inventory sources. Call `get_storefront` for the
rolled-up state, `get_storefront_capabilities` for active source rows, and
`list_agent_credentials` for the credentials already registered. Specifically:
- If an external source has `requiresCredentials: true` and no active credential in `list_agent_credentials` for that `(storefrontId, sourceId)`: Tell the user they need to register credentials for this source before they can use it.
- If credentials exist for that source: Show the registered account identifiers and statuses from `list_agent_credentials`.
- If an external source has `requiresCredentials: false`: The platform handles credentials — no action needed from the user.
- If an external source has `requiresCredentials: null`: The credential requirement is unavailable from non-synthetic capabilities and stored source configuration; do not infer that registration is required from the capability diagnostic alone.
- If a source has `probeable: false`, do not call it unreachable or ask for AdCP credentials based on this diagnostic alone; managed ad-server-backed sources are not checked through this endpoint.

Never silently omit this information. The user needs to know which storefronts have unconnected sources and which are ready to use.

**CRITICAL — Proactive Registration Prompt for disconnected sources:**
When `connectedSourceCount < sourceCount` for any Chef/composed storefront in the list, you MUST:
1. **Drill in via `get_storefront_capabilities` and `list_agent_credentials`** — identify external sources with `requiresCredentials: true` and no active credential for that `(storefrontId, sourceId)`.
2. **Explicitly call it out in a separate section** — After showing the storefront list, add a clear callout like: "The following sources require you to register credentials: [source names]. Would you like to register credentials for any of them?"
3. **Offer to start the registration flow** — Ask the user if they want to register credentials now. If yes, use `register_source_credentials` operation.
4. **After registering credentials, offer to link an account to an advertiser** — Once credentials are registered, ask: "Now that credentials are set up for [source name], would you like to discover and link an account to a specific advertiser?" If yes, follow the Link Agent Account to Advertiser workflow (see Advertisers section):
   a. List the customer's advertisers via `list_advertisers` operation
   b. For the chosen advertiser, discover available accounts for the agent via `list_available_accounts` operation
   c. Present discovered accounts and let the user pick
   d. Link via `update_advertiser` operation with `linkedAccounts`

This end-to-end flow (list storefronts → get_storefront_capabilities →
list_agent_credentials → register credentials → link account to advertiser)
should feel seamless for inventory-source storefronts. Do NOT make the user
figure out the next step — always offer it.
Do not apply this flow to official adapter storefronts; their provider
authorization happens through the adapter MCP OAuth/BYOK flow.

#### List Storefronts

List all enabled storefronts visible to the buyer. Each storefront contains inventory sources backed by agents. Results are paginated.

**Operation:** `list_storefronts`
```http
GET /api/v2/buyer/storefronts?name=Roundel&visibility=public&limit=20&offset=0
```
**As operation:**
```json
{ "operation": "list_storefronts", "params": { "name": "Roundel", "visibility": "public", "limit": "20", "offset": "0" } }
```

**Query Parameters (all optional):**
- `name` (string): Filter by storefront name (partial match, case-insensitive)
- `status` (`"configuring"` | `"transacting"` | `"archived"`): Filter by display status. `transacting` is live and accepting transactions; `configuring` is listed but still setting up.
- `channel` (string): Filter to storefronts that carry this ADCP channel code (e.g. `display`, `olv`, `ctv`).
- `region` (string): Filter to storefronts that cover this region code (e.g. `EMEA`, `NORAM`, `APAC`).
- `visibility` (`"public"` | `"private"`): Which storefronts to list. Defaults to `"public"` — ACTIVE storefronts available to any buyer. Use `"private"` to return ALL storefronts (ACTIVE, PENDING, **and DISABLED**) owned by customers in the caller's parent org. The DISABLED case matters when a customer also operates sell-side under the same umbrella and needs to see paused storefronts to bring them back, run pre-launch tests, or prep credentials in advance.
- `publisherDomain` (string): Filter to storefronts that carry this publisher domain in their declared publisher list (e.g. `bbc.com`). Useful for vendor selection — "which storefronts represent this publisher?" Returns only non-adapter storefronts.
- `limit` (number): Maximum storefronts per page (default: 20, max: 50)
- `offset` (number): Number of storefronts to skip for pagination (default: 0)

**Response:**
```json
{
  "data": {
    "items": [
      {
        "id": 1,
        "platformId": "premium-video",
        "name": "Premium Video Exchange",
        "publisherDomain": "premiumvideo.com",
        "status": "ACTIVE",
        "channels": ["CTV", "display"],
        "sourceCount": 3,
        "connectedSourceCount": 2,
        "publishers": {
          "total": 120,
          "verified": 95,
          "sample": ["bbc.com", "guardian.com", "telegraph.co.uk"]
        }
      }
    ],
    "total": 25,
    "hasMore": true,
    "nextOffset": 20
  }
}
```

**Pagination:** When `hasMore` is true, use the `nextOffset` value as the `offset` parameter in your next request to fetch the next page. Continue until `hasMore` is false or `nextOffset` is null.

**Notes:**
- `visibility=public` (default) returns only ACTIVE storefronts; `visibility=private` can include `PENDING` and `DISABLED` storefronts owned by the caller's parent org, so always check `status` before assuming a row is sellable.
- Each storefront has one or more inventory sources, each backed by an agent
- `channels` is the list of ad channels the storefront supports (e.g. `["CTV", "display", "audio"]`). Empty array if not configured.
- `sourceCount` is the total number of inventory sources on the storefront; `connectedSourceCount` is how many of those the buyer is wired to use, either because no buyer credentials are required or because credentials are active
- `publishers` is a summary of the storefront's declared publisher coverage — `{ total, verified, sample[] }`. `null` for adapter storefronts (walled-garden platforms have no publisher list). Use `list_storefront_publishers` for the full paginated list.
- `supportedRoutingTypes` is a deprecated v2 compatibility placeholder. It returns both legacy values for every storefront and must never be used for storefront classification or behavior.
- The list does NOT include per-source rows — call `get_storefront_capabilities` to enumerate active source IDs and external AdCP capability/auth state. Call `get_storefront` only for rolled-up storefront state such as `connected`, `requiresCredentials`, and `customerAccounts`.

**Credential rules — CRITICAL (apply to source rows on `get_storefront_capabilities`):**
- `requiresCredentials: true` on an external AdCP source → the buyer MUST provide their own credentials. Registration is done via `register_source_credentials` operation.
- `requiresCredentials: false` on an external AdCP source → Scope3 acts as the agent on behalf of the advertiser. **Individual credential registration is NOT possible.**
- `requiresCredentials: null` → the credential requirement is unavailable from non-synthetic capabilities and stored source configuration, or not applicable. Do not infer that registration is required.
- `probeable: false` → this diagnostic does not check an external AdCP agent for the source. Do not infer that the source is unreachable.
- **Account linking requires `requiresCredentials: true`.** Only sources where the buyer registers their own credentials can have accounts discovered and linked to advertisers.

**Display Requirements — ALWAYS include when listing storefronts:**

Present each storefront as a structured entry (not prose). For every storefront, show:
- **Name** and **ID**
- **Source coverage** — show `connectedSourceCount` / `sourceCount` (e.g. "2 of 3 sources connected")

Key rules:
- Never summarize into "You have N storefronts." Always show the per-item details above.
- **When `connectedSourceCount < sourceCount`, drill in via `get_storefront_capabilities` and `list_agent_credentials`** to identify external sources that still need credentials, then ask the user about registration. Do NOT just list them and move on.

#### Get Storefront AdCP Capabilities

Returns source-level capability diagnostics for each active source on a storefront. External AdCP sources include cached-or-refreshed capability details. Managed ad-server-backed sources are returned with `probeable: false` and `probeStatus: "not_applicable"`; do not treat those rows as unreachable agents. Use this to debug operation gating issues (e.g. why `update_media_buy` is blocked, whether sandbox mode is supported, what billing types are allowed) without needing to contact the agent team.

**Operation:** `get_storefront_capabilities`
```http
GET /api/v2/buyer/storefronts/:storefrontId/capabilities
```
**As operation:**
```json
{ "operation": "get_storefront_capabilities", "params": { "storefrontId": "53" } }
```

**Response (200):**
```json
{
  "data": {
    "storefrontId": 53,
    "agents": [
      {
        "sourceId": "selleragent-pubx-ai",
        "sourceName": "Pubx Direct",
        "executionType": "AGENT",
        "agentName": "Pubx Sales Agent",
        "requiresCredentials": false,
        "probeable": true,
        "probeStatus": "reachable",
        "message": null,
        "capabilities": {
          "version": "v3",
          "tools": ["get_products", "create_media_buy", "update_media_buy", "get_adcp_capabilities"],
          "protocols": ["media_buy", "creative"],
          "features": { "content_standards": false, "inline_creative_management": true },
          "accountResolution": "implicit_from_sync",
          "requireOperatorAuth": false,
          "defaultBilling": "agent",
          "supportedBillings": ["operator", "agent"],
          "reportingDeliveryMethods": null,
          "sandboxSupported": false,
          "synthetic": false,
          "raw": { ... }
        }
      },
      {
        "sourceId": "managed-ad-server",
        "sourceName": "Managed ad server",
        "executionType": "MANAGED_SALES_AGENT",
        "agentName": null,
        "requiresCredentials": null,
        "probeable": false,
        "probeStatus": "not_applicable",
        "message": "Managed ad-server-backed source; not probed via AdCP capabilities.",
        "capabilities": null
      }
    ]
  }
}
```

`probeStatus: "reachable"` means non-synthetic capabilities are currently available, possibly from cache; it is not a guarantee that this request opened a fresh network connection. `capabilities` is `null` for managed sources or if the external agent has no available capability payload. `synthetic: true` means the capabilities were synthesized from the tool list (no `get_adcp_capabilities` call succeeded). `raw` contains the full uninterpreted AdCP capability payload.

Use the `tools` array to check whether a specific operation (e.g. `update_media_buy`) is advertised. Use `supportedBillings` and `accountResolution` to debug account setup issues.

#### Get Storefront Publishers

Returns the full paginated list of publisher domains a storefront claims to represent. Use this when the buyer wants to verify a seller's publisher coverage before committing to a buy — "does this storefront actually carry BBC?", "what publishers does Ozone represent?".

Not available for adapter storefronts (Meta, Snap, TikTok, etc.) — returns 404. Use the `publishers` summary on the storefront detail (see `list_storefronts` / `get_storefront`) to know whether a publisher list exists before calling this endpoint.

**Operation:** `list_storefront_publishers`
```http
GET /api/v2/buyer/storefronts/:storefrontId/publishers?verification=verified&q=bbc
```
**As operation:**
```json
{ "operation": "list_storefront_publishers", "params": { "storefrontId": "42", "verification": "verified" } }
```

**Query Parameters (all optional):**
- `verification` (`"verified"` | `"declared"`): Filter by verification status. `verified` = cross-checked against the seller's adagents.json; `declared` = seller-asserted only.
- `q` (string): Prefix filter on domain name (e.g. `q=bbc` returns `bbc.com`, `bbc.co.uk`).
- `limit` (number): Max results per page (default: 20, max: 100).
- `offset` (number): Pagination offset.

**Response (200):**
```json
{
  "data": {
    "publishers": [
      { "domain": "bbc.com", "verification": "verified", "propertyCount": 142 },
      { "domain": "bbc.co.uk", "verification": "declared", "propertyCount": null }
    ],
    "total": 120,
    "verifiedCount": 95
  }
}
```

**Notes:**
- `domain` is the publisher_domain namespace from the seller's adagents.json authorization chain. It is the entity that controls seller access — not necessarily a property URL or property_id.
- `propertyCount` is the number of resolved properties under this publisher domain (from the AAO registry), or `null` if not yet resolved.
- `verifiedCount` is the count of `verified` entries out of `total`. Note: the inline summary (from `list_storefronts` / `get_storefront`) uses `verified` for this field; this full-list response uses `verifiedCount`.
- Adapter storefronts (walled-garden platforms) return 404 — they have no publisher list.
- The storefront's `publishers` summary in `list_storefronts` / `get_storefront` gives `{ total, verified, sample[] }` inline, so you only need this endpoint for full pagination or domain-level filtering.

#### List Registered Agent Credentials

List all agent credentials registered by this customer across all agents.

**Operation:** `list_agent_credentials`
```http
GET /api/v2/buyer/storefronts/credentials
```
**As operation:**
```json
{ "operation": "list_agent_credentials" }
```

**Response (200):**
```json
{
  "data": [
    {
      "id": "722",
      "accountIdentifier": "Retail Network Creds",
      "accountType": "CLIENT",
      "status": "ACTIVE",
      "registeredBy": "user@example.com",
      "createdAt": "2026-02-23T19:55:11.602Z",
      "updatedAt": "2026-02-23T19:56:56.272Z",
      "sources": [
        {
          "storefrontId": 1,
          "storefrontName": "Retail Media Network",
          "sourceId": "retail-network-agent",
          "sourceName": "Retail Network Agent"
        }
      ]
    }
  ]
}
```

**Notes:**
- Returns all credentials registered by this customer, scoped to the storefront sources each one gives access to
- `sources[]` lists every (storefrontId, sourceId) pair the credential covers — a single credential row can cover the same source across multiple storefronts
- Auth secrets are never returned (sensitive — stored in Google Secret Manager)
- Use this to check which sources are connected before linking accounts to advertisers

#### Register Agent Credentials

Register credentials for a specific inventory-source agent at the **customer
level**. This is the first step in connecting to a legacy or third-party source
listed by `get_storefront_capabilities` with
`requiresCredentials: true` — credentials belong to the whole customer, not a
specific advertiser. Once credentials are registered, accounts can be discovered
and linked to individual advertisers (see Link Agent Account to Advertiser in
the Advertisers section).

**Multiple credentials per agent:** A customer CAN register multiple sets of
credentials for the same inventory-source agent (e.g., two different accounts
with different API keys). Each set uses a different `accountIdentifier`. Each
credential discovers its own set of ad accounts. When discovering accounts for
linking, the `credentialId` parameter is required so the system knows which
credential to query — see "Link Agent Account to Advertiser" in the Advertisers
section for the full workflow.

**Do not use this endpoint for official adapters.** For built-in adapter
storefronts such as Snap, Google, Meta, Spotify, TikTok, Reddit, Pinterest,
Amazon, and AudioStack, provider authentication is handled by the adapter
storefront's OAuth/BYOK MCP flow. There is no `sourceId` to discover under a
different storefront and no `register_source_credentials` call to make.

After a registered AdCP source account is discovered and linked to an
advertiser, `connect_adcp_storefront` can project that existing relationship
into the shared connection plane for tracked campaigns. This operation does
not register credentials and never accepts an endpoint URL or raw secret:

```json
{
  "operation": "connect_adcp_storefront",
  "pathParams": { "storefrontId": "42", "sourceId": "src_abc123" },
  "body": {
    "advertiserId": "12345",
    "accountId": "account-returned-by-list-available-accounts",
    "credentialId": "credential-returned-by-list-available-accounts"
  }
}
```

It returns `connectionId` and `connectionAccountId`. Use those IDs with the
tracked-campaign subscription operations. `credentialId` is optional
only when exactly one active credential exposes the selected account. The
connection records the source's reported tools as readiness evidence but does
not reject an otherwise active, mapped source for omitting an operation.
If a later subscribe, delivery, or write operation is unsupported, report that
operation's exact error and continue to offer the source's other capabilities.

**Operation:** `register_source_credentials`
```http
POST /api/v2/buyer/storefronts/{storefrontId}/sources/{sourceId}/credentials
{
  "accountIdentifier": "my-publisher-account",
  "auth": {
    "type": "bearer",
    "token": "my-api-key"
  }
}
```
**As operation:**
```json
{ "operation": "register_source_credentials", "pathParams": { "storefrontId": "1", "sourceId": "src_abc123" }, "body": { "accountIdentifier": "my-publisher-account", "auth": { "type": "bearer", "token": "my-api-key" } } }
```

**Path Parameters:**
- `storefrontId` (number): The storefront ID
- `sourceId` (string): The inventory source ID within the storefront

**Required Fields:**
- `accountIdentifier` (string): Unique account identifier for this agent

**Optional Fields:**
- `auth` (object): Authentication credentials. Required for API_KEY/JWT agents, not needed for OAUTH agents.
- `marketplaceAccount` (boolean): Admin-only flag for marketplace accounts

**OAUTH agents:** Do NOT ask the user for any OAuth credentials (client_id, client_secret, tokens, etc.). Just omit the `auth` field. The response will include an `oauth.authorizationUrl` — present this link to the user to complete authorization. The platform handles discovery, client registration, and token exchange automatically.

**Response (non-OAUTH, 201):**
```json
{
  "id": "123",
  "accountIdentifier": "my-publisher-account",
  "status": "ACTIVE",
  "registeredBy": "user@example.com",
  "createdAt": "2026-01-15T10:00:00Z"
}
```

**Response (OAUTH, 201):**
```json
{
  "id": "123",
  "accountIdentifier": "my-publisher-account",
  "status": "PENDING",
  "registeredBy": "user@example.com",
  "createdAt": "2026-01-15T10:00:00Z",
  "oauth": {
    "authorizationUrl": "https://agent.example.com/authorize?client_id=abc&...",
    "storefrontId": 1,
    "sourceId": "src_abc123",
    "sourceName": "Inventory Source Name"
  }
}
```

**Notes:**
- Agent must be ACTIVE before accounts can be registered
- For OAUTH agents, the account is created with PENDING status and includes an `authorizationUrl` for the user to click
- After the user authorizes, the account status changes to ACTIVE automatically

### Audiences

Sync first-party CRM audiences into Scope3 for later syndication to sales agents. Processing is **asynchronous** — sync returns immediately with an `operationId`, and processing completes in the background.

**Identifier contract:** Read `/v2/buyer/advertisers/tasks/sync-audiences` for the canonical field and privacy rules. The operation accepts raw email or international phone values for immediate edge hashing, their pre-hashed equivalents, and supported universal IDs. Never place email-shaped or E.164-formatted contact values in `externalId` or `uids`.

**Limits:** Maximum 100,000 total members per sync call. For larger lists, chunk into sequential requests.

#### Sync Audiences

Sync audience data for an advertiser. The `accountId` in the URL is the **advertiser ID** (numeric, e.g. `25`) — the same `advertiserId` used when creating campaigns. Returns **202 Accepted** with an operation ID for tracking. Members in `add` require an `externalId` plus at least one identifier; members in `remove` may use `externalId` alone.

**Operation:** `sync_audiences`
```http
POST /api/v2/buyer/advertisers/{accountId}/audiences/sync
{
  "audiences": [
    {
      "audienceId": "crm-high-value",
      "name": "High Value Customers",
      "add": [
        {
          "externalId": "user-001",
          "email": "person@example.com"
        },
        {
          "externalId": "user-002",
          "uids": [{ "type": "uid2", "value": "uid2-token-value" }]
        }
      ],
      "consentBasis": "consent"
    }
  ],
  "deleteMissing": false
}
```

**Path Parameters:**
- `accountId` (string, required): Advertiser ID (numeric, e.g. `"25"`)

**Required Fields:**
- `audiences` (array): Audiences to sync
  - `audienceId` (string, required): Buyer's identifier for this audience

**Optional Fields per Audience:**
- `name` (string): Human-readable name
- `add` (array, max 100,000): Members to add (each needs `externalId` + at least one identifier)
- `remove` (array, max 100,000): Members to remove by `externalId`
- `delete` (boolean): When true, delete this audience entirely
- `consentBasis` (string): GDPR lawful basis — `consent`, `legitimate_interest`, `contract`, `legal_obligation`
- `deleteMissing` (boolean): When true, audiences not in this request are marked as deleted

**Response (202 Accepted):**
```json
{
  "success": true,
  "accountId": "25",
  "operationId": "550e8400-e29b-41d4-a716-446655440000",
  "taskId": "550e8400-e29b-41d4-a716-446655440000"
}
```

**Notes:**
- Processing is asynchronous — poll via `get_task` operation for progress (see [Tasks](#tasks))
- `status` values: `PROCESSING` (matching in progress), `READY` (available for targeting), `ERROR`, `TOO_SMALL` (below platform minimum)

#### List Audiences

List stored audiences for an account. Use this to check processing status after syncing.

**Operation:** `list_audiences`
```http
GET /api/v2/buyer/advertisers/{accountId}/audiences?take=50&skip=0
```
**As operation:**
```json
{ "operation": "list_audiences", "pathParams": { "advertiserId": "<id>" }, "params": { "take": "50", "skip": "0" } }
```

**Path Parameters:**
- `accountId` (string, required): Advertiser ID (numeric, e.g. `"25"`)

**Query Parameters (all optional):**
- `take` (number): Results per page (default: 50, max: 100)
- `skip` (number): Pagination offset (default: 0)

**Response:** each row is the audience **summary** shape — `audienceId`, `name`, `accountId`, `status`, `deleted`, `uploadedCount`, `matchedCount`, `createdAt`, `updatedAt`. `consentBasis` and `lastOperationStatus` are NOT on the summary — call `get_audience` for the full resource.

```json
{
  "audiences": [
    {
      "audienceId": "crm-high-value",
      "name": "High Value Customers",
      "accountId": "25",
      "status": "READY",
      "deleted": false,
      "uploadedCount": 1500,
      "matchedCount": 1200,
      "createdAt": "2026-02-24T10:00:00Z",
      "updatedAt": "2026-02-25T10:00:00Z"
    }
  ],
  "total": 1,
  "take": 50,
  "skip": 0
}
```

---

### Frequency Caps

Buyer-side frequency caps limit how often a user sees ads across all publishers/sales agents, scoped to an **advertiser**, **campaign**, or **creative**. These are distinct from publisher-side caps in the ADCP `target_overlay` — those are set by the seller on their own inventory.

**Shape of a single cap:**
```json
{ "max_impressions": 3, "window": { "interval": 1, "unit": "days" } }
```
- `max_impressions` (positive int): max impressions allowed per user within the window.
- `window.interval` (positive int) + `window.unit` (`"seconds"` | `"minutes"` | `"hours"` | `"days"` | `"campaign"`): rolling time window size. Matches the AdCP `Duration` shape.

**Multiple caps per entity are allowed and combine as AND.** For example, to enforce "no more than 3/day AND 10/hour" on a campaign, pass both:
```json
"frequencyCaps": [
  { "max_impressions": 3,  "window": { "interval": 1, "unit": "days"  } },
  { "max_impressions": 10, "window": { "interval": 1, "unit": "hours" } }
]
```

**Where to set them:** `frequencyCaps` is accepted on CREATE and UPDATE of:
- Advertisers — `create_advertiser`, `update_advertiser`
- Campaigns — `create_campaign`, `update_campaign`
- Creatives — `update_creative`

**Replace semantics (on UPDATE):**
- **Omit the field** → existing caps are left unchanged.
- **Pass `[]`** → all existing caps on that entity are cleared.
- **Pass a non-empty array** → the full set is replaced with the array supplied.

**Where to read them:**
- Single-GET (`get_advertiser`, `get_campaign`, `get_creative`) returns the `frequencyCaps` array.
- LIST endpoints (`list_advertisers`, `list_campaigns`, `list_creatives`) do NOT return `frequencyCaps` — call the single-GET to read them.

**Response cap shape** additionally includes `id`, `targetLevel` (`"ADVERTISER"` | `"CAMPAIGN"` | `"CREATIVE"`), `targetId`, `createdAt`, `updatedAt`, and `archivedAt` (null for active caps).

**Gathering inputs from the user:** if the user says "cap at 3 per day" or "no more than 10 impressions per week", translate to the shape above. Always confirm the scope (advertiser-wide vs. campaign-specific vs. creative-specific) before writing — they are independent and enforcement is the union of all applicable caps.

---

### Syndication

Syndicate audiences, event sources, or catalogs to ADCP agents. Tracks status asynchronously via webhooks.

#### Syndicate Resource

```http
POST /api/v2/buyer/advertisers/{advertiserId}/syndicate
{
  "resourceType": "AUDIENCE",
  "resourceId": "aud_12345",
  "adcpAgentIds": ["agent-abc-123", "agent-def-456"],
  "enabled": true
}
```

**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `resourceType` | string | Yes | `AUDIENCE`, `EVENT_SOURCE`, or `CATALOG` |
| `resourceId` | string | Yes | ID of the resource to syndicate |
| `adcpAgentIds` | string[] | Yes | Array of ADCP agent ID strings (min 1) |
| `enabled` | boolean | Yes | Whether to enable or disable syndication |

**Response (201):** Returns the syndication status records for each agent.

#### Query Syndication Status

```http
GET /api/v2/buyer/advertisers/{advertiserId}/syndication-status?resourceType=AUDIENCE&status=SYNCING&limit=20&offset=0
```

**Query Parameters (all optional):**
| Parameter | Type | Description |
|-----------|------|-------------|
| `resourceType` | string | Filter by `AUDIENCE`, `EVENT_SOURCE`, or `CATALOG` |
| `resourceId` | string | Filter by specific resource ID |
| `adcpAgentId` | string | Filter by ADCP agent ID |
| `enabled` | string | Filter by `true` or `false` |
| `status` | string | Filter by `PENDING`, `SYNCING`, `COMPLETED`, `FAILED`, or `DISABLED` |
| `limit` | number | Max results (1-100, default 50) |
| `offset` | number | Pagination offset (default 0) |

---

### Tasks

Async operations (audience sync, media buy creation, etc.) return a task ID that can be polled for status.

#### Get Task Status

**Operation:** `get_task`
```http
GET /api/v2/buyer/tasks/{taskId}
```
**As operation:**
```json
{ "operation": "get_task", "pathParams": { "taskId": "<id>" } }
```

**Response:**
```json
{
  "task": {
    "taskId": "550e8400-e29b-41d4-a716-446655440000",
    "taskType": "audience_sync",
    "status": "completed",
    "resourceType": "audience",
    "resourceId": "aud_12345",
    "error": null,
    "response": { "audience_id": "aud_12345", "member_count": 15000 },
    "metadata": {},
    "retryAfterSeconds": null,
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:35:00.000Z"
  }
}
```

**Task types:** `audience_sync`, `media_buy_create`, `creative_sync`

**Status values:** `submitted`, `working`, `completed`, `failed`, `input-required`

**Error format** (AdCP-compatible, set when status is `failed`):
```json
{
  "code": "VALIDATION_ERROR",
  "message": "Invalid budget value",
  "field": "packages[0].budget",
  "suggestion": "Budget must be positive",
  "recovery": "correctable"
}
```

**Notes:**
- Task IDs are UUIDs returned in 202 responses from async operations
- Poll this endpoint when webhooks are unavailable — use `retryAfterSeconds` for polling interval guidance
- `response` contains the original downstream response payload (varies by task type)
- Tasks are scoped to the caller's customer — you cannot access another customer's tasks

---

### Property Lists

Property lists define which inventory an advertiser targets (include lists) or avoids (exclude lists). They accept AdCP-typed identifiers covering websites, mobile apps (iOS / Android), and CTV apps (Roku, Fire TV, Samsung). Lists are scoped to an advertiser and automatically apply to all campaigns under that brand's targeting profile.

#### Identifier types (AdCP-aligned)

| Type | Resolves via | Example value |
|------|--------------|---------------|
| `domain` | `Domain.domain` (`SITE`) | `nytimes.com` |
| `subdomain` | `Domain.domain` (`SITE`) | `news.example.com` |
| `ios_bundle` | `App.bundle` (Apple App Store) | `com.facebook.katana` |
| `android_package` | `App.bundle` (Google Play) | `com.facebook.katana` |
| `apple_tv_bundle` | `App.bundle` (Apple App Store) | `com.netflix.Netflix` |
| `bundle_id` (generic fallback) | `App.bundle` (any store) | `com.example.app` |
| `apple_app_store_id` | `Domain.domain` (`APPLE_APP_STORE`) — numeric ID | `284882215` |
| `google_play_id` | `Domain.domain` (`GOOGLE_PLAY_STORE`) | `com.example.app` |
| `roku_store_id` | `Domain.domain` (`ROKU`) | `12` |
| `fire_tv_asin` | `Domain.domain` (`AMAZON`) | `B00X4WHP5E` |
| `samsung_app_id` | `Domain.domain` (`SAMSUNG`) | `G19173000091` |

A single mobile app may appear in a property list under multiple identifier types (e.g. `ios_bundle` AND `apple_app_store_id`); each is resolved independently against the AAO property registry / local DB.

Read-side normalization: `apple_tv_bundle` and `ios_bundle` share the same `App.bundle` storage (`appStore=APPLE_APP_STORE`), so an `apple_tv_bundle` write reads back as `ios_bundle` on subsequent `get_property_list` / `list_property_lists` responses. The generic `bundle_id` fallback similarly normalizes to the resolved app row's store-typed form (`ios_bundle` or `android_package`). Submit the type that best matches the AdCP property registry; expect the response to carry the canonical store-typed form.

#### Create Property List

Create a named include or exclude list. Identifiers are resolved to internal property records; anything that cannot be resolved is returned in `unresolvedIdentifiers`. Identifiers found in the AAO registry but not yet locally targetable are returned in `registeredIdentifiers`.

**Operation:** `create_property_list`
```http
POST /api/v2/buyer/advertisers/{advertiserId}/property-lists
```

**Request body — typed identifiers (preferred for mixed inputs):**
```json
{
  "name": "Q1 Campaign - Premium Inventory",
  "purpose": "include",
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "ios_bundle", "value": "com.facebook.katana" },
    { "type": "android_package", "value": "com.facebook.katana" },
    { "type": "apple_app_store_id", "value": "284882215" },
    { "type": "roku_store_id", "value": "12" }
  ]
}
```

**Request body — domains-only shorthand:**
```json
{
  "name": "Q1 Campaign - Premium Publishers",
  "purpose": "include",
  "domains": ["nytimes.com", "cnn.com", "bbc.co.uk"]
}
```

`domains` and `identifiers` may both be provided in the same request; the combined total must be 1..100,000. `domains: [...]` is shorthand equivalent to `identifiers: [{type: "domain", value: ...}]`.

> ⚠️ **Use `create_property_list` for small lists only (~500 entries or fewer).** For larger lists, especially anything in the thousands, direct the user to the multipart upload endpoint described below. Batching a single logical list across multiple `create_property_list` calls produces multiple disconnected lists, not one merged list, and is fragile when the identifier set is large.
>
> ⚠️ **Mandatory verification.** After a successful create, you MUST immediately call `list_property_lists` for the same advertiser and confirm the new `listId` appears with the expected `propertyCount`. Report the verified count to the user. If the listId is absent or the count is unexpectedly low, treat the create as failed and surface that to the user. Do not paraphrase or summarize a write as successful without this server-confirmed check.

> **Cascade on create.** Creating a property list automatically pushes it to all currently-active media buys for the same advertiser (same behavior as `update_property_list`). The `cascadeSummary` in the response shows counts. For buys created after the list exists but without the list, use `attach_property_list_to_campaign`.

**As operation:**
```json
{ "operation": "create_property_list", "pathParams": { "advertiserId": "<id>" }, "body": { "name": "Q1 Mixed", "purpose": "include", "identifiers": [{ "type": "domain", "value": "nytimes.com" }, { "type": "ios_bundle", "value": "com.facebook.katana" }] } }
```

**Response (201):**
```json
{
  "listId": "42",
  "name": "Q1 Mixed",
  "purpose": "include",
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" }
  ],
  "unresolvedIdentifiers": [
    { "type": "ios_bundle", "value": "com.facebook.katana" }
  ],
  "registeredIdentifiers": [],
  "domains": ["nytimes.com"],
  "unresolvedDomains": [],
  "registeredDomains": [],
  "propertyCount": 14,
  "resolutionSummary": {
    "totalRequested": 2,
    "resolvedCount": 1,
    "registeredCount": 0,
    "unresolvedCount": 1,
    "resolutionRate": 0.5
  },
  "createdAt": "2026-03-16T10:00:00.000Z",
  "updatedAt": "2026-03-16T10:00:00.000Z"
}
```

**Response field reference:**
- `identifiers` — typed `{type, value}[]` actually resolved to local Property rows. **Persisted** as catalog membership.
- `unresolvedIdentifiers` — typed identifiers with no matching local Property record. For app types, this means no `App` row (or `Domain` row, for store-ID types) matches the value. **Transient** — see persistence note below.
- `registeredIdentifiers` — typed identifiers found in the AAO registry but not yet locally targetable. Today only `domain` types can land here — non-domain types are not auto-registered with AAO. **Transient** — see persistence note below.
- `domains` / `unresolvedDomains` / `registeredDomains` — convenience views of the above filtered to `type: "domain"`. App identifiers (any non-domain type) are NOT in these arrays.
- `resolutionSummary` — counts and `resolutionRate` (0..1) over the deduplicated, normalized input.

**Persistence note:** only `identifiers` (the resolved set) is stored on the property list — as foreign-key links to existing `Property` rows. `unresolvedIdentifiers` and `registeredIdentifiers` are **transient output of the resolution call**, not persisted state. They appear in the response of any create/update that runs resolution and are then dropped — a subsequent GET will return empty arrays for them, and a name-only PUT will not return them either. To re-surface unresolved/registered values for the same list, re-submit the identifier set on a PUT.

**Always surface `resolutionSummary` to the user** so they know how many of their submitted identifiers will actually target.

#### Upload Property List (xlsx / csv)

For large lists, especially anything in the thousands, the user should upload an xlsx or csv file rather than batch identifiers through `create_property_list`. The agent does not drive this directly because the MCP `api_call` surface is JSON-only; instead, direct the user to call the multipart endpoint.

```http
POST /api/v2/buyer/advertisers/{advertiserId}/property-lists/upload
Content-Type: multipart/form-data

file:    <the xlsx or csv file>
name:    "Q1 2026 - Global Exclusions"
purpose: "exclude"
```

Parsing rules:
- xlsx, xls, or csv accepted (max 10MB)
- first column = identifier (additional columns ignored)
- header row auto-detected (skipped when the first cell matches `domain|identifier|url|host|app`)
- blank rows and whitespace-only cells dropped
- duplicates deduplicated (first-seen order preserved)
- hard limit: 100,000 identifiers after dedup

**Response (201)** matches `create_property_list` plus an `upload` block with `filename`, `sizeBytes`, `totalRows`, `skippedHeader`, and `parsedIdentifiers`. **Always surface `resolutionSummary` AND the upload counts** to the user so they can confirm the row count matches their file. Apply the same mandatory verification as `create_property_list`: after upload, call `list_property_lists` and confirm the new `listId` is present with the expected `propertyCount`.

When a user asks the agent to ingest a large exclusion list, the agent's response should be a short instruction with the URL above, the required form fields, and the example `curl`:

```bash
curl -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -F file=@exclusions.xlsx \
  -F name="Q1 2026 - Global Exclusions" \
  -F purpose=exclude \
  https://api.scope3.com/api/v2/buyer/advertisers/{advertiserId}/property-lists/upload
```

#### List Property Lists

**Operation:** `list_property_lists`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/property-lists?purpose=include
```
**As operation:**
```json
{ "operation": "list_property_lists", "pathParams": { "advertiserId": "<id>" }, "params": { "purpose": "include" } }
```

**Response:** each row is the property list **summary** shape — `listId`, `name`, `purpose`, `propertyCount`, `createdAt`, `updatedAt`. The resolved `domains[]`, `unresolvedDomains[]`, `registeredDomains[]`, `filters`, `resolutionSummary`, and `cascadeSummary` are NOT on the summary — call `get_property_list` for the full resource.

#### Get Property List

**Operation:** `get_property_list`
```http
GET /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId}
```
**As operation:**
```json
{ "operation": "get_property_list", "pathParams": { "advertiserId": "<id>", "listId": "<id>" } }
```

#### Update Property List

Update name and/or replace the identifier set entirely. Both `domains` and `identifiers` are optional; if either is provided, the existing identifier set on the list is replaced. When identifiers change, the response includes a `resolutionSummary` and a `cascadeSummary` with counts of active media buys re-synced.

> ⚠️ **Each update REPLACES the full identifier set.** Do NOT split the identifier set across multiple update calls; a second call will wipe out the first batch. For thousands of entries, direct the user to re-upload via the multipart endpoint instead.
>
> ⚠️ **Mandatory verification.** After a successful update that changed identifiers, call `get_property_list` and confirm the returned `propertyCount` matches your expectation. Report the verified count to the user. Do not summarize the update as successful without this server-confirmed check.

**Operation:** `update_property_list`
```http
PUT /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId}
```

**Request body — typed identifiers:**
```json
{
  "name": "Updated List Name",
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "ios_bundle", "value": "com.facebook.katana" }
  ]
}
```

**Request body — domains-only shorthand:**
```json
{
  "name": "Updated List Name",
  "domains": ["nytimes.com", "washingtonpost.com"]
}
```

#### Property Lists Applied to a Campaign

Use this to answer "is an exclusion or inclusion list actually applied to this campaign?". Aggregates the property lists referenced by every package on the campaign's media buys (via `targeting_overlay.property_list`), so the response reflects the real applied state — not just lists defined on the advertiser.

**Operation:** `get_campaign_property_lists`
```http
GET /api/v2/buyer/campaigns/{campaignId}/property-lists
```
**As operation:**
```json
{ "operation": "get_campaign_property_lists", "pathParams": { "campaignId": "<id>" } }
```

**Response:**
```json
{
  "campaignId": "cmp_xyz",
  "propertyLists": [
    {
      "listId": "1738",
      "name": "Q1 2026 - Global Exclusions",
      "purpose": "exclude",
      "propertyCount": 52309,
      "createdAt": "2026-05-13T13:33:51.000Z",
      "updatedAt": "2026-05-13T13:33:51.000Z",
      "viaMediaBuys": [
        { "mediaBuyId": "mb_abc", "packageIds": ["pkg_1", "pkg_2"] }
      ]
    }
  ],
  "summary": { "totalLists": 1, "includeCount": 0, "excludeCount": 1 }
}
```

**An empty `propertyLists` array is the authoritative answer that no lists are applied** — do not infer presence from the campaign brief, constraints, or anything else. If the user asks whether exclusions are in force, call this op and report exactly what it returns.

Alternative: `get_campaign` accepts `params.includePropertyLists=true`, which embeds the same `{ propertyLists, summary }` payload as a `propertyLists` field on the campaign response. Use that when you're already fetching the campaign and want to save a round trip.

#### Retroactively Attach a Property List to a Campaign

**Operation:** `attach_property_list_to_campaign`
```http
POST /api/v2/buyer/campaigns/{campaignId}/property-lists
```
**As operation:**
```json
{ "operation": "attach_property_list_to_campaign", "pathParams": { "campaignId": "<id>" }, "body": { "propertyListId": "<listId>" } }
```

Use this when a property list was created **after** the campaign's media buys were already active, or when buys were created without the list in their targeting overlay. Only **include lists** can be attached this way; exclude lists use a different targeting path and are not cascaded via this endpoint. The list must be an include list configured for the campaign's advertiser; attaching a list from a different advertiser or an exclude list returns a 400.

**Response (200):**
```json
{
  "campaignId": "campaign_abc",
  "propertyListId": "1756",
  "cascade": {
    "totalMediaBuys": 3,
    "updatedCount": 3,
    "failedCount": 0,
    "skippedCount": 0
  }
}
```

After calling this op, **always call `get_campaign_property_lists`** to confirm the list now appears. A `failedCount > 0` means some buys couldn't be updated: surface that to the user and tell them to retry or check that the buys are still ACTIVE.

#### Delete Property List

Archives the property list. The list remains associated with the advertiser but is no longer active.

**Operation:** `delete_property_list`
```http
DELETE /api/v2/buyer/advertisers/{advertiserId}/property-lists/{listId}
```
**As operation:**
```json
{ "operation": "delete_property_list", "pathParams": { "advertiserId": "<id>", "listId": "<id>" } }
```

**Recommended workflow:**
1. Create a property list with initial identifiers (web, mobile, CTV, or any mix).
2. Use the check endpoint (below) to validate domain entries against the AAO registry; non-domain types (mobile/CTV) are not validated upstream and are returned in the `assess` bucket.
3. Update the list based on check results (remove blocked domains, apply canonical corrections).
4. All campaigns under the advertiser automatically inherit the targeting.
5. Property lists are automatically passed to sales agents during product discovery via the ADCP `property_list` field, including all typed identifiers.

#### Resolve Property List (ADCP)

Returns a property list in ADCP `GetPropertyListResponse` format. The `identifiers` array contains every typed identifier the list resolves to (domains, mobile bundles/store IDs, CTV store IDs). Used by sales agents to resolve a `PropertyListReference` received during product discovery. Authenticated via HMAC token (not platform auth).

```http
GET /lists/{listId}
Authorization: Bearer {auth_token}
```

**Response:**
```json
{
  "list": {
    "list_id": "123",
    "name": "Premium inventory"
  },
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "domain", "value": "cnn.com" },
    { "type": "ios_bundle", "value": "com.spotify.music" },
    { "type": "android_package", "value": "com.spotify.music" },
    { "type": "apple_app_store_id", "value": "324684580" }
  ],
  "resolved_at": "2026-03-17T12:00:00.000Z",
  "cache_valid_until": "2026-03-18T12:00:00.000Z"
}
```

#### Check Property List

Validate a mixed list of typed identifiers against the AAO Community Registry. For `domain` entries, identifies blocked entries (ad servers, CDNs, trackers), normalizes URLs (strips www/m prefixes), removes duplicates, and flags unknown values. Non-domain types (mobile/CTV apps) are not currently checked against AAO and are returned in the `assess` bucket pending upstream support.

**Operation:** `check_property_list`
```http
POST /api/v2/buyer/property-lists/check
```

**Request body — typed identifiers:**
```json
{
  "identifiers": [
    { "type": "domain", "value": "nytimes.com" },
    { "type": "domain", "value": "www.cnn.com" },
    { "type": "domain", "value": "doubleclick.net" },
    { "type": "ios_bundle", "value": "com.facebook.katana" }
  ]
}
```

**Request body — domains-only shorthand:**
```json
{
  "domains": ["nytimes.com", "www.cnn.com", "doubleclick.net", "unknown-site.xyz"]
}
```

`domains` and `identifiers` may both be provided in the same request; combined total must be 1..100,000.

**As operation:**
```json
{ "operation": "check_property_list", "body": { "identifiers": [{ "type": "domain", "value": "nytimes.com" }, { "type": "ios_bundle", "value": "com.facebook.katana" }] } }
```

**Response:**
```json
{
  "summary": { "total": 5, "remove": 1, "modify": 1, "assess": 2, "ok": 1 },
  "remove": [
    { "input": "doubleclick.net", "canonical": "doubleclick.net", "reason": "blocked", "domain_type": "ad_server", "identifier": { "type": "domain", "value": "doubleclick.net" } }
  ],
  "modify": [
    { "input": "www.cnn.com", "canonical": "cnn.com", "reason": "www prefix removed", "identifier": { "type": "domain", "value": "www.cnn.com" } }
  ],
  "assess": [
    { "domain": "unknown-site.xyz", "identifier": { "type": "domain", "value": "unknown-site.xyz" } },
    { "domain": "com.facebook.katana", "identifier": { "type": "ios_bundle", "value": "com.facebook.katana" } }
  ],
  "ok": [
    { "domain": "nytimes.com", "source": "registry", "identifier": { "type": "domain", "value": "nytimes.com" } }
  ],
  "reportId": "rpt_abc123",
  "reportIds": ["rpt_abc123"]
}
```

**Result buckets:**
- `remove`: Domains to remove — duplicates or blocked (ad servers, CDNs, trackers, intermediaries). Non-domain types never appear here.
- `modify`: Domains that were normalized (e.g. `www.example.com` → `example.com`). Use the `canonical` value. Non-domain types never appear here.
- `assess`: Unknown domains not in the registry and not blocked — may need manual review. **All non-domain identifiers (mobile/CTV apps) land here** (AAO does not currently check them).
- `ok`: Domains found in the registry with no issues. Non-domain types never appear here.

Each bucket entry carries an `identifier: { type, value }` field that mirrors the input — use this rather than the `domain`/`input` field to disambiguate types.

**`reportId` / `reportIds`:** present only when at least one `domain` entry was submitted. Omitted entirely when the request contained only non-domain types (no AAO call is made). When domain input is chunked into multiple registry calls, `reportIds` contains all chunk IDs and `reportId` equals `reportIds[0]` for back-compat.

**Limits:** 1–100,000 entries per request, chunked server-side into 10,000-domain registry calls.

#### Get Property Check Report

Retrieve a stored property check report by ID. Reports expire after 7 days.

**Operation:** `get_property_list_report`
```http
GET /api/v2/buyer/property-lists/reports/{reportId}
```
**As operation:**
```json
{ "operation": "get_property_list_report", "pathParams": { "reportId": "<id>" } }
```

**Response:**
```json
{
  "summary": { "total": 4, "remove": 1, "modify": 1, "assess": 1, "ok": 1 }
}
```

### Buyer Activity

Use the typed `list_buyer_activity` read for API activity questions. For buyer
accounts, it opens the portable Activity page inside MCP clients that support
MCP Apps and returns a compact
model-readable summary. Calls is the default view and covers captured REST,
MCP, and A2A operations, workload identity, application outcome, latency,
environment, and correlation IDs. Selecting a call reveals authorized,
redacted request fields and available response/error/header/timing detail and
can invoke the shared typed `ask_murph` tool with the exact activity UID. The
page renders Murph's answer or retry state inline without creating a synthetic
chat turn or changing the host composer. When a user asks about a call in a
prompt instead, use the same authorized Activity diagnostics and answer the
question directly; open Buyer Activity when the surrounding calls or evidence
would help them inspect the issue.
Recorded Changes remain a separate view; never describe that feed as complete
call history.

```json
{
  "view": "calls",
  "outcome": "failed",
  "surface": "mcp"
}
```

The page owns `ui://agentic-api/buyer-activity/mcp-app.html`; do not call or
invent a separate `open_buyer_activity` tool. Hosts without MCP Apps support
should summarize the typed result and may query the REST operations below.

### Changes feed

Lists audit log events for the buyer — campaign changes, creative updates, media buy transitions, advertiser edits, and other buyer-side actions. Customer-scoped.

```http
GET /api/v2/buyer/audit-logs?take=50&skip=0
```
**As operation:**
```json
{ "operation": "list_activity", "params": { "take": "50" } }
```

Query parameters (all optional):
- `startDate`, `endDate` — ISO timestamps that limit the range
- `advertiserId` — filter to a single advertiser
- `campaignId` — filter to a single campaign (includes its media buys, creatives, etc.)
- `resourceTypes` — repeated (`?resourceTypes=CAMPAIGN&resourceTypes=CREATIVE`) or comma-separated
- `take` (max 500, default 50 over REST; default 100 and max 100 through MCP), `skip`

**Response:**
```json
{
  "data": {
    "logs": [
      {
        "id": 1,
        "timestamp": "2026-04-01T00:00:00Z",
        "action": "UPDATE",
        "resourceType": "CAMPAIGN",
        "resourceId": "camp_abc123",
        "resourceName": "Spring Promo",
        "advertiserId": 42,
        "userEmail": "user@example.com",
        "description": "Updated campaign Spring Promo"
      }
    ],
    "total": 123
  },
  "meta": { "pagination": { "skip": 0, "take": 10, "total": 123, "hasMore": true } }
}
```

The `list_activity` operation is the Changes view inside Buyer Activity. It
shows who changed what across the buyer's advertisers and campaigns, whether
the actor was a person, the agent, or automation, and whether each action
succeeded, was denied, or failed. It is not a complete API-call ledger and must
not be described as “all API activity.” Use `list_buyer_activity` for “show my
activity,” API failures, workloads, and latency; answer a focused one-off
who/what/when change question directly when a Page is unnecessary.

---

## Error Handling

### Hard Failures vs. No Products Found

These are two distinct outcomes — do NOT conflate them:

**Hard failures** — the agent returned an actual error response. Examples:
- Auth or tenant context errors
- Authentication required
- Data corruption errors
- MCP endpoint not responding

These surface as non-2xx HTTP responses or error payloads. Treat as errors that need investigation.

**Soft failures / no products** — the agent responded successfully (HTTP 200) but returned 0 products. This is **not an error**. It means the brief did not match available inventory. Do NOT tell the user the agent "failed." See "When discovery returns no products" above for how to handle this.

### REST API Error Format

All REST error responses use a standard envelope:

```json
{
  "data": null,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Human-readable error description",
    "field": "start_date",
    "details": {}
  }
}
```

- `code` — machine-readable error code (see table below)
- `message` — human-readable description
- `field` — (optional) the specific field that caused the error
- `details` — (optional) additional context

### MCP Tool Error Format

Tool errors return `isError: true` with a structured error object in `structuredContent` and a human-readable message in `content`:

```json
{
  "content": [{ "type": "text", "text": "Budget is below the minimum" }],
  "structuredContent": {
    "code": "VALIDATION_ERROR",
    "message": "Budget is below the minimum",
    "field": "packages[0].budget",
    "suggestion": "Minimum budget is $100"
  },
  "isError": true
}
```

- `code` — machine-readable error code (see table below)
- `message` — human-readable description
- `field` — (optional) field path (e.g. `packages[0].budget`)
- `suggestion` — (optional) suggested fix

### Error Codes

| Code | HTTP Status | Resolution |
|------|-------------|------------|
| `VALIDATION_ERROR` | 400 | Check request body against schema |
| `UNAUTHORIZED` | 401 | Verify API key/auth |
| `ACCESS_DENIED` | 403 | Check permissions |
| `NOT_FOUND` | 404 | Verify resource ID exists |
| `CONFLICT` | 409 | Resource already exists (e.g., brand) |
| `RATE_LIMITED` | 429 | Wait and retry |
| `INVALID_STATE` | 400 | Operation not allowed in current state |
| `CUSTOMER_SCOPE_REQUIRED` | 403 | The session has access to multiple customers and a mutation was attempted without scoping. Call the `customer_switch` MCP tool with one of the `customerId`s from `details.availableCustomers` (already in the error), then retry the ORIGINAL request unchanged. Do NOT invent other causes (flag not enabled, schema mismatch, permissions, allowlist) — this error is exclusively about session scoping. Do NOT call `customer_list` first; the choices are already in the error payload. |
| `INTERNAL_ERROR` | 500 | Contact support |

---

## Notifications

Notifications are events about resources you manage — campaigns going unhealthy, creatives syncing, agents registering, etc. They follow a `resource.action` taxonomy (e.g., `campaign.unhealthy`, `creative.sync_failed`).

### List Notifications

**Operation:** `list_notifications`
```http
GET /api/v2/notifications?unreadOnly=true&limit=20&offset=0
```
**As operation:**
```json
{ "operation": "list_notifications", "params": { "unreadOnly": "true", "limit": "20", "offset": "0" } }
```

**Query Parameters (all optional):**
- `unreadOnly` (`true`/`false`): Show only unread notifications
- `brandAgentId` (number): Filter by brand agent
- `types` (comma-separated): Filter by event types (e.g., `campaign.unhealthy,creative.sync_failed`)
- `campaignId` (string): Filter by campaign
- `creativeId` (string): Filter by creative
- `limit` (number): Results per page (default: 50, max: 100)
- `offset` (number): Pagination offset

**Response:** each row is the notification **summary** shape — `id`, `type`, `status`, `read`, `acknowledged`, `messagePreview`, `createdAt`. The full `data` payload is NOT on the summary.

```json
{
  "notifications": [
    {
      "id": "notif_1709123456_abc123",
      "type": "campaign.unhealthy",
      "status": "warning",
      "messagePreview": "Campaign \"Q1 CTV\" is unhealthy",
      "read": false,
      "acknowledged": false,
      "createdAt": "2026-03-01T12:00:00Z"
    }
  ],
  "totalCount": 15,
  "unreadCount": 3,
  "hasMore": false
}
```

`messagePreview` is the summary text (truncated at 200 characters). The full `data` payload (campaignId, campaignName, etc.) is not returned by the list — fetch the underlying resource (campaign, creative, etc.) by ID for the full context.

### Mark Notification as Read

**Operation:** `read_notification`
```http
POST /api/v2/notifications/{notificationId}/read
```
**As operation:**
```json
{ "operation": "read_notification", "pathParams": { "notificationId": "<notificationId>" } }
```

Marks a single notification as seen. No request body required.

### Mark Notification as Acknowledged

**Operation:** `acknowledge_notification`
```http
POST /api/v2/notifications/{notificationId}/acknowledge
```
**As operation:**
```json
{ "operation": "acknowledge_notification", "pathParams": { "notificationId": "<notificationId>" } }
```

Marks a notification as dealt with. Acknowledged notifications are automatically cleaned up after 90 days. No request body required.

### Mark All Notifications as Read

**Operation:** `read_all_notifications`
```http
POST /api/v2/notifications/read-all
```
**As operation:**
```json
{ "operation": "read_all_notifications" }
```

**Optional body:**
```json
{ "brandAgentId": 123 }
```

If `brandAgentId` is provided, only marks notifications for that agent as read. Otherwise marks all unread notifications as read.

### Proactive Notification Setup

Unread notifications are automatically included in `help` and `ask_about_capability` tool responses. To ensure your AI agent surfaces them to users at the start of every session, add the following to your client configuration:

- **Claude Desktop**: Create a Project and add to the project instructions: `When using Scope3 tools, always start by calling the help tool. The response includes unread notifications — summarize those for the user before answering their question.`
- **Claude Code**: Add the same instruction to your `CLAUDE.md` or project instructions.
- **API / Custom Agent**: Add it to your system prompt.
- **ChatGPT Custom GPT**: Add it to your Custom GPT's instructions.

---

## Optimization Suggestions

Optimization suggestions are generated by Scope3's AI optimization model for active media buys. When `optimizationApplyMode` is `"MANUAL"` (the default), suggestions wait for human approval before being applied.

### List Optimization Suggestions
```http
GET /api/v2/buyer/optimization-suggestions?status=HIL_WAITING&limit=20&offset=0
```

**Query Parameters (all optional):**
- `campaignId` (number): Filter by campaign
- `mediaBuyId` (number): Filter by media buy
- `status` (string): Filter by status. Values: `CREATED`, `CREATED_FAILED`, `INGESTED`, `INGESTED_FAIL`, `HIL_WAITING`, `HIL_APPROVED`, `HIL_REJECTED`, `APPLIED`, `APPLIED_FAIL`, `REJECTED`, `EXPIRED`
- `limit` (number): Results per page (default: 20, max: 100)
- `offset` (number): Pagination offset

**Response:**
```json
{
  "suggestions": [
    {
      "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
      "campaignId": "123",
      "mediaBuyId": "456",
      "optimizationMetric": "emissions",
      "status": "HIL_WAITING",
      "productAdjustmentCount": 3,
      "bidAdjustmentCount": 1,
      "createdAt": "2026-03-20T00:00:00.000Z",
      "statusUpdatedAt": "2026-03-20T01:00:00.000Z"
    }
  ],
  "totalCount": 5,
  "hasMore": false
}
```

**Display Requirements:** For each suggestion, show the suggestion ID, campaign/media buy IDs, optimization metric, current status, number of product and bid adjustments, and when it was created.

### Get Optimization Suggestion
```http
GET /api/v2/buyer/optimization-suggestions/{suggestionId}
```

Returns the full suggestion payload including product adjustments, bid adjustments, scaling factor, and rationale.

**Response:**
```json
{
  "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
  "optimizerRunId": "run-abc123",
  "campaignId": "123",
  "mediaBuyId": "456",
  "suggestionTimestamp": "2026-03-20T00:00:00.000Z",
  "modelVersion": "v1.0",
  "campaignSummary": { "name": "Q1 CTV Campaign" },
  "optimizationMetric": "emissions",
  "mediaBuyScalingFactor": 1.2,
  "mediaBuyScalingRationale": "Scale up for improved emissions performance",
  "productAdjustments": [
    { "productId": "prod_1", "action": "add", "reason": "High performance" }
  ],
  "bidAdjustments": [
    { "productId": "prod_2", "bidChange": 0.5, "reason": "Reduce CPM" }
  ],
  "createdAt": "2026-03-20T00:00:00.000Z",
  "status": "HIL_WAITING",
  "statusReason": null,
  "statusError": null,
  "statusUpdatedAt": "2026-03-20T01:00:00.000Z"
}
```

**Display Requirements:** Show all suggestion details including product adjustments (with actions and reasons), bid adjustments, scaling factor and rationale, and the current status. Present the data clearly so the user can make an informed approve/reject decision.

### Approve Optimization Suggestion
```http
POST /api/v2/buyer/optimization-suggestions/{suggestionId}/approve
```

Approves a suggestion that is in `HIL_WAITING` status. No request body required.

**Response:**
```json
{
  "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "HIL_APPROVED"
}
```

**Error:** Returns 409 Conflict if the suggestion is not in `HIL_WAITING` status.

### Reject Optimization Suggestion
```http
POST /api/v2/buyer/optimization-suggestions/{suggestionId}/reject
```

Rejects a suggestion that is in `HIL_WAITING` status.

**Optional body:**
```json
{ "reason": "Not aligned with current strategy" }
```

**Response:**
```json
{
  "suggestionId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "HIL_REJECTED"
}
```

**Error:** Returns 409 Conflict if the suggestion is not in `HIL_WAITING` status.

---

## Common Mistakes to Avoid

1. **Creating campaign without advertiser** — Always create/verify advertiser first
2. **Skipping product discovery** — Always use `discover_products` operation to discover products; use `browse_discovery` operation to browse more
3. **Optimization without event source** — You need an event source (`eventSourceId`) before creating a campaign with event-based optimization goals
4. **Optimization without conversion data** — System needs events logged via event sources to optimize for ROAS/conversions
5. **Forgetting to execute** — Campaigns start in DRAFT status; must use `execute_campaign` operation
6. **Wrong endpoint path** — Always use `/api/v2/buyer/` prefix
7. **Creating advertiser without brand** — `brand` is required. If brand resolution succeeds through enrichment, the advertiser already exists; show `brandWarning` and the enriched details, then offer `saveBrand: true` on `update_advertiser` only if the user wants registry persistence. If no enrichment data is found but the user confirms the advertiser name and brand domain, retry with `saveBrand: true`. Only direct the user to external registration if there is no confirmed name/domain to save or the registry save fails.
8. **Auto-selecting products for the user** — When the user wants to browse/select inventory, ALWAYS present discovery results and let them choose
9. **Defaulting to a configuration without asking** — When the user says "create a campaign" without specifying how to configure it, ask them to choose (product discovery or performance metrics)
10. **Fabricating field values** — NEVER guess or make up values for required fields. Always ask the user or use values from previous API responses
11. **Making multiple API calls in one turn** — ONE discovery/mutating call per turn. Present results, END YOUR TURN, wait for the user.
12. **Missing bid price for non-fixed pricing** — If a product's pricing option has `isFixed: false`, `bidPrice` is REQUIRED in the `add_discovery_products` request. Read it from the product's `pricingOptions` (`rate` or `floorPrice`) in the discovery response. Do NOT ask the user — the value comes from the product data.
13. **Summarizing list responses as prose** — When listing advertisers, sales agents, or campaigns, NEVER reduce the response to a sentence like "You have 13 advertisers." Always show the structured per-item details specified in the Display Requirements for that endpoint. The user needs to see each item's operational details, not a count.
14. **Using user-provided account IDs for linking** — NEVER use an account ID or account name that the user provides verbally. For official adapters, IDs must come from `list_storefront_connection_accounts` or `list_storefront_connection_account_mappings`; for legacy/third-party external AdCP sources, IDs must come from `list_available_accounts`. If an account does not appear on the appropriate discovery surface, tell the user it was not found — do NOT pretend to link it.
15. **Missing credentialId with multiple credentials** — When a customer has multiple credentials for the same agent, the `list_available_accounts` operation requires `credentialId`. If omitted, the API returns an error with available credential IDs. Present those to the user and ask which to use, then retry with the chosen `credentialId`.
16. **Retrying a Meta execution without supplying pageId, pixelId, instagramActorId, or welcomeMessageFlowId** — If `execute_campaign` fails with "page_id is required", "pixel_id is required", "instagram_actor_id is required", or "requires ext.meta_welcome_message_flow_id", a DRAFT buy already exists. Do NOT retry execution unchanged (always fails) and do NOT re-add via `add_discovery_products` (updating the discovery selection does not fix the existing DRAFT — execute skips reconciliation for products already on the buy). The correct recovery is: call `update_media_buy` on the stuck DRAFT buy with `products: [{ "productId": "...", "pageId": "..." }]` (or `pixelId`/`instagramActorId`/`welcomeMessageFlowId`), then retry `execute_campaign`. For `pageId`/`pixelId` the valid IDs are in the error message — extract them, do not ask the user to look them up. For `instagramActorId` and `welcomeMessageFlowId`, the error does not enumerate candidates — for `instagramActorId` ask the user for their Instagram Business Account ID from Meta Business Settings; for `welcomeMessageFlowId` read the welcome-message-flow candidates from account resource readiness in the discovery/`get_products` response. Note: `pageId`, `pixelId`, `instagramActorId`, and `welcomeMessageFlowId` are only accepted while the buy is in DRAFT status — `update_media_buy` rejects them once the buy has left DRAFT. Any combination can be set in the same `products[]` entry when required together (e.g. a Meta Sales buy on a multi-page account).
