# Accounts Protocol Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/accounts/overview AdCP Accounts Protocol defines the commercial layer for advertising transactions — billing, operator authorization, and usage reporting between buyers, brands, and vendor agents. The Accounts Protocol defines the commercial layer beneath all AdCP vendor protocols. Every transaction — a media buy, a data signal, a content standards check — happens between parties that have a commercial relationship. The Accounts Protocol establishes that relationship and provides consumption reporting so vendors can track how their services were used. ## The commercial model Seven questions underlie every AdCP transaction: | Question | Answered by | Mechanism | | ---------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Who is the advertiser? | Brand registry | `brand.domain` resolves to `brand.json` | | Who operates on the brand's behalf? | Brand registry | `authorized_operators` in `brand.json` declares who can buy on the brand's behalf | | How does the operator authenticate? | Seller capabilities | `require_operator_auth` determines who must authenticate and which account reference shape is expected. | | What am I allowed to do on this account? | Caller scope | The `authorization` object on each per-account entry in [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) responses describes `allowed_tasks`, `field_scopes`, `scope_name`, and `read_only` for the calling agent. See [Caller authorization](#caller-authorization) below. | | Who gets billed? | Buyer declaration | Buyer passes `billing` in `sync_accounts` — `operator`, `agent`, or `advertiser`. Seller accepts or rejects. | | What was consumed? | Usage reporting | `report_usage` informs vendor agents how their services were used after delivery | The seller declares the account model in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) via `require_operator_auth`. That field declares who must authenticate; it does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. When `require_operator_auth` is `true` (**account-id namespaces**), operators authenticate independently and buyers pass seller-assigned `account_id` values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) and buyers MUST resolve an explicit `account_id` before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose `list_accounts` returning that singleton, and MAY omit it only when the same explicit `account_id` is supplied through another declared path or out-of-band onboarding. When `require_operator_auth` is `false` (**buyer-declared accounts**), the agent is trusted and the buyer declares brand/operator pairs via [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) to provision accounts. An **ad network** may use both models simultaneously — buyer-declared accounts on the buyer-facing side (the network is agent-trusted) and an account-id namespace with each underlying platform (the network authenticates as an operator). See the [Sponsored Intelligence guide — account model for networks](/dist/docs/3.1.20/sponsored-intelligence/networks#account-model-for-networks) for the full account chain: `buyer agent → network (buyer-declared) → AI platform (account-id namespace)`. After delivery, the orchestrator calls [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) to inform vendor agents (signals, governance, creative) how their services were consumed. This is not settlement — it's consumption reporting so the vendor can track earned revenue and verify billing. ## Scope The Accounts Protocol applies across all vendor protocols. An orchestrator establishes an account once per brand/operator pair per vendor agent and reuses the same account reference across all interactions with that agent: | Vendor Protocol | Account reference used for | | --------------- | -------------------------------------------------------- | | Media Buy | Rate cards, invoicing, campaign attribution | | Signals | Per-account pricing options, activation, usage reporting | | Governance | Content standards billing | | Creative | Creative service billing | The account reference may be a seller-assigned `account_id` (seller-owned namespaces, usually `require_operator_auth: true`) or a natural key — `brand` + `operator` (buyer-declared accounts, `require_operator_auth: false`). For buyer-declared accounts, the natural-key `AccountRef` MUST remain valid on subsequent calls even if the seller also echoes an internal `account_id`. For sandbox, account-id namespaces use pre-existing test accounts discovered via `list_accounts` or supplied out-of-band, while buyer-declared accounts declare sandbox via `sync_accounts` with `sandbox: true`. See [Account references](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) for details. ## Account Status Lifecycle Accounts progress through a defined set of states. Terminal states (`rejected`, `closed`) allow no further transitions. ``` sync_accounts ──▶ pending_approval ──▶ active │ │ │ (seller declines) ├── (credit limit / funds depleted) ▼ │ ▼ rejected (terminal) │ payment_required │ │ (buyer resolves billing) │ ▼ │ active │ ├── (seller suspends) ──▶ suspended │ │ │ (seller reactivates) ◀─┤ │ │ │ └──▶ closed (terminal) │ └── (seller or buyer closes) ──▶ closed (terminal) ``` **Transition rules:** * `pending_approval` → `active`: seller approves after credit/contract/identity review * `pending_approval` → `rejected`: seller declines. Terminal — buyer must submit a new account request. * `active` → `payment_required`: automatic when credit limit is reached or funds are depleted * `payment_required` → `active`: when the buyer resolves the outstanding balance. Sellers MAY auto-transition or MAY require manual re-activation. * `active` → `suspended`: seller-initiated (policy violation, billing dispute, fraud review). Sellers MUST notify orchestrators via webhook. * `suspended` → `active`: seller-initiated reactivation * `suspended` → `closed`: seller-initiated permanent closure * `active` → `closed`: seller or buyer-initiated permanent closure. Terminal. * Sellers MUST reject operations on accounts in terminal states with `ACCOUNT_NOT_FOUND` or an appropriate error ### Operations by Account Status Account status acts as a gate on which tasks are permitted. Read-only operations are always available; mutation operations are restricted based on status. | Task | `active` | `pending_approval` | `payment_required` | `suspended` | `rejected` / `closed` | | ------------------------ | -------- | ------------------ | ------------------ | ----------- | --------------------- | | `list_accounts` | Yes | Yes | Yes | Yes | Yes | | `get_account_financials` | Yes | Yes | Yes | Yes | No | | `get_products` | Yes | No | Yes | No | No | | `create_media_buy` | Yes | No | No | No | No | | `update_media_buy` | Yes | No | Yes | No | No | | `get_media_buys` | Yes | No | Yes | Yes | No | | `sync_creatives` | Yes | No | Yes | No | No | | `sync_catalogs` | Yes | No | Yes | No | No | | `sync_event_sources` | Yes | No | Yes | No | No | | `report_usage` | Yes | No | Yes | Yes | No | * `payment_required` blocks new spend (`create_media_buy`) but allows managing existing buys and resolving setup. Sellers SHOULD also reject `new_packages` within `update_media_buy` when the account is in `payment_required`, since adding packages is functionally equivalent to new spend. * `suspended` allows read-only access to existing data but blocks all mutations * Sellers MUST return `ACCOUNT_SUSPENDED` for blocked operations on suspended accounts and `ACCOUNT_PAYMENT_REQUIRED` for blocked operations on payment-required accounts ## Caller authorization Not every caller with access to an account has the same grant. A vendor agent may issue one calling agent a full scope and another agent a narrow read-plus-update scope. Authentication confirms the caller is who they claim to be; authorization answers "what is this caller allowed to do on this account?" **Applies to every vendor protocol.** The authorization mechanism described here is part of the shared Accounts Protocol — it applies to every agent that implements `sync_accounts` / `list_accounts`: media-buy sales agents, signals agents, governance agents, creative agents, brand agents. Signals agents scope activation vs. catalog access; governance agents scope audit read vs. plan management; creative agents scope library read vs. upload. Only the standard named scope `attestation_verifier` is Media Buy Protocol-specific (it binds to the **AAO Verified (Live)** qualifier); the rest of the machinery — `allowed_tasks`, `field_scopes`, `read_only`, `custom:`-prefixed scopes — is protocol-neutral. **Schema**: [`/schemas/3.1.20/core/account-authorization.json`](https://adcontextprotocol.org/schemas/3.1.20/core/account-authorization.json) Vendor agents that support scope introspection attach an `authorization` object to each per-account entry in [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) responses: ```json theme={null} { "account_id": "acc_acme_compliance", "name": "Acme c/o AAO Compliance", "status": "active", "billing": "operator", "authorization": { "allowed_tasks": [ "get_adcp_capabilities", "get_products", "get_media_buys", "get_media_buy_delivery", "list_creatives", "update_media_buy" ], "field_scopes": { "update_media_buy": ["reporting_webhook"] }, "scope_name": "attestation_verifier", "read_only": false } } ``` ### Fields | Field | Description | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `allowed_tasks` | Canonical snake\_case task names the caller may invoke against this account. Absence of a task MUST be read as "not permitted" — invoking an absent task returns `SCOPE_INSUFFICIENT`. | | `field_scopes` | Optional per-task allowlist of request fields the caller may set. Keys are task names; values are field paths. When a task appears here, any field outside the allowlist returns `FIELD_NOT_PERMITTED`. Implicit framing fields — typed entity references (`account`, `media_buy_id`, `package_id`, `creative_id`, `signal_id`, `format_id`, `proposal_id`, `plan_id`, `session_id`), concurrency/idempotency (`revision`, `idempotency_key`), buyer-side correlation (`buyer_ref`, `po_number`), mode flags (`dry_run`), pagination (`pagination`, `cursor`, `max_results`), and envelope fields (`context`, `ext`, `adcp_major_version`, `push_notification_config`) — are always permitted and do not need to appear in the allowlist. The list is non-exhaustive: any other typed entity-id parameter or query-shaping field on a read task SHOULD be treated as framing. | | `scope_name` | Optional named scope identifier. Only `attestation_verifier` is standardized (media-buy-specific, binds to the **AAO Verified (Live)** qualifier); agent-defined names MUST use the `custom:` prefix so typos of the standard value fail schema validation rather than pass through. | | `read_only` | Convenience flag. When true, mutations return `READ_ONLY_SCOPE` regardless of whether the task is in `allowed_tasks`. Omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone. | ### Semantics of presence and absence * **Present**: the vendor agent asserts the shape reflects its enforcement for this caller on this account at this moment. Stale-by-seconds is fine; systematically-divergent is non-conformant. * **Absent on a single account**: the vendor agent is not telling the caller their scope for that account. Caller falls back to error-driven discovery (try the task, handle the RBAC error codes). * **Absent on all accounts**: the vendor agent does not implement scope introspection. Callers MUST NOT infer access from absence — the vendor agent may still enforce scope locally and return `SCOPE_INSUFFICIENT` / `READ_ONLY_SCOPE` / `FIELD_NOT_PERMITTED` on any task call. * **`authorization` is optional for 3.x**. Vendor agents that want to avoid a breaking change can defer populating it. Populating is strictly additive — it lets callers preempt errors they would otherwise discover by trying. Vendor agents claiming the `attestation_verifier` standard scope (Media Buy Protocol-specific) **MUST** populate `authorization` — the **AAO Verified (Live)** attestation flow depends on verifying the advertised scope matches enforcement. ### Identity binding, refresh cadence, and consistency The `authorization` object is **implicitly scoped to the `(caller identity, account_id)` tuple** at read time. The same account returned to a different authenticated caller MAY return a different `authorization` object — that's the point of the RBAC model. Vendor agents MUST resolve caller identity from the authenticated request (not from any client-supplied field) and MUST bind the returned `authorization` to that resolved identity. **Refresh cadence.** * Callers SHOULD re-read `authorization` at least every **300 seconds** of active use against an account, via `sync_accounts` or `list_accounts` (filtered by the held `account`). * Vendor agents MUST reflect operator-initiated scope changes in `sync_accounts` / `list_accounts` responses **within 300 seconds** of the change being made. A compliant caller polling every 300s sees operator changes within at most one refresh cycle. * Vendor agents MAY cache the `authorization` object briefly but MUST NOT cache past 300 seconds without revalidation. * The 300s figure is a floor, not a target — vendor agents whose scope changes more frequently SHOULD surface them faster, and callers running **AAO Verified (Live)** attestation SHOULD probe at the check-7 cadence (at least once per rolling window, plus on every observed scope change). **Consistency.** For a given `(caller identity, account_id)` tuple, sequential reads within the refresh window MUST return identical `authorization` objects, modulo operator-initiated scope changes. Flicker from load-balanced or eventually-consistent backends — two reads 10 seconds apart returning different `allowed_tasks` because they hit different replicas — is non-conformant. Compliance engines and coding agents rely on scope stability for state tracking; a vendor agent that cannot guarantee it MUST omit `authorization` rather than populate it inconsistently. This is the concrete form of the "systematically-divergent is non-conformant" guarantee from the presence semantics above — it's verifiable: a conformance check can read the same account twice from the same identity inside the refresh window and diff the results. ### Buyer response to SCOPE\_INSUFFICIENT within the refresh window A single `SCOPE_INSUFFICIENT` response is observationally indistinguishable between two causes: a seller replica that has not yet propagated a valid grant (transient infrastructure artifact — will resolve), and a legitimate scope reduction by the operator (persistent — must surface). Before classifying the error as a definitive `correctable` signal requiring operator intervention, buyers MAY exhaust a small bounded retry budget to disambiguate the two: * **Retry budget:** no more than 3 attempts, each separated by 1–5 seconds of jittered backoff. This is disambiguation logic — establishing whether the scope is genuinely insufficient before trusting the `correctable` classification — not a recovery action for a correctable error. * **Not the 300s window:** the retry budget is not the seller's propagation SLA. Buyers SHOULD NOT wait out the full 300-second window on every false negative; up to 15 seconds of accumulated backoff delay is sufficient to absorb typical replica lag. * **After retries exhaust:** buyers MUST surface the error. "Surface" means: return a structured error to the calling layer (including account ID, the failed task, and `error.details.retry_count` with the number of attempts made) AND ensure the failure is visible in an operator-accessible channel — a structured log, dashboard alert, or notification. The escalation mechanism is implementation-defined; the requirement is that the error is not silently swallowed. `READ_ONLY_SCOPE` follows the same bounded-retry logic when the buyer suspects grant-propagation lag (a replica lagging on a write-grant update). Caution: if write access was recently *revoked*, a stale replica may accept a mutation that the revoked scope should have rejected. If bounded retries succeed, buyers MUST re-read `authorization` before treating the result as reliable — and if re-reading confirms that write access has been revoked, buyers MUST surface an alert to the operator flagging the mutation as potentially unauthorized; they MUST NOT silently accept it as correct. `FIELD_NOT_PERMITTED` does not follow this pattern. The agent-autonomous recovery path — strip the disallowed field and resubmit — supersedes the retry consideration. Buyers MUST NOT retry the identical failing request for `FIELD_NOT_PERMITTED`; they SHOULD correct and resubmit immediately. See [Authorization (RBAC)](/dist/docs/3.1.20/building/by-layer/L3/error-handling#authorization-rbac) for the normative retry exception clause on these codes. ### Standard named scope: `attestation_verifier` Media-buy sales agents advertising **AAO Verified (Live)** readiness (tracked in [#2965](https://github.com/adcontextprotocol/adcp/issues/2965)) MUST support a named scope identified by `scope_name: "attestation_verifier"` with the following minimum shape: * `allowed_tasks` (at minimum — vendor agents MAY include additional read-only tasks): * `get_adcp_capabilities` * `get_products` * `get_media_buys` * `get_media_buy_delivery` * `list_creatives` * `update_media_buy` * `field_scopes.update_media_buy`: `["reporting_webhook"]` * `read_only`: `false` The scope deliberately omits `create_media_buy`, `sync_creatives`, and all spend-committing or targeting-modifying fields on `update_media_buy`. It is narrowly designed for continuous-observability verification — the compliance engine can discover live campaigns, read inventory and creative state, attach a verification reporting webhook, and read delivery, but cannot book inventory, modify budgets, change flight dates, upload creatives, or cancel anything. `get_products` and `list_creatives` are included because **AAO Verified (Live)** observability requires sanity-reading the seller's declared inventory and the creative pipeline state on active buys. `attestation_verifier` is Media Buy Protocol-specific — it binds to the **AAO Verified (Live)** qualifier, which is a media-buy flow (live observation requires real ad delivery). Equivalent observability scopes for signals, governance, creative, or brand agents are not yet standardized; until they are, those agents use `custom:` scopes. **Reporting only — lifecycle-running is a future scope.** `attestation_verifier` is the *reporting* half of (Live): the engine observes campaigns the seller has trafficked, reads delivery, and attaches a verification webhook. It deliberately cannot create campaigns, attach creatives, or modify budgets. The complementary *lifecycle-running* role — for the AAO-operated canonical-campaign runner contemplated in [#3046](https://github.com/adcontextprotocol/adcp/issues/3046), where the engine traffics canonical PSAs end-to-end through a seller's live agent — needs a broader write scope (`attestation_runner`, tracked in [#3561](https://github.com/adcontextprotocol/adcp/issues/3561)). Today's brownfield enrollment (Path B) requires only `attestation_verifier`; the runner-side scope lights up alongside the canonical-campaign runner itself. ### Custom scopes for other vendor protocols Any vendor agent MAY define custom scopes using the `custom:` prefix. Buyers MUST NOT assume any semantics from a custom-prefixed scope name — the names are agent-defined and learned out-of-band (docs, onboarding). Illustrative examples (not standardized — each agent names its own): * **Signals agent**: `custom:activation_only` — `allowed_tasks: [get_signals, activate_signal, get_adcp_capabilities]`, no catalog management, no cross-account metadata. * **Governance agent**: `custom:audit_viewer` — `allowed_tasks: [get_plan_audit_logs, get_adcp_capabilities]`, `read_only: true`. Useful for regulators or external auditors granted read access to governance trails. * **Creative agent**: `custom:library_reader` — `allowed_tasks: [list_creatives, list_creative_formats]`, `read_only: true`. Lets a non-uploading buyer (e.g., a measurement partner) discover what's in the library without mutating it. * **Brand agent (rights)**: `custom:rights_viewer` — `allowed_tasks: [get_rights, get_brand_identity]`, `read_only: true`. Discovery without clearance privilege. The presence/absence semantics, identity binding, refresh cadence, and consistency requirements above apply uniformly across all vendor protocols — a signals agent populating `custom:activation_only` takes on the same 300s refresh obligation a media-buy seller does populating `attestation_verifier`. ### Prior art The introspection model — "the caller asks the authorization-enforcing party what the grant is" — is structurally analogous to [RFC 7662 OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662), specialized for AdCP's task-and-field authorization model. Embedding the response in sync/list rather than splitting it into a separate task reflects that account discovery and scope introspection are the same natural question ("what are my accounts, and what can I do with them?") — the two are returned together. ## Transaction lifecycle ``` 1. Discover seller capabilities get_adcp_capabilities → require_operator_auth, supported_billing 2. Resolve brand identity Fetch brand.domain/.well-known/brand.json → canonical brand (domain, brand_id) 3. Verify operator identity Check authorized_operators in brand.json → confirm operator is permitted to buy for this brand 4. Authenticate (if required) When require_operator_auth is true → obtain operator credential via authorization_endpoint or out-of-band 5. Establish account reference Account-id namespace (require_operator_auth: true): list_accounts() → find existing account_id for this brand/operator (upstream-managed) or receive account_id out-of-band (seller-defined) Buyer-declared account (require_operator_auth: false): sync_accounts({ accounts: [{ brand, operator, billing, billing_entity? }] }) → status, billing terms list_accounts() → recover acknowledged brand/operator accounts after a cold start (SHOULD be exposed) 6. Execute Protocol tasks use the account reference to apply correct rates and terms Examples: get_products(account: {...}), create_media_buy(account: {...}) 7. Report usage report_usage(usage: [{ account: {...}, operator_id, kind, vendor_cost, ... }]) Informs vendor agents how their services were consumed after delivery ``` ## Parties The Accounts Protocol operates with four party types. See [Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) for full details on billing hierarchy, trust models, and authorized operators. | Party | Role | Identified by | | ------------ | ----------------------------- | --------------------------------------------------------- | | Brand | Whose products are advertised | `brand.domain` + optional `brand.brand_id` via brand.json | | Operator | Who drives the buys | Domain (e.g., `pinnacle-media.com`) | | Agent | What software places the buys | Authenticated session | | Vendor agent | The seller's AdCP agent | `agent_url` | ## Tasks **Account discovery (normative).** Every agent accepting accounts MUST expose at least one of `list_accounts` (upstream-managed account-id namespaces) or `sync_accounts` (buyer-declared accounts, `require_operator_auth: false`). A seller-defined account-id namespace MAY omit both account-discovery tasks only when account IDs are supplied out-of-band and no account settings are managed through AdCP. Buyer-declared-account sellers SHOULD expose `list_accounts` in addition to `sync_accounts`: a stateless buyer cannot recover its acknowledged portfolio by re-running `sync_accounts` when the lost state is the set of `(brand, operator, sandbox)` natural keys needed to construct that request. For account-id namespaces, `sync_accounts` is settings-update only in 3.0.x unless a future explicit capability declares account-id provisioning. See [Required tasks by protocol](/dist/docs/3.1.20/protocol/required-tasks#any-agent-accepting-accounts). | Task | Purpose | | ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) | Declare brand/operator pairs and billing; provision accounts (buyer-declared accounts, `require_operator_auth: false`); optionally update settings for existing accounts when the seller exposes settings-update mode | | [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) | Discover existing accounts in upstream-managed namespaces; recover acknowledged buyer-declared accounts after a cold start; poll status on pending accounts | | [`get_account_financials`](/dist/docs/3.1.20/accounts/tasks/get_account_financials) | Query spend, credit, and invoice status for operator-billed accounts | | [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance) | Sync governance agent endpoints to accounts for seller-side validation | | [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) | Inform vendor agents how their services were consumed after delivery | ## Brand registry connection The `brand.domain` in account references is not an arbitrary identifier — it is the brand's domain, resolvable to a `brand.json` file that declares the brand's canonical identity, sub-brands, authorized operators, and properties. Vendor agents can verify buyer claims against the brand registry: if an orchestrator claims to represent `acme-corp.com`, the vendor can fetch `acme-corp.com/.well-known/brand.json` to confirm authorized operators and brand hierarchy. This makes the Accounts Protocol tamper-resistant — account relationships are grounded in publicly verifiable brand identity. See the [Brand Protocol](/dist/docs/3.1.20/brand-protocol/index) for how brand identity resolution works. ## Counterparty verification Every commercial relationship in advertising depends on knowing who you're actually doing business with. The Accounts Protocol addresses this at the protocol level through the brand registry. When an orchestrator references an account, the `brand.domain` identifies the advertiser. Vendor agents can fetch `brand.domain/.well-known/brand.json` to verify: * **Brand identity**: Is this brand who they claim to be? * **Operator authorization**: Is the operator listed in the request actually authorized to buy on this brand's behalf? * **Brand hierarchy**: Which sub-brands does this house portfolio include? This verification is grounded in publicly accessible DNS-hosted identity — not in what the buyer agent asserts, but in what the brand itself has declared. The `pending_approval` account state is where human review occurs: credit checks, legal agreements, and identity verification. Vendor agents that require these steps return a `setup.url` for the human to complete the process before the account becomes active. ### Brand registry and the contribute-back pattern The [AgenticAdvertising.org brand registry](https://agenticadvertising.org) provides a community-maintained layer of brand identity for brands that haven't yet published their own `brand.json`. Buyer agents resolving brands before account setup can contribute data back to the registry as a byproduct of normal workflows — improving identity coverage for the ecosystem without extra effort. The recommended pattern for buyer agents uses three building blocks (see [#1166](https://github.com/adcontextprotocol/adcp/issues/1166)): | Tool | Purpose | | ---------------- | ----------------------------------------------------------------------------- | | `resolve_brand` | Check registry and fetch brand.json — returns canonical identity if available | | `research_brand` | Enrich via Brandfetch and auto-save to registry as `enriched` | | `save_brand` | Manually contribute a brand to the registry as `community` | ```javascript theme={null} async function ensureBrand(domain) { // 1. Check registry (brand.json or previously resolved) const resolved = await resolveBrand(domain); if (resolved.errors) { // Resolution failed — brand unknown, proceed to enrich } else if (resolved.source === 'brand_json' || resolved.source === 'enriched') { // Authoritative or enriched data available — confirm with user, then use return await confirmWithUser(resolved); } // source === 'community': registry has a placeholder, but enrich for richer data // 2. Enrich via Brandfetch — auto-saves to registry as 'enriched' const enriched = await researchBrand(domain); if (enriched.errors) { // Enrichment unavailable — fall back to community entry or prompt user to correct return resolved ? await confirmWithUser(resolved) : null; } // 3. Confirm with user before using enriched data // Enrichment is third-party — user confirmation catches errors and improves registry quality return await confirmWithUser(enriched); } ``` `confirmWithUser` is a placeholder for whatever confirmation mechanism fits your UX — an explicit prompt, a review step in a workflow UI, or a low-confidence flag that triggers human review. The confirmation step is what makes the improvement loop work: enrichment data comes from third parties and isn't guaranteed to be correct. User verification before the data is used in a live campaign is what keeps the registry accurate over time. #### Source authority The registry tracks where brand data came from. Sources in descending authority: | Source | Meaning | Can be overwritten? | | ------------ | ------------------------------------------------- | ------------------------ | | `brand_json` | Brand self-declared via `/.well-known/brand.json` | No — returns 409 | | `enriched` | Third-party enrichment (Brandfetch) | Only by higher authority | | `community` | Manually contributed by a registry member | Yes | When an agent calls `save_brand` or `research_brand`, the registry applies merge logic: existing fields from a higher-authority source are preserved, and only missing fields are filled in. This respects what brands have declared while filling gaps. `research_brand` skips re-enrichment if the registry already has recent `enriched` data for the domain, avoiding redundant API calls. The full edit history for any brand — who contributed, when, and with what summary — is queryable via [`GET /api/brands/history`](/dist/docs/3.1.20/registry/index#activity-history). #### Property contribute-back The same pattern applies to publisher properties. When a buyer agent discovers a new publisher through a sales agent interaction, it can contribute that property back to the registry via `POST /api/properties/save`. This improves property coverage for the ecosystem the same way brand contribute-back improves brand coverage. See [Registry API — save property](/dist/docs/3.1.20/registry/index#save-property) for details. ## Usage reporting Vendor agents (signals, governance, creative) are not direct participants in campaign execution — the orchestrator uses their services as inputs to a media buy. After delivery, `report_usage` tells these vendors what was consumed so they can track earned revenue and verify billing. `report_usage` is buyer-reported: the orchestrator computes and reports consumption. Each record carries its own `account`, `operator_id`, and `kind` (`"signal"`, `"content_standards"`, `"creative"`). The vendor agent uses the reported `pricing_option_id` to verify the correct rate was applied. Partial acceptance is valid — a single request can span multiple accounts, operators, and campaigns. The response confirms how many records were accepted and which (if any) failed validation. # get_account_financials Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/accounts/tasks/get_account_financials get_account_financials returns spend summaries, credit balances, payment status, and invoice history for operator-billed AdCP accounts. Requires account_financials capability. Query financial status for an operator-billed account — spend summary, credit or prepay balance, payment status, and invoice history. This gives budget-aware agents the context they need to make spend decisions without leaving the protocol. `get_account_financials` is only available when the seller declares `account_financials: true` in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities). It applies to **operator-billed accounts** only. For agent-billed accounts, the agent's own billing system is the source of truth. **Response Time**: \~1s. **Request Schema**: [`/schemas/3.1.20/account/get-account-financials-request.json`](https://adcontextprotocol.org/schemas/3.1.20/account/get-account-financials-request.json) **Response Schema**: [`/schemas/3.1.20/account/get-account-financials-response.json`](https://adcontextprotocol.org/schemas/3.1.20/account/get-account-financials-response.json) ## Quick start Check remaining credit before launching a campaign: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; const result = await testAgent.getAccountFinancials({ account: { account_id: "acc_acme_001" }, }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } if ("errors" in result.data && result.data.errors) { throw new Error(`Operation failed: ${JSON.stringify(result.data.errors)}`); } const { spend, credit, payment_status } = result.data; console.log(`Spent: $${spend?.total_spend} this period`); if (credit) { console.log(`Available credit: $${credit.available_credit} of $${credit.credit_limit}`); } if (payment_status === "past_due") { console.log("Warning: payment is past due — campaigns may be paused"); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.get_account_financials( account={"account_id": "acc_acme_001"}, ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") print(f"Spent: ${result.spend.total_spend} this period") if hasattr(result, 'credit') and result.credit: print(f"Available credit: ${result.credit.available_credit} of ${result.credit.credit_limit}") if getattr(result, 'payment_status', None) == 'past_due': print("Warning: payment is past due — campaigns may be paused") asyncio.run(main()) ``` ## Request parameters | Parameter | Type | Required | Description | | --------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | object | Yes | [Account reference](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) — by `account_id` or natural key (`brand` + `operator` + optional `sandbox`). Must be an operator-billed account. | | `period` | object | No | Date range for spend summary: `start` and `end` as ISO 8601 dates. Defaults to the current billing cycle if omitted. | ## Response **Success response:** Returns financial data for the account. Only `account`, `currency`, `period`, and `timezone` are guaranteed — everything else depends on what the seller exposes. | Field | Description | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | Account reference, echoed from the request. | | `currency` | ISO 4217 currency code for all monetary amounts. | | `period` | Actual period covered (`start`, `end`). May differ from requested period if adjusted to billing cycle boundaries. | | `timezone` | IANA timezone of the seller's billing day boundaries (e.g., `America/New_York`). All dates in the response are calendar dates in this timezone. | | `spend` | Spend summary: `total_spend` (amount in `currency`) and optional `media_buy_count`. | | `credit` | Present for credit-based accounts. Contains `credit_limit`, `available_credit`, and optional `utilization_percent` (0-100). | | `balance` | Present for prepay accounts. Contains `available` balance and optional `last_top_up` (`amount`, `date`). | | `payment_status` | `current`, `past_due`, or `suspended`. | | `payment_terms` | Payment terms in effect: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. | | `invoices` | Array of recent invoices: `invoice_id`, `amount`, `status` (`draft`, `issued`, `paid`, `past_due`, `void`), optional `period`, `due_date`, `paid_date`. | **Error response:** * `errors` -- Array of operation-level errors. No financial data is present. **Note:** Responses use discriminated unions -- you get either financial data OR `errors`, never both. ## Common scenarios ### Budget check before campaign launch ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; const financials = await testAgent.getAccountFinancials({ account: { account_id: "acc_acme_001" }, }); if (!financials.success || "errors" in financials.data) { throw new Error("Could not check financials"); } const campaignBudget = 15000; const { credit } = financials.data; if (credit && credit.available_credit < campaignBudget) { console.log( `Insufficient credit: $${credit.available_credit} available, ` + `$${campaignBudget} needed. ` + `Credit utilization: ${credit.utilization_percent}%` ); } else { console.log("Budget check passed — proceeding with campaign"); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): financials = await test_agent.simple.get_account_financials( account={"account_id": "acc_acme_001"}, ) if hasattr(financials, 'errors') and financials.errors: raise Exception("Could not check financials") campaign_budget = 15000 credit = getattr(financials, 'credit', None) if credit and credit.available_credit < campaign_budget: print( f"Insufficient credit: ${credit.available_credit} available, " f"${campaign_budget} needed. " f"Credit utilization: {credit.utilization_percent}%" ) else: print("Budget check passed — proceeding with campaign") asyncio.run(main()) ``` ### Prepay balance monitoring ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; const financials = await testAgent.getAccountFinancials({ account: { brand: { domain: "acme-corp.com" }, operator: "acme-corp.com", }, }); if (!financials.success || "errors" in financials.data) { throw new Error("Could not check financials"); } const { balance } = financials.data; if (balance && balance.available < 2000) { console.log( `Low balance warning: $${balance.available} remaining. ` + `Consider topping up before launching new campaigns.` ); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): financials = await test_agent.simple.get_account_financials( account={ "brand": {"domain": "acme-corp.com"}, "operator": "acme-corp.com", }, ) if hasattr(financials, 'errors') and financials.errors: raise Exception("Could not check financials") balance = getattr(financials, 'balance', None) if balance and balance.available < 2000: print( f"Low balance warning: ${balance.available} remaining. " f"Consider topping up before launching new campaigns." ) asyncio.run(main()) ``` ## Error handling | Error Code | Description | Resolution | | --------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- | | `UNSUPPORTED_FEATURE` | Account uses agent billing — financials not available from seller | Query your own billing system for agent-billed accounts | | `UNSUPPORTED_FEATURE` | Seller doesn't have financial data for this account or period | Verify `account_financials` capability is `true` | | `ACCOUNT_NOT_FOUND` | Account does not exist or is not accessible | Check account reference or re-sync | ## Next steps * [list\_accounts](/dist/docs/3.1.20/accounts/tasks/list_accounts) -- Discover accounts and check their status * [get\_adcp\_capabilities](/dist/docs/3.1.20/protocol/get_adcp_capabilities) -- Check `account_financials` before calling this task * [Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) -- Billing models and account references # list_accounts Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/accounts/tasks/list_accounts list_accounts returns all advertiser accounts an authenticated agent can operate on an AdCP vendor agent. Works across media buy, signals, governance, and creative protocols. Returns all accounts the authenticated agent can operate on this vendor agent. Use this to discover existing accounts, check status changes on pending accounts, and retrieve `account_id` values for use in protocol operations. For upstream-managed account namespaces, `list_accounts` is not optional discovery polish; it is the namespace discovery contract. The upstream platform owns the accessible account set, so buyers MUST resolve an explicit `account_id` before the first account-scoped request. If the authenticated credential can access more than one account, the seller MUST expose `list_accounts`; if it can access exactly one account, the seller SHOULD expose `list_accounts` returning that singleton so SDKs can auto-select it and still send `{ "account_id": "..." }` on required-account calls. `sync_accounts` provisioning does not create account-id accounts in 3.0.x unless a future explicit capability declares that mode; if `sync_accounts` is exposed on these sellers today, use it only for settings updates against an account already identified by `account_id`. `list_accounts` works across all vendor protocols — media buy agents, signals agents, governance agents, and creative agents all return accounts through this same task. **Response Time**: \~1s. **Request Schema**: [`static/schemas/source/account/list-accounts-request.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/source/account/list-accounts-request.json) **Response Schema**: [`/schemas/3.1.20/account/list-accounts-response.json`](https://adcontextprotocol.org/schemas/3.1.20/account/list-accounts-response.json) ## Quick Start List all accounts this agent can operate: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { ListAccountsResponseSchema } from "@adcp/sdk"; const result = await testAgent.listAccounts({}); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListAccountsResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } for (const account of validated.accounts) { console.log(`${account.account_id}: ${account.name} (${account.status})`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.list_accounts() if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for account in result.accounts: print(f"{account.account_id}: {account.name} ({account.status})") asyncio.run(main()) ``` ## Request Parameters All parameters are optional. An empty request returns all accounts visible to the authenticated caller. Use `account` when re-reading one known account by `account_id` or by natural key (`brand` + `operator`, optionally `sandbox`). | Parameter | Type | Required | Description | | ------------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | object | No | Exact account filter. Pass `{ "account_id": "..." }` after discovery, or a natural key with `brand`, `operator`, and optional `sandbox` for buyer-declared account sellers. The seller returns only matching accounts visible to the authenticated caller. | | `status` | string | No | Filter by account status: `active`, `pending_approval`, `rejected`, `payment_required`, `suspended`, or `closed`. | | `sandbox` | boolean | No | When true, return only sandbox accounts. When false, return only production accounts. Omit to return both. Primarily used with account-id namespaces where sandbox accounts are pre-existing test accounts on the platform. | | `pagination` | object | No | Pagination cursor for large account sets. | ## Response | Field | Description | | ------------ | ---------------------------------------------------------- | | `accounts` | Array of account objects (see below) | | `errors` | Array of errors, if the request failed | | `pagination` | Pagination cursor for the next page, if more results exist | **Each account includes:** | Field | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `account_id` | Vendor agent's identifier. Pass this to protocol tasks: `create_media_buy`, `get_signals`, `activate_signal`, `report_usage`, and other operations. May be absent when `status: "rejected"`. | | `name` | Vendor agent's display name for the account | | `brand` | Brand reference object: `domain` (the [brand registry](/dist/docs/3.1.20/brand-protocol/brand-json) house domain) and optional `brand_id` (sub-brand within the house) | | `operator` | Operator domain. Always present — when the brand operates directly, `operator` equals the brand's domain. | | `status` | Current account state: `active`, `pending_approval`, `rejected`, `payment_required`, `suspended`, or `closed` | | `billing` | Billing model in effect: `operator` or `agent` | | `account_scope` | How the seller scoped this account: `operator`, `brand`, `operator_brand`, or `agent`. See [account scope](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-scope). | | `payment_terms` | Payment terms agreed for this account: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. Binding for all invoices when the account is active. | | `governance_agents` | Governance agent endpoints registered on this account. Present when governance agents have been configured via [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance). | | `setup` | Present when `status: "pending_approval"`. Contains `url` for completing setup and `message` explaining what's needed. | | `authorization` | Optional. The calling agent's scope grant for this account — `allowed_tasks`, `field_scopes`, `scope_name`, `read_only`. Applies to every vendor agent type (media-buy, signals, governance, creative, brand) — the Accounts Protocol surface is shared. Vendor agents that support scope introspection SHOULD populate this; media-buy sales agents claiming the `attestation_verifier` standard scope MUST populate it. Absence means the vendor agent does not advertise introspectable scope for this account; callers MUST NOT infer access from absence and fall back to error-driven discovery via the RBAC error codes. See [Caller authorization](/dist/docs/3.1.20/accounts/overview#caller-authorization) for the full shape and semantics. | | `notification_configs` | Account-level webhook subscribers registered via [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts#account-level-webhook-subscriptions). Each entry carries `subscriber_id`, `url`, `event_types[]`, and `active`. Present when the account has any persisted subscribers. `subscriber_id` is the account-scoped logical key; re-registering the same subscriber replaces that subscriber's config. `authentication.credentials` is omitted on every entry (write-only). Use this surface to verify what's active after a sync, audit fan-out across multiple subscribers, and detect drift between buyer-side expectations and seller-side persisted state. This is not an account lifecycle feed; account status changes are read from the account's `status` field or from the one-shot `sync_accounts.push_notification_config` async result channel. | ### Single-publisher cardinality A seller serving exactly one publisher entity MAY return that entity as the sole account on `list_accounts` responses, regardless of calling principal. The *"Direct advertiser with single account"* example in [`list-accounts-response.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/source/account/list-accounts-response.json) is canonical for this case — a single-element `accounts[]` with no `pagination` envelope at all. Pagination conformance requiring `pagination.has_more: true` does not apply when: * `pagination` is absent entirely (canonical single-account shape), or * `pagination.total_count` is present and ≤ 1 Runners SHOULD grade pagination-walk phases as `not_applicable` in either case. This pattern is conformant; the spec carries no `minItems` constraint on `accounts[]` and the single-account example is normative. ## Common Scenarios ### Poll until account becomes active After `sync_accounts` returns `pending_approval`, poll until the account is ready: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { ListAccountsResponseSchema } from "@adcp/sdk"; async function waitForAccount(targetAccountId, maxAttempts = 20) { for (let i = 0; i < maxAttempts; i++) { const result = await testAgent.listAccounts({ status: "active" }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListAccountsResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("accounts" in validated) { const account = validated.accounts.find(a => a.account_id === targetAccountId); if (account) { console.log(`Account active: ${account.account_id}`); return account; } } // Wait 30 seconds before polling again await new Promise(resolve => setTimeout(resolve, 30_000)); } throw new Error(`Account ${targetAccountId} did not become active`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def wait_for_account(target_account_id: str, max_attempts: int = 20): for _ in range(max_attempts): result = await test_agent.simple.list_accounts(status='active') if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") account = next( (a for a in result.accounts if a.account_id == target_account_id), None ) if account: print(f"Account active: {account.account_id}") return account await asyncio.sleep(30) raise Exception(f"Account {target_account_id} did not become active") ``` ### Filter active accounts only ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { ListAccountsResponseSchema } from "@adcp/sdk"; const result = await testAgent.listAccounts({ status: "active" }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListAccountsResponseSchema.parse(result.data); if ("accounts" in validated) { for (const account of validated.accounts) { console.log(`${account.account_id}: ${account.name} — billing: ${account.billing}`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.list_accounts(status='active') if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for account in result.accounts: print(f"{account.account_id}: {account.name} — billing: {account.billing}") asyncio.run(main()) ``` ## Error Handling | Error Code | Description | Resolution | | ------------------- | -------------------------------- | ------------------------------------------------------------ | | `ACCOUNT_NOT_FOUND` | No accounts found for this agent | Run `sync_accounts` first to establish a buying relationship | ## Next Steps * [sync\_accounts](/dist/docs/3.1.20/accounts/tasks/sync_accounts) — Sync advertiser accounts with a seller * [sync\_governance](/dist/docs/3.1.20/accounts/tasks/sync_governance) — Sync governance agents to accounts * [Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) — Billing models, trust models, and authorized operators * [Brand protocol](/dist/docs/3.1.20/brand-protocol/brand-json) — How vendor agents resolve brand identity from the brand's `domain` # report_usage Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/accounts/tasks/report_usage report_usage sends consumption data to AdCP vendor agents after campaign delivery — impressions served, signals queried, governance checks run — so vendors can track revenue and verify billing. Reports how a vendor's service was consumed after campaign delivery. Called by orchestrators to inform a vendor agent (signals, governance, creative) what was used so the vendor can track earned revenue and verify billing. Each usage record is self-contained — it carries its own `account` and `media_buy_id`. A single request can span multiple accounts and campaigns. **Response Time**: \~1s. **Request Schema**: [`/schemas/3.1.20/account/report-usage-request.json`](https://adcontextprotocol.org/schemas/3.1.20/account/report-usage-request.json) **Response Schema**: [`/schemas/3.1.20/account/report-usage-response.json`](https://adcontextprotocol.org/schemas/3.1.20/account/report-usage-response.json) ## Request Parameters | Parameter | Type | Required | Description | | ------------------ | -------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `idempotency_key` | string | Recommended | Client-generated unique key for this request (UUID recommended). If a request with the same key has already been accepted, the server returns the original response without re-processing. Prevents duplicate billing on retries. | | `reporting_period` | object | Yes | `start` and `end` as ISO 8601 date-time in UTC. Applies to all records in the request. | | `usage` | UsageRecord\[] | Yes | One or more usage records. | ### Usage Record Fields Each record requires `account`, `vendor_cost`, and `currency`. Additional fields depend on the vendor type: | Field | Type | Required | Description | | ------------------------- | ------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [AccountRef](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account for this record — by `account_id` or `{ brand, operator }`. | | `vendor_cost` | number | Yes | Amount owed to the vendor for this record, in `currency` | | `currency` | string | Yes | ISO 4217 currency code | | `pricing_option_id` | string | Vendor: Yes | Pricing option from the vendor's discovery response (`get_signals`, `list_creatives`, `list_content_standards`, `list_property_lists`) or execution response (`build_creative`). The vendor uses this to verify the correct rate was applied. | | `impressions` | number | Signals: Yes | Impressions delivered | | `media_spend` | number | percent\_of\_media: Yes | Media spend for percent-of-media cost verification | | `signal_agent_segment_id` | string | Signals: Yes | Signal identifier from `get_signals` | | `creative_id` | string | Creative: Yes | Creative identifier from `build_creative` or `list_creatives`. Links usage to a specific creative for billing verification. A `build_creative` variant leaf earns a `creative_id` only when trafficked/added to the library — discarded best-of-N or fan-out variants are never reported here; their charge is the inline per-leaf `vendor_cost` on the `build_creative` response (the authoritative record for untrafficked leaves). | | `build_variant_id` | string | No | When the reported `creative_id` was promoted from a specific `build_creative` variant leaf but differs from that source id, carry the source `build_variant_id` for reconciliation. On the canonical path where `creative_id` is the kept `build_variant_id`, omit this field. | | `property_list_id` | string | Property lists: Yes | Property list identifier from `list_property_lists`. Links usage to a specific property list for billing verification. | ## Response | Field | Description | | ---------- | ------------------------------------------------------------------------------------------------------------------------ | | `accepted` | Number of usage records successfully stored | | `errors` | Validation errors for individual records. Partial acceptance is valid — accepted records are stored even when some fail. | ## Examples ### Signal usage — single campaign ```json Request theme={null} { "idempotency_key": "550e8400-e29b-41d4-a716-446655440000", "reporting_period": { "start": "2025-03-01T00:00:00Z", "end": "2025-03-31T23:59:59Z" }, "usage": [ { "account": { "account_id": "acct_pinnacle_signals" }, "signal_agent_segment_id": "luxury_auto_intenders", "pricing_option_id": "po_lux_auto_cpm", "impressions": 4200000, "media_spend": 21000.00, "vendor_cost": 2100.00, "currency": "USD" } ] } ``` ```json Response theme={null} { "accepted": 1 } ``` ### Creative usage — ad server with CPM pricing ```json Request theme={null} { "idempotency_key": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "reporting_period": { "start": "2026-03-01T00:00:00Z", "end": "2026-03-31T23:59:59Z" }, "usage": [ { "account": { "account_id": "acct_acme_creative" }, "creative_id": "cr_88201", "pricing_option_id": "po_video_cpm", "impressions": 2400000, "vendor_cost": 1200.00, "currency": "USD" } ] } ``` ```json Response theme={null} { "accepted": 1 } ``` ### Multi-account batch A single request spanning two campaigns across two accounts: ```json theme={null} { "idempotency_key": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "reporting_period": { "start": "2025-03-01T00:00:00Z", "end": "2025-03-31T23:59:59Z" }, "usage": [ { "account": { "account_id": "acct_pinnacle_signals" }, "signal_agent_segment_id": "luxury_auto_intenders", "pricing_option_id": "po_lux_auto_cpm", "impressions": 2100000, "vendor_cost": 1050.00, "currency": "USD" }, { "account": { "account_id": "acct_nova" }, "signal_agent_segment_id": "eco_conscious_shoppers", "pricing_option_id": "po_eco_cpm", "impressions": 800000, "vendor_cost": 400.00, "currency": "USD" } ] } ``` ### Partial acceptance If some records fail validation, the response identifies how many were accepted: ```json theme={null} { "accepted": 1, "errors": [ { "code": "INVALID_PRICING_OPTION", "message": "pricing_option_id 'po_unknown' does not exist on this account", "field": "usage[1].pricing_option_id" } ] } ``` ## Retry Safety Always include `idempotency_key` in production usage. If a request times out or returns a network error, retry with the same key — the server will return the original result without double-counting. Generate a fresh UUID per request, not per usage record. If you need to report additional records for the same period, submit a new request with a new key. ## Reporting cadence Report at regular intervals — monthly at minimum. For campaigns with significant spend, weekly reporting gives vendor agents timely visibility into earned revenue. Report upon campaign completion to close out the final period. ## Error Handling | Error Code | Description | Resolution | | ------------------------ | --------------------------------------------------------------- | --------------------------------------------------------------- | | `ACCOUNT_NOT_FOUND` | Account reference in a usage record not found or not accessible | Verify via `list_accounts`; re-run `sync_accounts` if needed | | `INVALID_USAGE_DATA` | A usage record has missing or invalid fields | Check required fields for your vendor type | | `INVALID_PRICING_OPTION` | `pricing_option_id` not found on this account | Verify `pricing_option_id` from the vendor's discovery response | | `DUPLICATE_REQUEST` | Request with this `idempotency_key` was already accepted | Safe to ignore — original response is returned unchanged | ## Next Steps * [sync\_accounts](/dist/docs/3.1.20/accounts/tasks/sync_accounts) — Sync advertiser accounts with a seller before reporting * [Accounts Protocol](/dist/docs/3.1.20/accounts/overview) — How account establishment and settlement fit together * [Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) — Billing hierarchy and operator model # sync_accounts Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/accounts/tasks/sync_accounts sync_accounts provisions buyer-declared accounts or updates settings on existing accounts with an AdCP seller agent. Sync advertiser accounts with a seller for one or more brand/operator pairs, or update settings on existing accounts when the seller exposes that mode. Brands are identified by a `brand` object containing `domain` + optional `brand_id`, resolved via `/.well-known/brand.json`. `sync_accounts` is used across all seller protocols: media buy agents, signals agents, governance agents, and creative agents. In provisioning mode it declares the buyer's intent — the seller provisions or links accounts internally. Use provisioning mode for buyer-declared accounts (`require_operator_auth: false`) and use natural keys (`brand` + `operator`) on subsequent requests. Sellers MAY echo an `account_id` as an internal handle, but they MUST continue accepting the natural-key `AccountRef` for accounts provisioned this way. For account-id namespaces, discover seller-assigned account IDs via [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) or out-of-band onboarding; `sync_accounts` provisioning for account-id namespaces is out of scope unless a future explicit capability declares that mode. If such a seller exposes `sync_accounts` today, use only settings-update mode keyed by `account_id`. **Response Time**: \~1s. Account provisioning is synchronous; credit and legal review may require human action (indicated by `status: "pending_approval"` with a `setup.url`). **Request Schema**: [`/schemas/3.1.20/account/sync-accounts-request.json`](https://adcontextprotocol.org/schemas/3.1.20/account/sync-accounts-request.json) **Response Schema**: [`/schemas/3.1.20/account/sync-accounts-response.json`](https://adcontextprotocol.org/schemas/3.1.20/account/sync-accounts-response.json) ## Quick start Sync a single advertiser account and check the resulting status: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAccountsResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncAccounts({ accounts: [ { brand: { domain: "acme-corp.com" }, operator: "acme-corp.com", billing: "operator", }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAccountsResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } for (const account of validated.accounts) { console.log(`${account.brand.domain}: ${account.status}`); if (account.status === "pending_approval" && account.setup?.url) { console.log(` Complete setup at: ${account.setup.url}`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_accounts( accounts=[ { "brand": {"domain": "acme-corp.com"}, "operator": "acme-corp.com", "billing": "operator", }, ], ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for account in result.accounts: print(f"{account.brand['domain']}: {account.status}") if account.status == 'pending_approval' and hasattr(account, 'setup') and account.setup: print(f" Complete setup at: {account.setup.url}") asyncio.run(main()) ``` ## Request parameters | Parameter | Type | Required | Description | | -------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `accounts` | array | Yes | Array of account entries to sync (see below). | | `delete_missing` | boolean | No | When true, accounts previously synced by this agent but not in this request are deactivated. Scoped to the authenticated agent. Default: `false`. | | `dry_run` | boolean | No | When true, preview what would change without applying. Default: `false`. | | `push_notification_config` | object | No | Webhook for async notifications when account status changes (e.g., `pending_approval` transitions to `active`). | **Account entry fields:** | Field | Type | Required | Description | | ---------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `brand` | object | Yes | Brand reference identifying the advertiser. Contains `domain` (house domain where brand.json is hosted) and optional `brand_id` (for multi-brand houses). See [brand-ref](/dist/docs/3.1.20/brand-protocol/brand-json). | | `operator` | string | Yes | Domain of the entity operating on the brand's behalf (e.g. `pinnacle-media.com`). When the brand operates directly, set to the brand's domain. Verified against the brand's `authorized_operators` in brand.json. | | `billing` | string | Yes | Who should be invoiced: `operator`, `agent`, or `advertiser`. Check `get_adcp_capabilities` for `supported_billing` to see what the seller accepts at the capability level. The seller must either accept this billing model or reject the request. Sellers MAY additionally reject a value the seller-wide capability accepts when the calling buyer agent's commercial relationship does not permit it — e.g., a buyer agent onboarded as passthrough-only (no payments relationship — only the operator can be invoiced). The two gates use distinct error codes — `BILLING_NOT_SUPPORTED` for the seller-wide capability gate, `BILLING_NOT_PERMITTED_FOR_AGENT` for the per-buyer-agent gate — so agents can dispatch on autonomous-retry vs human-onboarding without parsing prose. See [Buyer-agent identity](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#buyer-agent-identity) and [Billing and Account Setup](/dist/docs/3.1.20/building/by-layer/L3/error-handling#billing-and-account-setup). | | `billing_entity` | object | No | Structured business entity details for the party responsible for payment. Contains `legal_name` (required), plus optional `vat_id`, `tax_id`, `registration_number`, `address`, `contacts`, and `bank`. Bank details are write-only — included in requests but never echoed in responses. See [billing entity and invoice recipient](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient). | | `payment_terms` | string | No | Payment terms for this account: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. The seller must either accept these terms or reject the account — terms are never silently remapped. When omitted, the seller applies its default terms. | | `sandbox` | boolean | No | When true, set up a sandbox account with no real platform calls or billing. Only applicable to buyer-declared accounts (`require_operator_auth: false`). For account-id namespaces, sandbox accounts are pre-existing test accounts discovered via `list_accounts` or supplied out-of-band. | | `notification_configs` | array | No | Account-level webhook subscribers for events that outlive any single media buy: creative lifecycle notifications and wholesale feed change webhooks. Omit to leave existing subscribers unchanged; send `[]` to remove all subscribers; send a full array to replace. Entries are keyed by account-scoped `subscriber_id`; an existing `subscriber_id` is upserted, and persisted IDs absent from the sent array are removed. | **Natural key**: The tuple `(brand, operator, sandbox)` uniquely identifies an account relationship. `{brand: {domain: "acme-corp.com"}, operator: "acme-corp.com"}` (direct) is a different account from `{brand: {domain: "acme-corp.com"}, operator: "pinnacle-media.com"}` (via agency). Adding `sandbox: true` provisions a sandbox account for the same brand/operator pair — no real platform calls or billing. ## Response **Success response:** Returns an `accounts` array with per-account results. Individual accounts may be pending, rejected, or failed even when the operation succeeds. **Error response:** * `errors` -- Array of operation-level errors (auth failure, service unavailable). No `accounts` array is present. **Note:** Responses use discriminated unions -- you get either `accounts` OR `errors`, never both. **Per-account fields:** | Field | Description | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `brand` | Echoed from request. Object with `domain` and optional `brand_id`. | | `operator` | Echoed from request. | | `name` | Seller's display name for the account. | | `action` | What happened: `created`, `updated`, `unchanged`, or `failed`. | | `status` | Current state of the account (see [Account status](#account-status)). | | `billing` | Billing model applied. Matches the requested value. | | `billing_entity` | Business entity details for the invoiced party, echoed from the request. Sellers may add fields the agent omitted (e.g., `registration_number` from a credit check) but must not return data from a different entity. Bank details are omitted (write-only). | | `account_scope` | How the seller scoped this account: `operator` (shared across brands for this operator), `brand` (shared across operators for this brand), `operator_brand` (dedicated to this operator+brand pair), or `agent` (agent-scoped account shared across declared brand/operator pairs). See [account scope](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-scope). | | `setup` | Present when `status: "pending_approval"`. Contains `url` for completing credit or legal setup, `message` explaining what's needed, and optional `expires_at`. | | `rate_card` | Seller-assigned rate card identifier (when applicable). | | `payment_terms` | Payment terms agreed for this account: `net_15`, `net_30`, `net_45`, `net_60`, `net_90`, or `prepay`. When the account is active, these are the binding terms for all invoices. | | `credit_limit` | Maximum outstanding balance as `{amount, currency}`. | | `errors` | Per-account errors (only present when `action: "failed"`). | | `warnings` | Non-fatal notices. | | `sandbox` | Whether this is a sandbox account, echoed from the request. Only present for buyer-declared accounts. | | `notification_configs` | Optional. Current persisted account-level webhook subscribers after applying the request. Present on `created`, `updated`, and `unchanged` results when the request included `notification_configs` or the account already has persisted subscribers. Each entry carries `subscriber_id`, `url`, `event_types[]`, and `active`; `authentication.credentials` is omitted (write-only). | | `authorization` | Optional. The calling agent's scope grant for this account — `allowed_tasks`, `field_scopes`, `scope_name`, `read_only`. Applies to every vendor agent type (media-buy, signals, governance, creative, brand) — the Accounts Protocol surface is shared. Present on `created`, `updated`, and `unchanged` results; omitted on `failed` results. Vendor agents that support scope introspection SHOULD populate this; media-buy sales agents claiming the `attestation_verifier` standard scope MUST populate it. Absence means the vendor agent does not advertise introspectable scope; callers MUST NOT infer access from absence. See [Caller authorization](/dist/docs/3.1.20/accounts/overview#caller-authorization) for the full shape. | ### Account status | Status | Meaning | Next step | | ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `active` | Ready to use | Use [account reference](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) in protocol operations | | `pending_approval` | Seller reviewing | Human may need to visit `setup.url` to complete credit or legal process. Poll `list_accounts` for updates. | | `rejected` | Seller declined the request | Review rejection reason in `warnings`, adjust and retry, or contact seller | | `payment_required` | Credit limit reached or funds depleted | Add funds or increase credit limit. Route spend to other accounts. | | `suspended` | Was active, now paused | Contact seller to resolve | | `closed` | Was active, now terminated | -- | ### Async notifications When `push_notification_config` is provided and the seller returns `pending_approval`, the seller sends a webhook notification when the account status changes (e.g., approved → `active`, declined → `rejected`). For provisioning requests, the notification payload includes the `(brand, operator)` natural key so the buyer can correlate it to the original sync request. When the seller also returns a seller-assigned `account_id`, the notification includes it as a convenience handle; buyers still follow the seller's declared account-reference model for subsequent calls. ```json theme={null} { "brand": { "domain": "nova-brands.com", "brand_id": "glow" }, "operator": "pinnacle-media.com", "status": "active", "account_id": "acc_glow_001" } ``` If the buyer did not provide `push_notification_config`, poll [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) to check for status changes. ## Two modes: provisioning vs. settings-update Each per-account entry uses one of two key shapes, never both: * **Provisioning mode** — flat `brand` + `operator` + `billing` at the entry root. The seller provisions or upserts accounts. Used for buyer-declared accounts (`require_operator_auth: false`). This is the shape AdCP 3.0 shipped with. Sellers MAY echo an `account_id`, but the natural-key `AccountRef` remains valid for subsequent calls. * **Settings-update mode** — `account` (an [AccountRef](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references)) at the entry root, with `brand`/`operator`/`billing` absent. The seller updates the account's settable state — no provisioning side effects. Used for account-id namespaces only when the seller exposes settings updates through this task; upstream-managed accounts are discovered via `list_accounts`, while seller-defined account IDs may be supplied out-of-band. Buyer-declared account sellers MAY also accept this mode for settings updates against accounts they previously provisioned. Schema enforces the exclusivity via `oneOf` — sending both shapes on the same entry is a validation error. Sellers that don't implement settings-update mode reject `account`-keyed entries with `UNSUPPORTED_PROVISIONING`; sellers that don't provision through `sync_accounts`, including account-id namespaces, reject natural-key provisioning entries with the same code. ## Account-level webhook subscriptions `notification_configs[]` carries account-level webhook subscribers for notifications whose lifecycle outlives any single media buy — `creative.status_changed`, `creative.purged`, wholesale feed change webhooks (`product.*`, `signal.*`, `wholesale_feed.bulk_change`), and future account-anchored resource events after those event types are added to `notification-type.json`. This is not the account object's lifecycle event stream. There are no `account.created`, `account.updated`, `account.status_changed`, or `account.closed` notification types today. Account status changes are observed by polling [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) or, for the async result of a `sync_accounts` provisioning request, through `push_notification_config` on this task. For these event types, "wholesale feed" means the seller's buyable wholesale product and signals feeds returned by `get_products` or `get_signals`; it is not the buyer-provided feeds managed by `sync_catalogs`. Permitted in **both** provisioning and settings-update modes. Declarative semantics: * Omit `notification_configs` to leave the account's existing subscribers unchanged. * Send `notification_configs: []` to remove every subscriber on that account. * Send a non-empty array to replace the account's current set with the submitted set. Within one account, `subscriber_id` is the stable logical key. Re-sending an existing `(account_id, subscriber_id)` with a different `url`, `event_types`, `authentication`, or `active` value replaces that subscriber's active config rather than creating a duplicate. The seller MUST NOT merge the submitted array with persisted state: persisted subscribers whose `subscriber_id` does not appear in the sent array are removed. Paused entries (`active: false`) are subject to the same replacement semantics; to preserve a paused subscription, re-include it with `active: false` in the sent array. Duplicate `subscriber_id` values within the same submitted array are invalid. The replacement is account-scoped; the same `subscriber_id` MAY be reused on a different account. If any entry in the submitted replacement set fails validation or activation proof, the seller rejects that account entry with `action: "failed"` and leaves the account's previous `notification_configs[]` set unchanged. Sellers MUST NOT partially apply a replacement set and silently drop only the failed subscriber. Each entry has: * `subscriber_id` — buyer-supplied identifier, unique within the account; echoed on every fire so multi-subscriber accounts can route by endpoint * `url` — HTTPS endpoint URL. Sellers MUST complete an endpoint activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active. * `event_types[]` — types the subscriber wants. Only account-anchored types are permitted (today: `creative.status_changed`, `creative.purged`, `product.created`, `product.updated`, `product.priced`, `product.removed`, `signal.created`, `signal.updated`, `signal.priced`, `signal.removed`, `wholesale_feed.bulk_change`). Sellers MUST reject any media-buy-anchored type (`scheduled`, `final`, `delayed`, `adjusted`, `impairment`) and any undefined account-lifecycle name such as `account.status_changed` as a per-account validation failure with `INVALID_REQUEST` or `VALIDATION_ERROR` in `accounts[].errors[]`, and `error.field` MUST point at the invalid `event_types` entry. * `authentication` (optional) — legacy Bearer or HMAC-SHA256. Omit to use the default RFC 9421 webhook profile. When present, the same signed-registration downgrade-resistance rules as `push_notification_config.authentication` apply. Credentials are write-only — sellers omit them on reads. * `active` (default `true`) — set `false` to pause a subscriber without removing the registration. Sellers MAY skip only the outbound proof challenge while `active: false`; they MUST still enforce HTTPS parsing, hostname normalization, and reserved-range rejection on write. Paused subscribers MUST NOT receive fires until reactivated. Reactivation MUST repeat full SSRF validation with connect pinning plus proof-of-control for any tuple without current valid proof. ### Endpoint proof of control Before persisting or echoing an entry as `active: true`, the seller MUST validate the URL, apply the SSRF rules in [Webhook URL validation](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-url-validation-ssrf), and prove that the receiver controls the endpoint. Proof is required when there is no current valid proof for the tuple `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)`. Changing the subscriber ID, normalized URL, authentication mode/credential binding, or `event_types[]` requires fresh proof before the new set can become active. The challenge POST itself MUST be signed with the seller's RFC 9421 webhook profile key even when the candidate config selects legacy delivery auth. New signers use `adcp_use: "request-signing"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. The receiver MUST verify the RFC 9421 signature and MUST reject the challenge unless `seller_agent_url`, `delivery_auth`, and `event_types` match the pending registration. Sellers MAY re-challenge on their own proof-expiration policy. The standard challenge is an HTTPS POST to the candidate `url` with a JSON body containing `type`, `challenge`, `account_id`, `subscriber_id`, `seller_agent_url`, `delivery_auth`, and `event_types`. The canonical schemas are [`webhook-challenge.json`](https://adcontextprotocol.org/schemas/3.1.20/core/webhook-challenge.json) and [`webhook-challenge-response.json`](https://adcontextprotocol.org/schemas/3.1.20/core/webhook-challenge-response.json). ```json theme={null} { "type": "webhook.challenge", "challenge": "example-challenge-token-000000000000", "account_id": "acct_123", "subscriber_id": "buyer-primary", "seller_agent_url": "https://seller.example/adcp", "delivery_auth": { "mode": "rfc9421" }, "event_types": ["creative.status_changed"] } ``` The receiver proves control by returning HTTP `2xx` with a JSON body containing exactly one echo field: ```json theme={null} { "challenge": "example-challenge-token-000000000000" } ``` Sellers MUST also accept the backward-compatible alias: ```json theme={null} { "token": "example-challenge-token-000000000000" } ``` The challenge value MUST be cryptographically random, single-use, and scoped to the registration tuple. A failed, non-`2xx`, malformed, mismatched, or timed-out challenge means proof failed. Sellers SHOULD use the same outbound fetch caps as SSRF validation (10 second connect and 10 second read) and SHOULD make at most one initial challenge POST on the `sync_accounts` critical path. A seller MAY retry one transient network failure before returning if it uses the same challenge value and still completes within its request budget; otherwise the buyer retries by re-sending `sync_accounts`. On proof failure, the seller returns a per-account failure with `action: "failed"`, `errors[].code: "VALIDATION_ERROR"` (or `INVALID_REQUEST` for malformed URLs), and `error.field` pointing at `accounts[i].notification_configs[j].url`. The previous persisted subscriber set remains unchanged. `dry_run: true` MUST NOT send network challenges; it can only report structural validation and what would require proof. Example — register a buyer-side endpoint plus an audit bus on an account-id namespace account: ```json theme={null} { "idempotency_key": "f2c4b7d9-6789-49bc-defa-2345678901bc", "accounts": [ { "account": { "account_id": "acc_acme_pinnacle" }, "notification_configs": [ { "subscriber_id": "buyer-primary", "url": "https://buyer.example/webhooks/adcp/creative", "event_types": ["creative.status_changed", "creative.purged"], "active": true }, { "subscriber_id": "audit-bus", "url": "https://audit.buyer.example/adcp/ingest", "event_types": ["creative.status_changed", "creative.purged"], "active": true } ] } ] } ``` Example — register a wholesale feed mirror subscriber for wholesale product and signal changes: ```json theme={null} { "idempotency_key": "a8af8cf1-89bd-41f3-b27d-7ee7e9f8d2e4", "accounts": [ { "account": { "account_id": "acc_acme_pinnacle" }, "notification_configs": [ { "subscriber_id": "wholesale-feed-sync", "url": "https://buyer.example/webhooks/adcp/wholesale-feed", "event_types": [ "product.created", "product.updated", "product.priced", "product.removed", "signal.created", "signal.updated", "signal.priced", "signal.removed", "wholesale_feed.bulk_change" ], "active": true } ] } ] } ``` Governance agents registered via [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance) are **not** implicitly subscribed to these webhooks. If your governance agent should also receive creative-lifecycle fires, register its URL as a separate `notification_configs[]` entry — explicit, auditable, with its own `event_types[]` filter. Verify applied state via [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) — the response carries the current persisted `notification_configs[]` per account with credentials redacted. `sync_accounts` also echoes the current sanitized set on `created`, `updated`, and `unchanged` results when the request included `notification_configs` or any persisted subscribers already exist. Wholesale feed notifications are registered here, not through a separate subscription task. The webhook body is [`wholesale-feed-webhook.json`](https://adcontextprotocol.org/schemas/3.1.20/core/wholesale-feed-webhook.json): it carries the changed product, signal, or bulk-change summary plus the post-change `wholesale_feed_version`. Sellers MUST apply the same per-subscriber authorization and scope predicate used by the corresponding wholesale read before emitting each webhook. Receivers MAY apply the payload to local mirrors; use `get_products` / `get_signals` with `if_wholesale_feed_version` to repair missed or distrusted pushes and before binding spend or authority. See [wholesale\_feed\_webhooks](/dist/docs/3.1.20/protocol/get_adcp_capabilities#wholesale_feed_webhooks) for capability declaration and event semantics. ## Common scenarios ### Agency syncing multiple brands ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAccountsResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncAccounts({ accounts: [ { brand: { domain: "nova-brands.com", brand_id: "spark" }, operator: "pinnacle-media.com", billing: "operator", }, { brand: { domain: "nova-brands.com", brand_id: "glow" }, operator: "pinnacle-media.com", billing: "operator", }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAccountsResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } for (const account of validated.accounts) { if (account.status === "active") { console.log(`Ready: ${account.brand.domain}/${account.brand.brand_id} → ${account.status}`); } else if (account.status === "pending_approval") { console.log(`Setup required for ${account.brand.brand_id}: ${account.setup?.url}`); // Poll list_accounts until status becomes active } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_accounts( accounts=[ { "brand": {"domain": "nova-brands.com", "brand_id": "spark"}, "operator": "pinnacle-media.com", "billing": "operator", }, { "brand": {"domain": "nova-brands.com", "brand_id": "glow"}, "operator": "pinnacle-media.com", "billing": "operator", }, ], ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for account in result.accounts: if account.status == 'active': print(f"Ready: {account.brand['domain']}/{account.brand.get('brand_id')} → {account.status}") elif account.status == 'pending_approval': print(f"Setup required for {account.brand.get('brand_id')}: {account.setup.url}") # Poll list_accounts until status becomes active asyncio.run(main()) ``` ### Direct brand purchase ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAccountsResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncAccounts({ accounts: [ { brand: { domain: "acme-corp.com" }, operator: "acme-corp.com", billing: "operator", }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAccountsResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } const account = validated.accounts[0]; if (account.status === "active") { console.log(`Ready: ${account.brand.domain} — ${account.status}`); } else if (account.status === "pending_approval") { console.log(`Setup required: ${account.setup?.url}`); // Poll list_accounts until status becomes active } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_accounts( accounts=[ { "brand": {"domain": "acme-corp.com"}, "operator": "acme-corp.com", "billing": "operator", }, ], ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") account = result.accounts[0] if account.status == 'active': print(f"Ready: {account.brand['domain']} — {account.status}") elif account.status == 'pending_approval': print(f"Setup required: {account.setup.url}") # Poll list_accounts until status becomes active asyncio.run(main()) ``` ### Handling rejection When a seller declines a request, the account entry has `status: "rejected"`: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAccountsResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncAccounts({ accounts: [ { brand: { domain: "acme-corp.com", brand_id: "clearance" }, operator: "acme-corp.com", }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAccountsResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } for (const account of validated.accounts) { if (account.status === "rejected") { console.log("Account request was rejected"); if (account.warnings?.length) { console.log(`Reason: ${account.warnings.join(", ")}`); } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_accounts( accounts=[ { "brand": {"domain": "acme-corp.com", "brand_id": "clearance"}, "operator": "acme-corp.com", }, ], ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for account in result.accounts: if account.status == 'rejected': print("Account request was rejected") warnings = getattr(account, 'warnings', None) if warnings: print(f"Reason: {', '.join(warnings)}") asyncio.run(main()) ``` ## Error handling | Error Code | Description | Resolution | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ACCOUNT_NOT_FOUND` | Referenced account does not exist or is not accessible | Check `account_id` or re-sync | | `BILLING_NOT_SUPPORTED` | Seller-wide capability gate (`supported_billing` does not include the value) or per-account-relationship gate; see [Billing and Account Setup](/dist/docs/3.1.20/building/by-layer/L3/error-handling#billing-and-account-setup) | Check `get_adcp_capabilities` for `supported_billing`, adjust or omit `billing`; inspect `error.details.scope` to disambiguate capability vs account scope | | `BILLING_NOT_PERMITTED_FOR_AGENT` | Seller-wide capability accepts the value, but the calling buyer agent's commercial relationship does not (e.g., passthrough-only — no payments relationship); see [Buyer-agent identity](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#buyer-agent-identity) | Retry with `error.details.suggested_billing` (typically `operator`) when present; when absent, surface to a human — the agent cannot extend its own commercial relationship | | `PAYMENT_TERMS_NOT_SUPPORTED` | Seller does not accept the requested payment terms | Omit `payment_terms` to accept the seller's default, or negotiate offline | | `ACCOUNT_PAYMENT_REQUIRED` | Account has an outstanding balance requiring payment | Resolve outstanding balance or route to another account | | `ACCOUNT_SUSPENDED` | Account is suspended | Contact seller to resolve | | `BRAND_REQUIRED` | Billable operation attempted without brand reference | Include `brand` in the request | ## Next steps * [list\_accounts](/dist/docs/3.1.20/accounts/tasks/list_accounts) -- Poll for status changes on pending accounts * [sync\_governance](/dist/docs/3.1.20/accounts/tasks/sync_governance) -- Sync governance agents to accounts * [Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) -- Billing models, trust models, and authorized operators * [Brand protocol](/dist/docs/3.1.20/brand-protocol/brand-json) -- How seller agents resolve brand identity from the `brand.domain` * [get\_adcp\_capabilities](/dist/docs/3.1.20/protocol/get_adcp_capabilities) -- Discover `supported_billing` and `require_operator_auth` before syncing accounts # sync_governance Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/accounts/tasks/sync_governance sync_governance syncs governance agent endpoints to specific accounts. The seller persists these agents and calls them via check_governance during media buy lifecycle events. Sync the governance agent endpoint for specific accounts. The seller persists the agent and calls it via `check_governance` during media buy lifecycle events. Each account entry pairs an [account reference](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) with exactly one governance agent, supporting both account-id namespaces (`account_id`) and buyer-declared accounts (`brand` + `operator`). An account binds to one governance agent that owns the full lifecycle. Authorization, delivery monitoring, and compliance are phases of the same evaluation against one plan, not specialisms held by separate authorities; specialist review (legal, brand safety, category) composes inside the governance agent rather than across multiple registrations. `governance_agents` is an array with `maxItems: 1` because the array shape is the shape 3.0 shipped with — the constraint is load-bearing and not a staging post toward loosening. The envelope's `governance_context` is singular below this layer; relaxing the cap would require a coordinated wire-shape change that is not planned. See [One governance agent per account](/dist/docs/3.1.20/governance/campaign/specification#one-governance-agent-per-account). This uses **replace semantics** — each call replaces any previously registered agent on the specified accounts. Accounts not included in the request keep their existing configuration. **Response Time**: \~1s. **Request Schema**: [`/schemas/3.1.20/account/sync-governance-request.json`](https://adcontextprotocol.org/schemas/3.1.20/account/sync-governance-request.json) **Response Schema**: [`/schemas/3.1.20/account/sync-governance-response.json`](https://adcontextprotocol.org/schemas/3.1.20/account/sync-governance-response.json) ## Quick Start Sync the governance agent for an account-id namespace account: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncGovernanceResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncGovernance({ accounts: [ { account: { account_id: "acct-social-001" }, governance_agents: [ { url: "https://governance.pinnacle-media.com", authentication: { schemes: ["Bearer"], credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ] } ] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncGovernanceResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } for (const entry of validated.accounts) { if (entry.status === "synced") { console.log(`${JSON.stringify(entry.account)}: ${entry.governance_agents.length} agent registered`); } else { console.log(`${JSON.stringify(entry.account)}: failed — ${JSON.stringify(entry.errors)}`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_governance( accounts=[ { "account": {"account_id": "acct-social-001"}, "governance_agents": [ { "url": "https://governance.pinnacle-media.com", "authentication": { "schemes": ["Bearer"], "credentials": "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ] } ] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for entry in result.accounts: if entry.status == "synced": print(f"{entry.account}: {len(entry.governance_agents)} agent registered") else: print(f"{entry.account}: failed — {entry.errors}") asyncio.run(main()) ``` ## Request Parameters | Parameter | Type | Required | Description | | ---------- | ----- | -------- | -------------------------------------------------------------------------------------------------------------- | | `accounts` | array | Yes | Per-account governance agent entries. Each pairs an account reference with governance agents for that account. | **Each account entry:** | Field | Type | Required | Description | | ------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `account` | object | Yes | [Account reference](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references): `{account_id}` for account-id namespaces or `{brand, operator}` for buyer-declared accounts. | | `governance_agents` | array | Yes | Governance agent endpoint for this account. Array with exactly one entry (`minItems: 1`, `maxItems: 1`). | **The governance agent:** | Field | Type | Required | Description | | ---------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `url` | string | Yes | HTTPS endpoint URL for the governance agent. | | `authentication` | object | Yes | Credentials the seller presents when calling this agent. Contains `schemes` (array with one auth scheme) and `credentials` (token, min 32 characters). | ## Response **Success response:** Returns an `accounts` array with per-account results. Individual entries may fail even when the operation succeeds. | Field | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------- | | `account` | Account reference, echoed from request. | | `status` | `"synced"` or `"failed"`. | | `governance_agents` | Governance agents now active on this account. Reflects persisted state. Only present when `status: "synced"`. | | `errors` | Per-account errors. Only present when `status: "failed"`. | **Error response:** `errors` array with operation-level errors (auth failure, service unavailable). No `accounts` array is present. ## Authorization The seller MUST verify that the authenticated agent has authority over each referenced account before persisting governance agents. Requests referencing accounts the agent does not own MUST return a `failed` status with an error for those entries. ## Common Scenarios ### Different governance agents per account A single `sync_governance` call can register a distinct agent per account — each account still binds to exactly one agent, but accounts on the same call need not share it. ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncGovernanceResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncGovernance({ accounts: [ { account: { account_id: "acct-social-001" }, governance_agents: [ { url: "https://governance.pinnacle-media.com", authentication: { schemes: ["Bearer"], credentials: "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ] }, { account: { account_id: "acct-social-002" }, governance_agents: [ { url: "https://governance.acme-buyer.com", authentication: { schemes: ["Bearer"], credentials: "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" } } ] } ] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncGovernanceResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } for (const entry of validated.accounts) { console.log(`${JSON.stringify(entry.account)}: ${entry.status}`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_governance( accounts=[ { "account": {"account_id": "acct-social-001"}, "governance_agents": [ { "url": "https://governance.pinnacle-media.com", "authentication": { "schemes": ["Bearer"], "credentials": "gov-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } } ] }, { "account": {"account_id": "acct-social-002"}, "governance_agents": [ { "url": "https://governance.acme-buyer.com", "authentication": { "schemes": ["Bearer"], "credentials": "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" } } ] } ] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for entry in result.accounts: print(f"{entry.account}: {entry.status}") asyncio.run(main()) ``` ### Buyer-declared accounts (brand + operator) ```json Request theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/account/sync-governance-request.json", "idempotency_key": "e5b9f2c3-1234-48a0-1234-56789012345e", "accounts": [ { "account": { "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "operator": "pinnacle-media.com" }, "governance_agents": [ { "url": "https://governance.pinnacle-media.com", "authentication": { "schemes": ["Bearer"], "credentials": "gov-token-yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" } } ] } ] } ``` ### Rotate governance agent credentials Call `sync_governance` again with updated `authentication`. Replace semantics means the new credentials overwrite the previous configuration. ### Migrating from pre-3.1 multi-agent registration Earlier drafts of 3.0 allowed up to 10 governance agents per account with per-agent `categories`. 3.1 constrains `governance_agents` to exactly one entry and removes `categories`. Buyers that registered more than one agent against the previous shape MUST collapse to a single agent on their next `sync_governance` call; the seller's persisted state is replaced. The new request schema rejects more than one agent outright, so no "mixed-mode" window exists. **Buyer-side collapse decision.** Which of the previously-registered agents becomes the single agent is a buyer-internal decision — the protocol does not rank or recommend. Typical paths: (a) keep the agent with the broadest policy coverage (usually the budget/spend-authority agent) and fold specialist logic (legal, brand safety, regulatory review) into it as internal workflow; (b) deploy a new "front-door" governance agent that fans out to the previous specialists internally, and register only that agent; (c) keep the agent that was always the de facto governance surface and fold the others' specialist review into it as internal workflow without re-registering them. Surface the internal decomposition to auditors via `categories_evaluated` and `findings[].details` on check responses so the audit trail retains what each internal reviewer contributed. **Seller-side.** Sellers MAY, on first boot under the new schema, collapse previously-persisted multi-agent state to the first entry (ordered by original sync position) and log the migration to their audit trail. Sellers SHOULD surface a clear error to buyers whose next `sync_governance` call attempts to re-register multiple agents, pointing at this migration guidance. ## Error Handling | Error Code | Description | Resolution | | ------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ | | `ACCOUNT_NOT_FOUND` | Referenced account does not exist or is not accessible | Verify account reference via `list_accounts` or `sync_accounts` | | `UNAUTHORIZED` | Agent does not have authority over the referenced account | Check that you are authenticated as an agent with access to this account | ## Next Steps * [list\_accounts](/dist/docs/3.1.20/accounts/tasks/list_accounts) — Discover accounts and their current governance agents * [sync\_accounts](/dist/docs/3.1.20/accounts/tasks/sync_accounts) — Provision or link advertiser accounts * [check\_governance](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) — How sellers call governance agents during media buy events * [Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) — Account models, billing, and trust # A2A Guide Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L0/a2a-guide AdCP A2A integration guide: client setup, agent card verification, SSE streaming for async tasks, artifact handling, and response format for Agent-to-Agent Protocol. Transport-specific guide for integrating AdCP using the Agent-to-Agent Protocol. For task handling, status management, and workflow patterns, see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle). ## A2A Protocol Versions AdCP tracks the [A2A specification](https://a2a-protocol.org/latest/) under Linux Foundation governance. The **1.0** wire format is the target; **v0.3** remains widely deployed and is supported through the compatibility period. ### What Changed in 1.0 | Area | v0.3 | 1.0 | | -------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------- | | Agent Card transport | `url` + `protocolVersion` at root | `supportedInterfaces[]` array with per-interface `url`, `protocolBinding`, `protocolVersion` | | `Part` discriminator | `kind: "text" \| "data" \| "file"` | No `kind` — content determined by which field is set (`text`, `data`, `url`, `raw`) | | File fields | `uri`, `name`, `mimeType` | `url` (by reference) or `raw` (base64 bytes), `filename`, `mediaType` | | Message role | `"user"` / `"agent"` | `"ROLE_USER"` / `"ROLE_AGENT"` (ProtoJSON canonical) | | Task state | `"completed"`, `"working"`, … | `"TASK_STATE_COMPLETED"`, `"TASK_STATE_WORKING"`, … | | Timestamps | ISO-8601 | ISO-8601 UTC with ms precision (`YYYY-MM-DDTHH:mm:ss.sssZ`) | AdCP's own unified top-level `status` field (returned by `@adcp/sdk`) continues to use the lowercase shorthand (`"completed"`, `"working"`, …) — that is an AdCP abstraction over the raw A2A `status.state`, not an A2A wire value. ### Dual-Version Compatibility Servers that need to serve both v0.3 and 1.0 clients advertise both interfaces in their Agent Card and enable explicit compatibility at the transport layer (e.g. `enable_v0_3_compat=True` in the Python SDK). Backward compatibility is **not** enabled by default. Clients that speak 1.0 can talk to a v0.3 server when the SDK provides downward translation; the reverse (v0.3 client → 1.0-only server) requires the server to enable compat. ### Examples in This Guide Examples below use **1.0 wire format** (no `kind` field, ProtoJSON enums). For a v0.3 server, the same Part becomes `{ kind: "text", text: "…" }` and states become lowercase. AdCP extraction clients (see [A2A Response Extraction](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction)) accept both shapes during the compatibility period. ## A2A Client Setup ### 1. Initialize A2A Client ```javascript theme={null} const a2a = new A2AClient({ endpoint: 'https://adcp.example.com/a2a', auth: { type: 'bearer', token: process.env.ADCP_API_KEY }, agent: { name: "AdCP Media Buyer", version: "1.0.0" } }); ``` ### 2. Verify Agent Card ```javascript theme={null} // Check available skills const agentCard = await a2a.getAgentCard(); console.log(agentCard.skills.map(s => s.name)); // ["get_products", "create_media_buy", "sync_creatives", ...] ``` ### 3. Send Your First Task ```javascript theme={null} const response = await a2a.send({ message: { role: "ROLE_USER", parts: [{ text: "Find video products for pet food campaign" }] } }); // All responses include unified status field (AdCP 1.6.0+) console.log(response.status); // "completed" | "input-required" | "working" | etc. console.log(response.message); // Human-readable summary ``` ## Message Structure (A2A-Specific) ### Multi-Part Messages A2A's key advantage is multi-part messages combining text, data, and files: ```javascript theme={null} // Text + structured data + file const response = await a2a.send({ message: { role: "ROLE_USER", parts: [ { text: "Create campaign with these assets" }, { data: { skill: "create_media_buy", parameters: { packages: ["pkg_001"], total_budget: 100000 } } }, { url: "https://cdn.example.com/hero-video.mp4", filename: "hero_video_30s.mp4", mediaType: "video/mp4" } ] } }); ``` ### Skill Invocation Methods #### Natural Language (Flexible) ```javascript theme={null} // Agent interprets intent const task = await a2a.send({ message: { role: "ROLE_USER", parts: [{ text: "Find premium CTV inventory under $50 CPM" }] } }); ``` #### Explicit Skill (Deterministic) ```javascript theme={null} // Explicit skill with exact parameters const task = await a2a.send({ message: { role: "ROLE_USER", parts: [{ data: { skill: "get_products", parameters: { max_cpm: 50, channels: ["ctv"], tier: "premium" } } }] } }); ``` #### Hybrid Approach (Recommended) ```javascript theme={null} // Context + explicit execution for best results const task = await a2a.send({ message: { role: "ROLE_USER", parts: [ { text: "Looking for inventory for spring campaign targeting millennials" }, { data: { skill: "get_products", parameters: { audience: "millennials", season: "Q2_2024", max_cpm: 45 } } } ] } }); ``` **Status Handling**: See [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for complete status handling patterns. ## A2A Response Format **New in AdCP 1.6.0**: All responses include unified status field. ### Canonical Response Structure AdCP responses over A2A **MUST** include at least one DataPart (a Part carrying a `data` field) containing the task response. A TextPart (a Part carrying a `text` field) for human-readable messages is **recommended** but optional. ```json theme={null} { "status": "completed", // AdCP unified status (see Core Concepts) "taskId": "task-123", // A2A task identifier "contextId": "ctx-456", // Automatic context management "artifacts": [{ // A2A-specific artifact structure "artifactId": "artifact-product-catalog-abc", "name": "product_catalog", "parts": [ { "text": "Found 12 video products perfect for pet food campaigns" }, { "data": { "products": [...], "total": 12 } } ] }] } ``` The A2A 1.0 wire format carries no `kind` discriminator — the Part's content type is implied by which field is set (`text`, `data`, `url`, or `raw`). For v0.3 servers/clients, the equivalent Part includes `"kind": "text"` / `"kind": "data"` / `"kind": "file"`. **For complete canonical format specification, see [A2A Response Format](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format).** ### A2A-Specific Fields * **taskId**: A2A task identifier for streaming updates * **contextId**: Automatically managed by A2A protocol * **artifacts**: Multi-part deliverables with text and data parts * **status**: AdCP's unified lowercase shorthand, mapped from A2A's `status.state` (see [A2A Response Extraction](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction#wire-format-compatibility)) ### Processing Artifacts AdCP responses use the **last `DataPart` as authoritative** when multiple data parts exist (e.g., from streaming operations): ```javascript theme={null} // Extract the artifact (currently AdCP returns single artifact per response) const artifact = response.artifacts?.[0]; if (artifact) { // Detect Part type by presence of field (1.0) with kind fallback (v0.3) const isText = (p) => typeof p.text === 'string' || p.kind === 'text'; const isData = (p) => p.data != null || p.kind === 'data'; const message = artifact.parts?.find(isText)?.text; const data = artifact.parts?.find(isData)?.data; return { artifactId: artifact.artifactId, message, data, status: response.status }; } return { status: response.status }; ``` **For complete response structure requirements, error handling, and implementation patterns, see [A2A Response Format](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format).** ## Push Notifications (A2A-Specific) A2A defines push notifications natively via `PushNotificationConfig`. When you configure a webhook URL, the server will POST task updates directly to your endpoint instead of requiring you to poll. ### Correlation: payload field, not URL Correlate incoming notifications using `operation_id` (and `task_type`) from the payload body — **never** by parsing `pushNotificationConfig.url`. The URL is opaque to the server; the wire-level source of truth for correlation is the payload field. See [Webhooks — Operation IDs and URL templates](/dist/docs/3.1.20/building/by-layer/L3/webhooks#operation-ids-and-url-templates) for the full normative wire contract (it applies to both MCP and A2A — every comparable async-notification protocol in ad tech makes the URL opaque to the firing entity). Buyers MAY encode `operation_id` in the URL path or query as a routing aid for their own HTTP server — many web frameworks dispatch on path segments before parsing the body — but that's a buyer-side server design choice, not part of the wire contract. A buyer's server-routing template is not visible to the seller; the seller reads `operation_id` only from the buyer-supplied `pushNotificationConfig.operation_id` field and echoes it verbatim in the payload. **URL templates (buyer-side server routing only):** ```javascript theme={null} // Path parameters url: `https://buyer.com/webhooks/a2a/${taskType}/${operationId}` // Query parameters url: `https://buyer.com/webhooks/a2a?task=${taskType}&op=${operationId}` // Or fully opaque — the seller doesn't care about URL shape url: `https://buyer.com/webhooks/${randomToken}` ``` **Example Configuration:** ```javascript theme={null} const operationId = "op_nike_q1_2025"; const taskType = "create_media_buy"; await a2a.send({ message: { role: "ROLE_USER", parts: [{ data: { skill: "create_media_buy", parameters: { /* task params */ } } }] }, pushNotificationConfig: { url: `https://buyer.com/webhooks/a2a/${taskType}/${operationId}`, operation_id: operationId, // canonical correlation channel — seller echoes verbatim token: "client-validation-token", // Optional: for client-side validation authentication: { schemes: ["bearer"], credentials: "shared_secret_32_chars" } } }); ``` For webhook payload formats, protocol comparison, and detailed handling examples, see [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks). ## SSE Streaming (A2A-Specific) A2A's key advantage is real-time updates via Server-Sent Events: AdCP still owns the application-layer task lifecycle on A2A. A2A `Task`, `taskId`, SSE, and push-notification frames are transport delivery mechanics; the durable business operation remains the AdCP payload keyed by AdCP `task_id`. A completed A2A task can still carry an AdCP response whose payload says `status: 'submitted'`. ### Task Monitoring ```javascript theme={null} class A2aTaskMonitor { constructor(taskId) { this.taskId = taskId; this.events = new EventSource(`/a2a/tasks/${taskId}/events`); this.events.addEventListener('status', (e) => { const update = JSON.parse(e.data); this.handleStatusUpdate(update); }); this.events.addEventListener('progress', (e) => { const data = JSON.parse(e.data); console.log(`${data.percentage}% - ${data.message}`); }); } handleStatusUpdate(update) { switch (update.status) { case 'input-required': // Handle clarification/approval needed this.emit('input-required', update); break; case 'completed': this.events.close(); this.emit('completed', update); break; case 'failed': this.events.close(); this.emit('failed', update); break; } } } ``` ### Real-Time Updates Example ```javascript theme={null} // Start long-running operation const response = await a2a.send({ message: { role: "ROLE_USER", parts: [{ data: { skill: "create_media_buy", parameters: { packages: ["pkg_001"], total_budget: 100000 } } }] } }); // Monitor A2A transport progress in real time via SSE if (response.status === 'working' || response.status === 'submitted') { const monitor = new A2aTaskMonitor(response.taskId); monitor.on('progress', (data) => { updateUI(`${data.percentage}%: ${data.message}`); }); monitor.on('completed', (final) => { // Extract last DataPart from the artifact — don't assume a positional index. const parts = final.artifacts[0].parts; const dataParts = parts.filter(p => p.data != null || p.kind === 'data'); const payload = dataParts[dataParts.length - 1]?.data; if (payload?.status === 'submitted') { // A2A delivery completed, but the AdCP operation is still queued. return pollAdcpTask(payload.task_id); } console.log('Created:', payload?.media_buy_id); }); } ``` ### A2A Webhook Payload Examples **Example 1: `Task` payload for completed operation** When a task finishes, the server sends the full `Task` object wrapped in the A2A 1.0 `StreamResponse` envelope. The task result lives in `.artifacts`: ```json theme={null} { "task": { "id": "task_456", "contextId": "ctx_123", "status": { "state": "TASK_STATE_COMPLETED", "timestamp": "2026-01-22T10:30:00.000Z" }, "artifacts": [{ "name": "task_result", "parts": [ { "text": "Media buy created successfully" }, { "data": { "media_buy_id": "mb_12345", "creative_deadline": "2026-01-30T23:59:59.000Z", "packages": [ { "package_id": "pkg_001", "context": { "line_item": "li_ctv_sports" } } ] } } ] }] } } ``` **CRITICAL**: For **`completed`, `failed`, or `rejected`** status, the AdCP task result **MUST** be in `.artifacts[0].parts[]`. If the server has only a free-text fatal message (no structured payload), it MAY fall back to `status.message.parts[]` — clients handle both. The A2A 1.0 `StreamResponse` oneof wraps every SSE frame and push-notification payload with exactly one of: `{ task }`, `{ statusUpdate }`, `{ artifactUpdate }`, `{ message }` (A2A 1.0 §3.2.3, §4.3.3). Non-streaming responses from `tasks/get` and v0.3 servers deliver the bare object. Clients unwrap before reading fields. **Example 2: `TaskStatusUpdateEvent` for progress updates** During execution, interim status updates can include optional data in `status.message.parts[]`. SSE/push frames wrap the event as `{ "statusUpdate": { … } }`: ```json theme={null} { "statusUpdate": { "taskId": "task_456", "contextId": "ctx_123", "status": { "state": "TASK_STATE_INPUT_REQUIRED", "message": { "role": "ROLE_AGENT", "parts": [ { "text": "Campaign budget $150K requires VP approval" }, { "data": { "reason": "BUDGET_EXCEEDS_LIMIT" } } ] }, "timestamp": "2026-01-22T10:15:00.000Z" } } } ``` **All status payloads use AdCP schemas**: Both final statuses (completed/failed) and interim statuses (working, input-required, submitted) have corresponding AdCP schemas referenced in [`async-response-data.json`](https://adcontextprotocol.org/schemas/3.1.20/core/async-response-data.json). Note that interim status schemas are evolving and may change in future versions, so implementors may choose to handle them more loosely. ### A2A Webhook Payload Types Per the [A2A 1.0 specification](https://a2a-protocol.org/latest/specification/#433-push-notification-payload), the server sends different payload types wrapped in the `StreamResponse` oneof: | Envelope Key | Inner Payload | When Used | What It Contains | | ---------------- | ------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | `task` | `Task` | Final states (`completed`, `failed`, `canceled`, `rejected`) or when full context needed | Complete task object with all history and artifact data | | `statusUpdate` | `TaskStatusUpdateEvent` | Status transitions during execution (`working`, `input-required`, `auth-required`, `submitted`) | Lightweight status change with message parts | | `artifactUpdate` | `TaskArtifactUpdateEvent` | Streaming artifact updates | Artifact chunk with `append` / `lastChunk` flags | | `message` | `Message` | Out-of-band agent messages | A message unattached to a task status transition | For AdCP, most webhooks will be: * `{ task }` for final results (`completed`, `failed`, `rejected`) * `{ statusUpdate }` for progress updates (`working`, `input-required`, `auth-required`) Clients unwrap the single-key envelope before reading fields. Non-streaming responses (e.g., `tasks/get`) deliver the bare payload — unwrapping a single-key envelope is a no-op there. **Envelope semantics:** * **`{ artifactUpdate }`** frames carry incremental artifact chunks with boolean flags `append` (concatenate parts onto the named artifact) and `lastChunk` (marks the final chunk). AdCP clients consuming streams SHOULD accumulate these into the target artifact, then apply the extraction algorithm when the `{ task }` frame arrives with a terminal state. Clients consuming push notifications typically receive the already-merged `Task` object and can ignore individual `artifactUpdate` frames. See A2A 1.0 §7.3. * **`{ message }`** frames are out-of-band agent messages unattached to a task status transition. AdCP is task-oriented — task-facing clients SHOULD log and ignore bare `message` envelopes. ### Webhook Trigger Rules Webhooks are sent when **all** of these conditions are met: 1. **Task type supports async** (e.g., `create_media_buy`, `sync_creatives`, `get_products`) 2. **`pushNotificationConfig` is provided** in the request 3. **Task runs asynchronously** — initial response is `working` or `submitted` If the initial response is already terminal (`completed`, `failed`, `rejected`), no webhook is sent—you already have the result. **Status changes that trigger webhooks:** * `working` → Progress update (task actively processing) * `input-required` → Human input needed * `auth-required` (1.0) → Re-authentication challenge during execution * `completed` → Final result available * `failed` → Error details * `rejected` (1.0) → Policy/validation rejection with `adcp_error` * `canceled` → Cancellation confirmed ### Data Schema Validation The DataPart `data` field in A2A webhooks uses status-specific schemas: | Status | Schema | Contents | | --------------------- | ------------------------------------------- | --------------------------------------------- | | `completed` | `[task]-response.json` | Full task response (success branch) | | `failed` | `[task]-response.json` | Full task response (error branch) | | `rejected` (1.0) | `[task]-response.json` (error branch) | Policy/validation rejection with `adcp_error` | | `working` | `[task]-async-response-working.json` | Progress info (`percentage`, `step`) | | `input-required` | `[task]-async-response-input-required.json` | Requirements, approval data | | `auth-required` (1.0) | `[task]-async-response-auth-required.json` | Auth challenge (scheme, URL, scopes) | | `submitted` | `[task]-async-response-submitted.json` | Acknowledgment (usually minimal) | Schema reference: [`async-response-data.json`](https://adcontextprotocol.org/schemas/3.1.20/core/async-response-data.json) ### Webhook Handler Example ```javascript theme={null} const express = require('express'); const app = express(); app.post('/webhooks/a2a/:taskType/:operationId', async (req, res) => { const { taskType, operationId } = req.params; const rawBody = req.body; // Verify webhook authenticity (Bearer token example) const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing Authorization header' }); } const token = authHeader.substring(7); if (token !== process.env.A2A_WEBHOOK_TOKEN) { return res.status(401).json({ error: 'Invalid token' }); } // Unwrap A2A 1.0 StreamResponse envelope: { task } | { statusUpdate } | { artifactUpdate } | { message } const envelopeKeys = ['task', 'message', 'statusUpdate', 'artifactUpdate']; const bodyKeys = Object.keys(rawBody || {}); const webhook = (bodyKeys.length === 1 && envelopeKeys.includes(bodyKeys[0])) ? rawBody[bodyKeys[0]] : rawBody; // Extract basic fields from A2A webhook payload const taskId = webhook.id || webhook.taskId; const contextId = webhook.contextId; const status = webhook.status?.state || webhook.status; // Normalize 1.0 / v0.3 state values const normalizeState = (s) => s?.replace(/^TASK_STATE_/, '').toLowerCase().replace(/_/g, '-'); const normalizedStatus = normalizeState(status); // Detect Part type by field presence (1.0) with kind fallback (v0.3) const isDataPart = (p) => p.data != null || p.kind === 'data'; const isTextPart = (p) => typeof p.text === 'string' || p.kind === 'text'; // Extract AdCP data based on status let adcpData, textMessage; const FINAL = ['completed', 'failed', 'canceled', 'rejected']; if (FINAL.includes(normalizedStatus)) { // FINAL STATES: Extract from .artifacts (fallback to status.message.parts) const artifactParts = webhook.artifacts?.[0]?.parts; const dataPart = artifactParts?.find(isDataPart) ?? webhook.status?.message?.parts?.find(isDataPart); const textPart = artifactParts?.find(isTextPart) ?? webhook.status?.message?.parts?.find(isTextPart); adcpData = dataPart?.data; textMessage = textPart?.text; } else { // INTERIM STATES: Extract from status.message.parts (optional) const dataPart = webhook.status?.message?.parts?.find(isDataPart); const textPart = webhook.status?.message?.parts?.find(isTextPart); adcpData = dataPart?.data; textMessage = textPart?.text; } // Handle status changes (normalized works for both 1.0 and v0.3 wire values) switch (normalizedStatus) { case 'input-required': // Alert human that input is needed await notifyHuman({ task_id: taskId, context_id: contextId, message: textMessage, data: adcpData }); break; case 'auth-required': // A2A 1.0: re-authenticate and resume the task // SECURITY: validate challenge_url against the agent's registered origin // before opening/fetching. See A2A Response Extraction §Auth Challenge URL Validation. if (!isValidChallengeUrl(adcpData?.challenge_url, agentAuthOrigin(taskId))) { return res.status(400).json({ error: 'Invalid challenge_url for agent' }); } await startAuthChallenge({ task_id: taskId, auth_scheme: adcpData?.auth_scheme, challenge_url: adcpData.challenge_url, scopes: adcpData?.scopes // show to user for fresh consent, do not auto-grant }); break; case 'completed': // Process the completed operation if (adcpData?.media_buy_id) { await handleMediaBuyCreated({ media_buy_id: adcpData.media_buy_id, packages: adcpData.packages }); } break; case 'failed': // Handle failure await handleOperationFailed({ task_id: taskId, error: adcpData?.adcp_error ?? adcpData?.errors, message: textMessage }); break; case 'rejected': // A2A 1.0: policy/validation rejection with structured adcp_error await handleOperationRejected({ task_id: taskId, error: adcpData?.adcp_error, message: textMessage }); break; case 'working': // Update progress UI await updateProgress({ task_id: taskId, percentage: adcpData?.percentage, message: textMessage }); break; case 'canceled': await handleOperationCanceled(taskId); break; } // Always return 200 for successful processing res.status(200).json({ status: 'processed' }); }); ``` ## Context Management (A2A-Specific) **Key Advantage**: A2A handles context automatically - no manual context\_id management needed. ### Automatic Context ```javascript theme={null} // First request - A2A creates context automatically const response1 = await a2a.send({ message: { role: "ROLE_USER", parts: [{ text: "Find premium video products" }] } }); // Follow-up - A2A remembers context automatically const response2 = await a2a.send({ message: { role: "ROLE_USER", parts: [{ text: "Filter for sports content" }] } }); // System automatically connects this to previous request ``` ### Explicit Context (Optional) ```javascript theme={null} // When you need explicit control const response2 = await a2a.send({ contextId: response1.contextId, // Optional - A2A tracks this anyway message: { role: "ROLE_USER", parts: [{ text: "Refine those results" }] } }); ``` **vs. MCP**: Unlike MCP's manual context\_id management, A2A handles session continuity at the protocol level. ## Multi-Modal Messages (A2A-Specific) A2A's unique capability - combine text, data, and files in one message: ### Creative Upload with Context ```javascript theme={null} // Upload creative with campaign context in single message const response = await a2a.send({ message: { role: "ROLE_USER", parts: [ { text: "Add this hero video to the premium sports campaign" }, { data: { skill: "sync_creatives", parameters: { media_buy_id: "mb_12345", action: "upload_and_assign" } } }, { url: "https://cdn.example.com/hero-30s.mp4", filename: "sports_hero_30s.mp4", mediaType: "video/mp4" } ] } }); ``` ### Campaign Brief + Assets ```javascript theme={null} // Submit comprehensive campaign brief await a2a.send({ message: { role: "ROLE_USER", parts: [ { text: "Campaign brief and assets for Q1 launch" }, { url: "https://docs.google.com/campaign-brief.pdf", filename: "Q1_campaign_brief.pdf", mediaType: "application/pdf" }, { data: { budget: 250000, kpis: ["reach", "awareness", "conversions"], target_launch: "2026-01-15" } } ] } }); ``` ## Available Skills All AdCP tasks are available as A2A skills. Use explicit invocation for deterministic execution: **Task Management**: For comprehensive guidance on tracking async operations across all domains, polling patterns, and webhook integration, see [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks). ### Skill Structure ```javascript theme={null} // Standard pattern for explicit skill invocation await a2a.send({ message: { role: "ROLE_USER", parts: [{ data: { skill: "skill_name", // Exact name from Agent Card parameters: { // Task-specific parameters // See task documentation for parameters } } }] } }); ``` ### Available Skills * **Protocol**: `get_adcp_capabilities` (start here to discover agent capabilities) * **Media Buy**: `get_products`, `list_creative_formats`, `create_media_buy`, `update_media_buy`, `sync_creatives`, `get_media_buy_delivery`, `provide_performance_feedback` * **Signals**: `get_signals`, `activate_signal` **Task Parameters**: See [Media Buy](/dist/docs/3.1.20/media-buy) and [Signals](/dist/docs/3.1.20/signals/overview) documentation for complete parameter specifications. ## Agent Cards A2A agents advertise capabilities via Agent Cards at `.well-known/agent.json`. ### Discovering Agent Cards ```javascript theme={null} // Get agent capabilities const agentCard = await a2a.getAgentCard(); // List available skills const skillNames = agentCard.skills.map(skill => skill.name); console.log('Available skills:', skillNames); // Get skill details const getProductsSkill = agentCard.skills.find(s => s.name === 'get_products'); console.log('Examples:', getProductsSkill.examples); // Pick a transport interface (1.0) const jsonrpc = agentCard.supportedInterfaces?.find( i => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0' ); console.log('Endpoint:', jsonrpc?.url); ``` ### Sample Agent Card Structure (A2A 1.0) In 1.0, the top-level `url` and `protocolVersion` fields from v0.3 are replaced by a `supportedInterfaces` array. Each entry advertises one transport binding and protocol version. `supportsAuthenticatedExtendedCard` moved to `capabilities.extendedAgentCard`. ```json theme={null} { "name": "AdCP Media Buy Agent", "description": "AI-powered media buying agent", "version": "1.0.0", "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer" } }, "security": [{"bearerAuth": []}], "supportedInterfaces": [ { "url": "https://sales.example.com/a2a/jsonrpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" } ], "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["application/json"], "capabilities": { "streaming": true, "pushNotifications": true, "extendedAgentCard": false }, "skills": [ { "name": "get_products", "description": "Discover available advertising products", "examples": [ "Find premium CTV inventory for sports fans", "Show me video products under $50 CPM" ] } ], "extensions": [ { "uri": "https://adcontextprotocol.org/extensions/adcp", "description": "AdCP media buying protocol support", "required": false, "params": { "adcp_version": "2.6.0", "protocols_supported": ["media_buy"], "extensions_supported": ["sustainability"] } } ] } ``` ### Dual-Advertising for v0.3 Compatibility Servers transitioning from v0.3 advertise both interfaces. Clients pick the version they understand: ```json theme={null} { "supportedInterfaces": [ { "url": "https://sales.example.com/a2a/jsonrpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }, { "url": "https://sales.example.com/", "protocolBinding": "JSONRPC", "protocolVersion": "0.3" } ] } ``` Python SDK servers must also pass `enable_v0_3_compat=True` when constructing routes — backward compatibility is not enabled by default. See the [A2A Python SDK 1.0 migration guide](https://github.com/a2aproject/a2a-python/blob/v1.0.0/docs/migrations/v1_0/README.md). ### AdCP Extension **Recommended**: Use [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) for runtime capability discovery. The agent card extension provides static metadata for agent registries and discovery services. Include the AdCP extension in your agent card's `extensions` array to declare AdCP support programmatically. The A2A protocol uses an `extensions` array where each extension has: * **`uri`**: Extension identifier (use `https://adcontextprotocol.org/extensions/adcp`) * **`description`**: Human-readable description of how you use AdCP * **`required`**: Whether clients must support this extension (typically `false` for AdCP) * **`params`**: AdCP-specific configuration (see schema below) ```javascript theme={null} // Check if agent supports AdCP const agentCard = await fetch('https://sales.example.com/.well-known/agent.json') .then(r => r.json()); // Find the AdCP extension in the extensions array const adcpExt = agentCard.extensions?.find( ext => ext.uri === 'https://adcontextprotocol.org/extensions/adcp' ); if (adcpExt) { console.log('AdCP Version:', adcpExt.params.adcp_version); console.log('Supported domains:', adcpExt.params.protocols_supported); // ["media_buy", "creative", "signals"] console.log('Typed extensions:', adcpExt.params.extensions_supported); // ["sustainability"] } ``` **Extension Params**: The `adcp-extension.json` schema was used in v2 to describe these params, but was removed in v3. For v3+ agents, use the `get_adcp_capabilities` task for runtime capability discovery instead. The extension `params` object above shows the typical structure. :::note The `adcp_version` field in agent card metadata is a v2 convention and is not part of the v3 spec. For v3 version negotiation, the buyer sends release-precision `adcp_version` (e.g., `"3.1"`) on every request, and the seller advertises supported releases via `adcp.supported_versions` on [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) and echoes `adcp_version` at the envelope root on every response. The legacy integer-only `adcp_major_version` field is still accepted for backwards compatibility. See [versioning.mdx § Version negotiation](/dist/docs/3.1.20/reference/versioning#version-negotiation) for the full contract. ::: **Benefits**: * Clients can discover AdCP capabilities without making test calls * Declare which protocol domains you implement (media\_buy, creative, signals) * Enable compatibility checks based on version ## Integration Example ```javascript theme={null} // Initialize A2A client const a2a = new A2AClient({ /* config */ }); // Use unified status handling (see Core Concepts) async function handleA2aResponse(response) { switch (response.status) { case 'input-required': // Handle clarification (see Core Concepts for patterns) const input = await promptUser(response.message); return a2a.send({ contextId: response.contextId, message: { role: "ROLE_USER", parts: [{ text: input }] } }); case 'working': // Monitor via SSE streaming return streamUpdates(response.taskId); case 'completed': // Extract last DataPart — presence of .data field identifies it in 1.0 const parts = response.artifacts[0].parts; const dataParts = parts.filter(p => p.data != null || p.kind === 'data'); return dataParts[dataParts.length - 1].data; case 'failed': throw new Error(response.message); } } // Example usage with multi-modal message const result = await a2a.send({ message: { role: "ROLE_USER", parts: [ { text: "Find luxury car inventory" }, { data: { skill: "get_products", parameters: { audience: "luxury car intenders" } } } ] } }); const finalResult = await handleA2aResponse(result); ``` ## A2A-Specific Considerations ### Error Handling Failed tasks carry structured AdCP errors in artifact `DataPart` under the `adcp_error` key. For the full extraction logic and recovery behavior, see [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors). ```javascript theme={null} try { const response = await a2a.send(message); if (response.status === 'failed') { // Check for structured AdCP error in artifacts // Detect DataPart by field presence (1.0) or kind (v0.3) const dataPart = response.artifacts?.[0]?.parts?.find( p => p.data != null || p.kind === 'data' ); const adcpError = dataPart?.data?.adcp_error; if (adcpError) { // Structured error with code, recovery, retry_after, etc. console.log('AdCP error:', adcpError.code, adcpError.recovery); if (adcpError.recovery === 'transient') { // Retry after delay await sleep((adcpError.retry_after || 5) * 1000); return retry(); } } throw new Error(response.message); } } catch (a2aError) { // A2A transport error (connection, auth, etc.) console.error('A2A Error:', a2aError); } ``` ### Creative Upload Error Handling For uploading creative assets and handling validation errors, use the `sync_creatives` task. See [sync\_creatives Task Reference](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for complete testable examples. The `@adcp/sdk` library handles A2A artifact extraction automatically, so you don't need to manually parse the response structure. ## Best Practices 1. **Use hybrid messages** for best results (text + data + optional files) 2. **Check status field** before processing artifacts 3. **Leverage SSE streaming** for real-time updates on long operations 4. **Reference Core Concepts** for status handling patterns 5. **Use agent cards** to discover available skills and examples ## Next Steps * **Core Concepts**: Read [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for status handling and workflows * **Task Reference**: See [Media Buy Tasks](/dist/docs/3.1.20/media-buy) and [Signals](/dist/docs/3.1.20/signals/overview) * **Protocol Comparison**: Compare with [MCP integration](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide) * **Examples**: Find complete workflow examples in Core Concepts **For status handling, async operations, and clarification patterns, see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) - this guide focuses on A2A transport specifics only.** # A2A Response Extraction Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction How to extract AdCP response data from A2A Task objects: status-based branching, last-DataPart authority, wrapper rejection, and client implementation requirements. This page defines the normative algorithm for extracting AdCP response data from A2A Task objects and TaskStatusUpdateEvents. For the canonical response structure that sellers must produce, see [A2A Response Format](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format). For error-specific extraction, see [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors). ## AdCP Conventions on Top of A2A The rules on this page layer AdCP-specific semantics onto A2A. Non-AdCP A2A agents do not enforce them and should not be expected to produce conforming output. * **Single-artifact invariant.** AdCP tasks produce one artifact containing all output parts. Clients read from `artifacts[0]`. If a seller needs multiple distinct deliverables, they should be modeled as separate tasks — not multiple artifacts. * **Last-DataPart authority.** When multiple DataParts appear in one artifact (typical during streaming), the last one is authoritative. Earlier DataParts are superseded progress snapshots. * **First-DataPart for interim.** When multiple DataParts appear in `status.message.parts`, the first is used — interim updates are single-event snapshots, not accumulated. * **Wrapper rejection.** A DataPart whose `.data` is `{ response: {...} }` (single key named `response`) is treated as a framework-wrapper bug, not a valid payload. ## Wire-Format Compatibility This algorithm handles both **A2A 1.0** and **v0.3** responses. Extraction must not assume one wire format — the same AdCP client may talk to both during the v0.3 compatibility period. **State values.** The `status.state` field arrives as either the ProtoJSON form (`"TASK_STATE_COMPLETED"`, `"TASK_STATE_WORKING"`, …) in 1.0 or the lowercase form (`"completed"`, `"working"`, …) in v0.3. Clients normalize before comparison. **Part shape.** A 1.0 DataPart has a non-null `data` field and no `kind`. A v0.3 DataPart has `kind: "data"` and a `data` field. Both satisfy "the `data` field is a non-null object." The same holds for TextParts (`text` field present) and FileParts (`url`/`raw` in 1.0, or `kind: "file"` in v0.3). Per A2A 1.0 §4.1.6, a Part is a strict `oneof` — exactly one of `text`, `raw`, `url`, or `data` is set. Clients receiving a Part with multiple content fields SHOULD treat it as malformed. **Streaming envelope.** A2A 1.0 wraps streaming responses and push-notification payloads in a `StreamResponse` oneof with exactly one of the keys `task`, `message`, `statusUpdate`, or `artifactUpdate` (A2A 1.0 §3.2.3, §4.3.3). Non-streaming responses (e.g., `tasks/get`, or v0.3 over HTTP) deliver the bare object. Extraction unwraps a single-key envelope before applying the algorithm below. ## Status-Based Extraction The extraction location depends on the task's status. State names in this table are shown in normalized lowercase form — match against the normalized state, not the raw wire value. | Status | Type | Data Location | DataPart Selection | | ---------------- | ------------- | ------------------------------------------------------------ | --------------------------------------------------------------------- | | `completed` | Final | `.artifacts[0].parts[]` (fallback: `status.message.parts[]`) | Last DataPart | | `failed` | Final | `.artifacts[0].parts[]` (fallback: `status.message.parts[]`) | Last DataPart | | `canceled` | Final | `.artifacts[0].parts[]` | Last DataPart (typically none) | | `rejected` | Final (1.0) | `.artifacts[0].parts[]` | Last DataPart (carries `adcp_error` for policy/validation rejections) | | `working` | Interim | `status.message.parts[]` | First DataPart | | `submitted` | Interim | `status.message.parts[]` | First DataPart | | `input-required` | Interim | `status.message.parts[]` | First DataPart | | `auth-required` | Interim (1.0) | `status.message.parts[]` | First DataPart (carries auth challenge data — scheme, URL, scopes) | Final states fall back to `status.message.parts[]` when `.artifacts` is absent or empty — this covers servers that put a final payload in the status message rather than a separate artifact. Canceled tasks rarely carry data — extraction returns null when no DataPart is present, which is the expected case. Rejected tasks are expected to carry an `adcp_error` DataPart describing why the request was rejected (tier/policy/validation). ## Extraction Algorithm Clients MUST extract AdCP data from A2A responses using these steps: 0. **Unwrap stream envelopes.** If the input is an object with exactly one top-level key named `task`, `message`, `statusUpdate`, or `artifactUpdate` and that key's value is a non-null, non-array object, replace the input with that value (A2A 1.0 `StreamResponse` oneof). Bare `Task` / `TaskStatusUpdateEvent` objects — non-streaming responses or v0.3 — pass through unchanged. An `artifactUpdate` carries no task status; once unwrapped its `status.state` is absent and step 1 returns null. Unwrap **exactly once**. Clients MUST NOT recurse. If the unwrapped inner object itself has the single-key envelope shape (`{ task: { task: {...} } }` or any combination), treat as malformed and return null — this is a nested-envelope smuggling attempt. An envelope whose inner value's top-level keys include any of `task` / `message` / `statusUpdate` / `artifactUpdate` MUST be rejected. Bare `{ message }` envelopes (out-of-band agent messages) MUST be ignored by task-oriented extractors — step 1 returns null when the unwrapped object has no `status.state`. Webhook/SSE handlers MUST NOT return a `200 OK` acknowledgment for unrecognized `{ message }` envelopes; return `400 Bad Request` or silently discard at the transport layer to avoid acting as a presence oracle for attackers probing endpoints. 1. **Read `status.state`.** If absent, return null. Normalize to lowercase form (`TASK_STATE_COMPLETED` → `completed`) before comparing. After normalization, the state MUST match one of the known final/interim tokens by **exact ASCII string equality**. Clients MUST NOT collapse repeated separators, trim whitespace, or apply Unicode case-folding beyond ASCII lowercase. Any other value — including novel `TASK_STATE_*` inputs the client does not recognize — is "unknown" and extraction returns null (step 4). 2. **Final states** (`completed`, `failed`, `canceled`, `rejected`): a. Look in `artifacts[0].parts[]` for DataParts (a Part whose `data` field is a non-null object — regardless of whether `kind` is present). b. Use the **last** DataPart as authoritative (see [Last-DataPart Authority](#last-datapart-authority)). c. **Reject wrappers**: If the DataPart's `.data` has a single key `response` containing an object, this is a framework wrapper bug. Throw or log an error. d. Return `.data`. e. **Fallback**: If no artifacts or no DataPart in artifacts, check `status.message.parts[]` using step 3. 3. **Interim states** (`working`, `submitted`, `input-required`, `auth-required`): a. Look in `status.message.parts[]` for DataParts. b. Use the **first** DataPart. c. Return `.data`, or null if no DataPart found. 4. **Unknown states**: Return null. Forward-compatible clients SHOULD NOT throw on unrecognized status values. State normalization: strip a `TASK_STATE_` prefix, lowercase, replace underscores with hyphens. That maps both A2A 1.0 (`"TASK_STATE_INPUT_REQUIRED"`) and v0.3 (`"input-required"`) onto the same value. DataPart detection uses field presence — a 1.0 Part `{ "data": {...} }` and a v0.3 Part `{ "kind": "data", "data": {...} }` both satisfy the "non-null object `data` field" test. ```javascript A2A Client theme={null} function normalizeState(state) { if (typeof state !== 'string') return null; return state.replace(/^TASK_STATE_/, '').toLowerCase().replace(/_/g, '-'); } function isDataPart(p) { return p != null && p.data != null && typeof p.data === 'object' && !Array.isArray(p.data); } // A2A 1.0 StreamResponse oneof: { task } | { message } | { statusUpdate } | { artifactUpdate } function unwrapStreamEnvelope(input) { if (input == null || typeof input !== 'object' || Array.isArray(input)) return input; const keys = Object.keys(input); if (keys.length !== 1) return input; const envelopeKeys = ['task', 'message', 'statusUpdate', 'artifactUpdate']; if (envelopeKeys.includes(keys[0]) && typeof input[keys[0]] === 'object' && input[keys[0]] !== null) { return input[keys[0]]; } return input; } function extractAdcpResponseFromA2A(input) { const task = unwrapStreamEnvelope(input); const state = normalizeState(task?.status?.state); if (!state) return null; const FINAL = ['completed', 'failed', 'canceled', 'rejected']; const INTERIM = ['working', 'submitted', 'input-required', 'auth-required']; if (FINAL.includes(state)) { // Final: last DataPart from artifacts[0] const artifact = task.artifacts?.[0]; if (artifact?.parts) { const dataParts = artifact.parts.filter(isDataPart); if (dataParts.length > 0) { const last = dataParts[dataParts.length - 1]; // Reject framework wrappers const keys = Object.keys(last.data); if (keys.length === 1 && keys[0] === 'response' && typeof last.data.response === 'object') { throw new Error( 'Invalid response format: DataPart contains wrapper object {response: {...}}. ' + 'This is a server-side bug.' ); } return last.data; } } // Fallback to status.message.parts return extractFromMessage(task); } if (INTERIM.includes(state)) { return extractFromMessage(task); } return null; // Unknown state } function extractFromMessage(task) { const parts = task.status?.message?.parts; if (!Array.isArray(parts)) return null; const dataPart = parts.find(isDataPart); return dataPart?.data ?? null; } ``` ## Last-DataPart Authority For final states, the **last** DataPart in `artifacts[0].parts[]` is authoritative. During streaming, intermediate DataParts may contain stale progress data that gets superseded by the final result: ```json theme={null} { "status": {"state": "TASK_STATE_COMPLETED"}, "artifacts": [{ "parts": [ {"text": "Found products"}, {"data": {"progress": 25}}, {"data": {"products": [...], "total": 12}} ] }] } ``` The extracted data is `{"products": [...], "total": 12}`, not `{"progress": 25}`. For interim states, the **first** DataPart is used because interim updates are single-event snapshots, not accumulated. ## Wrapper Rejection Clients MUST reject DataParts where `.data` is wrapped in a framework-specific object: ```json theme={null} // REJECTED: wrapper detected {"data": {"response": {"products": [...]}}} // ACCEPTED: direct payload {"data": {"products": [...]}} ``` The detection rule: if `.data` has exactly one key named `response` whose value is an object, it is a wrapper. This is a server-side bug — clients should throw or log an error, not silently unwrap. Wrapper detection applies to **final states only** (artifacts). Interim status messages are lightweight progress snapshots — wrapper detection is not required for `status.message.parts`. **Exception**: A `.data` object that has `response` alongside other keys is NOT a wrapper: ```json theme={null} // NOT a wrapper — response is one of several keys {"data": {"response": {...}, "status": "completed", "errors": []}} ``` ## Relationship to Error Extraction This algorithm extracts *any* AdCP data from A2A responses, including error payloads (`adcp_error`). Error-specific extraction ([Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors)) is a specialization that checks for the `adcp_error` key in the extracted data. The transport-errors spec provides its own `extractAdcpErrorFromA2A` function that scans all artifacts for `adcp_error`. That function is optimized for error detection (scanning all parts for the error key). This function is the general-purpose extractor (last DataPart from first artifact). For failed tasks with a single `adcp_error` DataPart, both produce equivalent results. Typical client flow: ```javascript theme={null} function handleA2aResponse(task) { const data = extractAdcpResponseFromA2A(task); // Check if the extracted data is an error if (data?.adcp_error) { return handleError(data.adcp_error); } return handleSuccess(data); } ``` ## Security Considerations ### Seller-Controlled Data All data in `.artifacts[].parts[].data` and `status.message.parts[].data` is seller-controlled. The prompt injection, data boundary, and size limit requirements from [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors#security-considerations) apply. ### Prototype Pollution Clients MUST NOT merge extracted DataPart payloads into application state via `Object.assign` or spread without filtering keys. Validate against the expected task response schema before merging. ### FilePart URI Validation A2A responses may include FileParts. In 1.0 these are Parts carrying a `url` field (file by reference) or a `raw` field (base64 bytes); in v0.3 they carry `kind: "file"` with a `uri` field. Clients MUST validate that the URL uses the `https` scheme, contains no userinfo component, and matches an expected domain allowlist. Reject `javascript:`, `data:`, `file:`, and `http:` URIs. For `raw` parts, enforce a max decoded size before accepting. ### Auth Challenge URL Validation When handling `auth-required`, the seller sends an auth challenge in `status.message.parts` — typically a DataPart with fields like `auth_scheme`, `challenge_url`, and `scopes`. A seller-controlled URL that the client opens or fetches is an OAuth-phishing and SSRF vector. Before initiating any user-facing or programmatic auth flow, clients MUST validate `challenge_url`: * Scheme MUST be `https`. Reject `http:`, `javascript:`, `data:`, `file:`. * URL MUST NOT contain a userinfo component (`user:pass@host` form). * Host MUST match the authenticated seller's registered auth origin for this agent card. Clients SHOULD maintain a per-agent allowlist seeded from the Agent Card's `supportedInterfaces[].url` origin or a declared `authOrigin` extension field — not derived from the task payload. * Any `redirect_uri`, `return_url`, or similar query parameter MUST be dropped or overwritten by the client before navigation. Never forward a seller-supplied redirect. * `scopes` MUST be treated as a request, not a grant. Show scopes to the user and obtain fresh consent for each challenge. Response-size and timeout bounds apply if the client fetches the challenge URL server-side (e.g., 256 KB response cap, 10 second timeout, redirect limit of 3). ### Seller-Controlled String Hygiene All `adcp_error.message`, `adcp_error.details.*`, and status TextPart content is seller-controlled. Clients rendering these in UI MUST escape for the target context (HTML, Slack, CLI). Clients logging them MUST strip CRLF to prevent log-injection. This applies to all states carrying `adcp_error` (`failed`, `rejected`, system-initiated `canceled`) and to free-text `status.message`. ### Size Limits Clients SHOULD enforce a maximum DataPart size (e.g., 1MB) before schema validation. Unlike error payloads (capped at 4096 bytes), success payloads can be larger but still need bounds. ### Intermediary Injection The last-DataPart convention assumes the artifact is received intact from a single trusted sender. In multi-hop scenarios (buyer → orchestrator → seller), an intermediary could inject additional parts. Clients operating through intermediaries SHOULD validate that the artifact part count matches expectations. ## Client Library Requirements Client libraries that implement this spec MUST: 1. **Unwrap A2A 1.0 stream envelopes.** A single-key object with key `task`, `message`, `statusUpdate`, or `artifactUpdate` is a `StreamResponse` wrapper — unwrap to the inner object before applying the rest of the algorithm. Bare objects pass through unchanged. 2. **Accept both A2A 1.0 and v0.3 wire shapes.** Normalize `status.state` before comparison (strip `TASK_STATE_` prefix, lowercase, underscores to hyphens). Detect DataParts by field presence (`data` is a non-null object), not by `kind`. 3. **Branch on normalized state.** Final states (`completed`, `failed`, `canceled`, `rejected`) use artifacts; interim states (`working`, `submitted`, `input-required`, `auth-required`) use `status.message.parts`. 4. **Use last DataPart for final states.** Skip DataParts with null, non-object, or array `.data`. 5. **Use first DataPart for interim states.** 6. **Detect and reject wrappers.** Single-key `{response: {...}}` payloads are bugs. 7. **Fall back gracefully.** If artifacts are empty for a final state, check `status.message.parts`. 8. **Handle unknown states.** Return null, do not throw. ## Test Vectors Machine-readable test vectors are available at [`/static/test-vectors/a2a-response-extraction.json`](https://adcontextprotocol.org/test-vectors/a2a-response-extraction.json). Each vector contains: * `status`: the A2A task status * `path`: extraction path (`artifact`, `status_message`, or `none`) * `response`: the A2A Task or TaskStatusUpdateEvent * `expected_data`: the AdCP data that should be extracted (or `null`) * `expected_error_type`: if present, the extraction should throw (e.g., `wrapper_detected`) Client libraries SHOULD validate their extraction logic against these vectors. ## See Also * [A2A Response Format](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format) — canonical response structure for sellers * [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors) — error extraction from MCP and A2A * [MCP Response Extraction](/dist/docs/3.1.20/building/by-layer/L0/mcp-response-extraction) — equivalent spec for MCP * [A2A Guide](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide) — A2A transport integration # A2A Response Format Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format A2A response format for AdCP: required DataPart structure, artifact layout for completed and async tasks, and status-specific response patterns over Agent-to-Agent Protocol. This document defines the **canonical structure** for AdCP responses transmitted over the A2A protocol. ## A2A Wire Format Examples below use **A2A 1.0** wire format: Parts carry no `kind` discriminator (content type is implied by which field is set — `text`, `data`, `url`, or `raw`), roles are `ROLE_USER` / `ROLE_AGENT`, and task states are `TASK_STATE_*` (ProtoJSON canonical). See the [A2A Guide](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide#a2a-protocol-versions) for a side-by-side with v0.3. AdCP's top-level unified `status` field (returned by `@adcp/sdk`) continues to use the lowercase shorthand (`"completed"`, `"failed"`, `"working"`, `"input-required"`, `"submitted"`). That is an AdCP abstraction over `status.state` — not an A2A wire value. For v0.3 servers, the same DataPart becomes `{ "kind": "data", "data": {...} }` and states become lowercase. Extraction clients accept both shapes during the compatibility period. ## Required Structure ### Final Responses (status: "completed") **AdCP responses over A2A MUST:** * Include at least one DataPart (a Part carrying a non-null `data` field) containing the task response payload * Use single artifact with multiple parts (not multiple artifacts) * Use the last DataPart as authoritative when multiple data parts exist * NOT wrap AdCP payloads in custom framework objects (no `{ response: {...} }` wrappers) **Recommended pattern:** ```json theme={null} { "status": "completed", "taskId": "task_123", "contextId": "ctx_456", "artifacts": [{ "name": "task_result", "parts": [ { "text": "Found 12 video products perfect for pet food campaigns" }, { "data": { "products": [...], "total": 12 } } ] }] } ``` * **TextPart** (Part with `text` field): Human-readable summary — **recommended** but optional * **DataPart** (Part with `data` field): Structured AdCP response payload — **required** * **FilePart** (Part with `url` or `raw` field): Optional file references (previews, reports) **Multiple artifacts:** Only for fundamentally distinct deliverables (e.g., creative asset + separate trafficking report). Rare in AdCP - prefer single artifact with multiple parts. ### Interim Responses (working, submitted, input-required, auth-required) Interim status updates are delivered as `TaskStatusUpdateEvent`, with optional progress/challenge data carried in `status.message.parts[]` (not in `artifacts`). Artifacts accumulate during the task lifecycle but are read as the final deliverable once the task reaches a terminal state. ```json theme={null} { "taskId": "task_123", "contextId": "ctx_456", "status": { "state": "TASK_STATE_WORKING", "timestamp": "2026-01-22T10:15:00.000Z", "message": { "role": "ROLE_AGENT", "parts": [ { "text": "Processing your request. Analyzing 50,000 inventory records..." }, { "data": { "percentage": 45, "current_step": "analyzing_inventory" } } ] } } } ``` When delivered over SSE or as a push notification, this event is wrapped in the A2A 1.0 `StreamResponse` oneof: `{ "statusUpdate": { … } }`. Non-streaming responses (e.g. `tasks/get`) deliver the bare object. Clients unwrap before reading `status.state` — see [A2A Response Extraction](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction#extraction-algorithm). **Interim response characteristics:** * **TextPart** is recommended for human-readable status * **DataPart** is optional but follows AdCP schemas when provided * Interim status schemas (`*-async-response-working.json`, `*-async-response-input-required.json`, etc.) are work-in-progress and may evolve * Implementors may choose to handle interim data more loosely given schema evolution **When final status is reached** (`completed`, `failed`, `canceled`, or `rejected`), the full AdCP task response is delivered on a `Task` object with the DataPart in `.artifacts[0].parts[]`. ### Framework Wrappers (NOT PERMITTED) **CRITICAL**: DataPart content MUST be the direct AdCP response payload, not wrapped in framework-specific objects. ```json theme={null} // ❌ WRONG - Wrapped in custom object { "data": { "response": { // ← Framework wrapper "products": [...] } } } // ✅ CORRECT - Direct AdCP payload { "data": { "products": [...] // ← Direct schema-compliant response } } ``` **Why this matters:** * Breaks schema validation (clients expect `products` at root, not `response.products`) * Adds unnecessary nesting layer * Violates protocol-agnostic design (wrapper is framework-specific) * Complicates client extraction code **If your implementation adds wrappers**, this is a bug that should be fixed in the framework layer, not worked around in client code. ## Canonical Client Behavior This section defines EXACTLY how clients MUST extract AdCP responses from A2A protocol responses. ### Quick Reference | Status | Webhook Type | Data Location | Schema Required? | Returns | | --------------------- | ----------------------- | --------------------------------------------------- | ------------------------- | ------------------------------------ | | `working` | `TaskStatusUpdateEvent` | `status.message.parts[]` | ✅ Yes (if present) | `{ status, taskId, message, data? }` | | `submitted` | `TaskStatusUpdateEvent` | `status.message.parts[]` | ✅ Yes (if present) | `{ status, taskId, message, data? }` | | `input-required` | `TaskStatusUpdateEvent` | `status.message.parts[]` | ✅ Yes (if present) | `{ status, taskId, message, data? }` | | `auth-required` (1.0) | `TaskStatusUpdateEvent` | `status.message.parts[]` | ✅ Yes (auth challenge) | `{ status, taskId, message, data }` | | `completed` | `Task` | `.artifacts[]` (fallback: `status.message.parts[]`) | ✅ Required | `{ status, taskId, message, data }` | | `failed` | `Task` | `.artifacts[]` (fallback: `status.message.parts[]`) | ✅ Required | `{ status, taskId, message, data }` | | `rejected` (1.0) | `Task` | `.artifacts[]` | ✅ Required (`adcp_error`) | `{ status, taskId, message, data }` | **Key Insights**: * **Final statuses** use `Task` object with data in `.artifacts`. If a server has no structured payload (e.g., JSON-RPC parse error, pre-task auth failure), it may place only a text message in `status.message.parts` — clients fall back to that location. * **Interim statuses** use `TaskStatusUpdateEvent` with optional data in `status.message.parts[]`. * **Stream/webhook delivery** wraps the payload in the A2A 1.0 `StreamResponse` oneof (`{ task }`, `{ statusUpdate }`, `{ artifactUpdate }`, `{ message }`). Clients unwrap before reading fields. * All statuses use AdCP schemas when data is present. * Interim status schemas are work-in-progress and may evolve. ### Rule 1: Status-Based Handling Clients MUST branch on the normalized status to determine the correct data extraction location. The `status` referenced here is AdCP's unified lowercase value (e.g. `"completed"`); the raw A2A wire value at `status.state` is `TASK_STATE_COMPLETED` in 1.0 or `completed` in v0.3. Normalize before comparing — see [A2A Response Extraction](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction#extraction-algorithm). ```javascript theme={null} const INTERIM = ['working', 'submitted', 'input-required', 'auth-required']; const FINAL = ['completed', 'failed', 'canceled', 'rejected']; function handleA2aResponse(response) { const status = response.status; // AdCP unified status // INTERIM STATUSES - Extract from status.message.parts (TaskStatusUpdateEvent) if (INTERIM.includes(status)) { return { status: status, taskId: response.taskId, contextId: response.contextId, message: extractTextPartFromMessage(response), data: extractDataPartFromMessage(response), // Optional AdCP data (required for auth-required) }; } // FINAL STATUSES - Extract from .artifacts (Task object), fallback to status.message if (FINAL.includes(status)) { return { status: status, taskId: response.taskId, contextId: response.contextId, message: extractTextPartFromArtifacts(response) ?? extractTextPartFromMessage(response), data: extractDataPartFromArtifacts(response) ?? extractDataPartFromMessage(response), }; } // Forward-compatible: unknown future states return null, do not throw return { status, taskId: response.taskId, contextId: response.contextId, message: null, data: null }; } ``` **Critical**: * **Interim statuses** use `TaskStatusUpdateEvent` → extract from `status.message.parts[]` * **Final statuses** use `Task` object → extract from `.artifacts[0].parts[]`, falling back to `status.message.parts[]` if artifacts are empty ### Rule 2: Data Extraction Helpers Extract data from the appropriate location based on webhook type: ```javascript theme={null} // Part-type detectors: field presence (A2A 1.0) with kind fallback (v0.3) const isDataPart = (p) => p.data != null && typeof p.data === 'object' && !Array.isArray(p.data); const isTextPart = (p) => typeof p.text === 'string'; // For FINAL statuses (Task object) - extract from .artifacts, return null if absent function extractDataPartFromArtifacts(response) { const dataParts = response.artifacts?.[0]?.parts?.filter(isDataPart) || []; if (dataParts.length === 0) return null; // caller falls back to status.message.parts // Use LAST data part as authoritative const lastDataPart = dataParts[dataParts.length - 1]; const payload = lastDataPart.data; // CRITICAL: Payload MUST be direct AdCP response, not a framework wrapper. // A wrapper is a single-key object { response: {...} } — reject it. // Objects that have 'response' alongside other keys are NOT wrappers. const keys = Object.keys(payload); if (keys.length === 1 && keys[0] === 'response' && typeof payload.response === 'object') { throw new Error( 'Invalid response format: DataPart contains wrapper object. ' + 'Expected direct AdCP payload (e.g., {products: [...]}) ' + 'but received {response: {products: [...]}}. ' + 'This is a server-side bug that must be fixed.' ); } return payload; } function extractTextPartFromArtifacts(response) { const textPart = response.artifacts?.[0]?.parts?.find(isTextPart); return textPart?.text || null; } // For INTERIM statuses (TaskStatusUpdateEvent) - extract from status.message.parts function extractDataPartFromMessage(response) { const dataPart = response.status?.message?.parts?.find(isDataPart); return dataPart?.data || null; } function extractTextPartFromMessage(response) { const textPart = response.status?.message?.parts?.find(isTextPart); return textPart?.text || null; } ``` These detectors work for both wire formats: a 1.0 DataPart has `data` set (no `kind`), a v0.3 DataPart has `kind: "data"` and `data` set — both satisfy `p.data != null`. ### Rule 3: Schema Validation All AdCP responses use schemas, but validation approach varies by status: ```javascript theme={null} function validateResponse(response, taskName) { const status = response.status; let data, schemaName; // Extract data and determine schema based on status if (INTERIM.includes(status)) { // INTERIM: Optional data from status.message.parts data = extractDataPartFromMessage(response); if (data) { // Interim status has its own schema (work-in-progress) schemaName = `${taskName}-async-response-${status}.json`; // Optional: Implementors may skip interim validation as schemas evolve if (STRICT_VALIDATION_MODE) { validateAgainstSchema(data, loadSchema(schemaName)); } } } else if (FINAL.includes(status)) { // FINAL: Required data from .artifacts (fallback to status.message.parts) data = extractDataPartFromArtifacts(response) ?? extractDataPartFromMessage(response); schemaName = `${taskName}-response.json`; // Required: Final responses must validate if (!validateAgainstSchema(data, loadSchema(schemaName))) { throw new Error( `Response payload does not match ${taskName} schema. ` + `Ensure DataPart contains direct AdCP response structure.` ); } } } ``` **Schema Evolution Note**: Interim status schemas (`*-async-response-working.json`, etc.) are work-in-progress. Implementors may choose to handle these more loosely while schemas stabilize. ### Complete Example Putting it all together with proper handling of both Task and TaskStatusUpdateEvent payloads: ```javascript theme={null} async function executeTask(taskName, params) { const response = await a2aClient.send({ task: taskName, params: params }); // 1. Status-based handling (extracts from correct location) const result = handleA2aResponse(response); // 2. Schema validation validateResponse(response, taskName); return result; } // Usage const result = await executeTask('get_products', { brief: 'CTV inventory in California' }); // Handle different response types if (result.status === 'working') { // TaskStatusUpdateEvent - data from status.message.parts console.log('Processing:', result.message); if (result.data) { console.log('Progress:', result.data.percentage + '%'); } } else if (result.status === 'input-required') { // TaskStatusUpdateEvent - data from status.message.parts console.log('Input needed:', result.message); console.log('Reason:', result.data?.reason); } else if (result.status === 'completed') { // Task object - data from .artifacts console.log('Success:', result.message); console.log('Products:', result.data.products); // Full AdCP response } ``` ## Last Data Part Authority Pattern
Why this pattern? During streaming operations, intermediate responses may include old progress data: ```json theme={null} // Working status with progress { "status": "working", "artifacts": [{ "parts": [ {"text": "Searching inventory..."}, {"data": {"progress": 25}} ] }] } // Completed - last data part is authoritative { "status": "completed", "artifacts": [{ "parts": [ {"text": "Found 12 products"}, {"data": {"progress": 25}}, // Old {"data": {"products": [...], "total": 12}} // ← Authoritative ] }] } ``` **Note:** This is an AdCP-specific convention, not required by A2A protocol. Document this in your Agent Card when serving non-AdCP clients.
## Test Cases ### ✅ Correct Behavior ```javascript theme={null} // Test 1: Working status (TaskStatusUpdateEvent) - extract from status.message.parts const workingResponse = { taskId: 'task_123', contextId: 'ctx_456', status: { state: 'TASK_STATE_WORKING', message: { role: 'ROLE_AGENT', parts: [ { text: 'Processing inventory...' }, { data: { percentage: 50, current_step: 'analyzing' } } ] } } }; const result1 = handleA2aResponse(workingResponse); assert(result1.data.percentage === 50, 'Should extract data from status.message.parts'); assert(result1.message === 'Processing inventory...', 'Should extract text from status.message.parts'); // Test 2: Completed status (Task) - extract from .artifacts const completedResponse = { taskId: 'task_123', contextId: 'ctx_456', status: { state: 'TASK_STATE_COMPLETED', timestamp: '2026-01-22T10:30:00.000Z' }, artifacts: [{ parts: [ { text: 'Found 3 products' }, { data: { products: [...], total: 3 } } ] }] }; const result2 = handleA2aResponse(completedResponse); assert(result2.data !== undefined, 'Completed status must have data'); assert(Array.isArray(result2.data.products), 'Data should be direct AdCP payload'); // Test 3: Wrapper detection (should reject) const wrappedResponse = { taskId: 'task_123', status: { state: 'TASK_STATE_COMPLETED' }, artifacts: [{ parts: [ { data: { response: { products: [...] } } } ] }] }; assert.throws(() => { extractDataPartFromArtifacts(wrappedResponse); }, /Invalid response format.*wrapper/); ``` ### ❌ Incorrect Behavior (Common Mistakes) ```javascript theme={null} // WRONG: Extracting from wrong location for interim status function badHandleWorking(response) { // ❌ TaskStatusUpdateEvent doesn't have .artifacts - data is in status.message.parts const data = response.artifacts?.[0]?.parts?.find(isDataPart)?.data; return { status: 'working', data }; // Will be null/undefined! } // WRONG: Extracting from wrong location for completed status function badHandleCompleted(response) { // ❌ Task object has data in .artifacts, not in status.message.parts const data = response.status?.message?.parts?.find(p => p.data)?.data; return { status: 'completed', data }; // Will be null/undefined! } // WRONG: Not checking for wrappers function badExtraction(response) { const payload = response.artifacts[0].parts[0].data; // ❌ Returns { response: { products: [...] } } instead of { products: [...] } return payload; // Client receives wrong structure! } // WRONG: Accessing nested response field function badClientUsage(result) { // ❌ Client code shouldn't need to do this const products = result.data.response.products; // Should be: result.data.products } ``` ## Error Handling ### Task-Level Errors (Partial Failures) Task executed but couldn't complete fully. Use `errors` array in DataPart with `status: "completed"`: ```json theme={null} { "status": "completed", "taskId": "task_123", "artifacts": [{ "parts": [ { "text": "Signal discovery completed with partial results" }, { "data": { "signals": [...], "errors": [{ "code": "NO_DATA_IN_REGION", "message": "No signal data available for Australia", "field": "countries[1]", "details": { "requested_country": "AU", "available_countries": ["US", "CA", "GB"] } }] } } ] }] } ``` **When to use errors array:** * Platform authorization issues (`PLATFORM_UNAUTHORIZED`) * Partial data availability * Validation issues in subset of data ### Protocol-Level Errors (Fatal) Task couldn't execute. Use `status: "failed"` with message: ```json theme={null} { "taskId": "task_456", "status": "failed", "message": { "role": "ROLE_AGENT", "parts": [{ "text": "Authentication failed: Invalid or expired API token" }] } } ``` **When to use status: failed:** * Authentication failures (invalid credentials, expired tokens) * Invalid request parameters (malformed JSON, missing required fields) * Resource not found (unknown taskId, expired context) * System errors (database unavailable, internal service failure) ### Where the Error Lives: Decision Rule Placement is chosen by what the server has and which state it's in: | Situation | State | Location | Payload | | --------------------------------------------------- | ----------- | --------------------------------- | ------------------------------------- | | Task executed, subset failed | `completed` | `artifacts[0].parts[]` DataPart | `{ , errors: [...] }` | | Task failed with structured error | `failed` | `artifacts[0].parts[]` DataPart | `{ adcp_error: {...} }` | | Task rejected by policy/validation (1.0) | `rejected` | `artifacts[0].parts[]` DataPart | `{ adcp_error: {...} }` | | System-initiated cancel (timeout, upstream failure) | `canceled` | `artifacts[0].parts[]` DataPart | `{ adcp_error: {...} }` | | User-initiated cancel (`tasks/cancel`) | `canceled` | `status.message.parts[]` TextPart | Human-readable text only | | Protocol/transport failure, no artifact produced | `failed` | `status.message.parts[]` TextPart | Human-readable text only | **Rule of thumb:** if the server has structured error data, put it in artifacts as a DataPart. `status.message` is the free-text fallback for cases where no task artifact was ever produced (JSON-RPC parse errors, auth handshake failures, malformed requests, or a user-initiated cancel with no further detail). A2A 1.0 §3.7 reinforces this: *"Messages SHOULD NOT be used to deliver task outputs. Results SHOULD be returned using Artifacts."* **`rejected` vs `failed`.** Use `rejected` when the server refuses to attempt the task (policy/tier/validation check, before any work is started). Use `failed` when work started and encountered a fatal error. Both carry `adcp_error` in the artifact — the state distinguishes *when* the failure occurred, which drives different retry and UX behavior on the caller side. **Cancel origin is client-reconciled, not seller-attributed.** `status.state: "canceled"` (or `TASK_STATE_CANCELED`) does not tell the caller whether the cancel was user-initiated or system-initiated — a seller could place `adcp_error` in artifacts for what was actually a user-initiated cancel to mislead the buyer's bookkeeping or retry logic. Clients MUST reconcile cancel origin locally: if the caller has an outstanding `tasks/cancel` request for this `taskId`, treat the cancel as user-initiated regardless of payload and ignore any `adcp_error` the seller attached. Clients MUST NOT retry a user-initiated cancel on the basis of a seller-sent `adcp_error.recovery` hint. ## Status Mapping AdCP uses A2A's TaskState enum directly: | A2A Status | Payload Type | Data Location | AdCP Usage | | --------------------- | ----------------------- | ------------------------------------------------ | -------------------------------------------------------------------- | | `completed` | `Task` | `.artifacts` | Task finished successfully, data in DataPart, optional errors array | | `failed` | `Task` | `.artifacts` (or `status.message` for text-only) | Fatal error preventing completion, `adcp_error` when structured | | `rejected` (1.0) | `Task` | `.artifacts` | Policy/validation rejection, `adcp_error` with rejection reason | | `canceled` | `Task` | `.artifacts` (typically none) | Task canceled by user or system | | `input-required` | `TaskStatusUpdateEvent` | `status.message.parts` | Need user input/approval, data + text explaining what's needed | | `auth-required` (1.0) | `TaskStatusUpdateEvent` | `status.message.parts` | Authentication challenge during task execution (scheme, URL, scopes) | | `working` | `TaskStatusUpdateEvent` | `status.message.parts` | Processing (\< 120s), optional progress data | | `submitted` | `TaskStatusUpdateEvent` | `status.message.parts` | Long-running (hours/days), minimal data, use webhooks/polling | ## Webhook Payloads Async operations (`status: "submitted"`) deliver the same artifact structure in webhooks: ```json theme={null} POST /webhook-endpoint { "taskId": "task_123", "status": "completed", "timestamp": "2026-01-22T10:30:00.000Z", "artifacts": [{ "parts": [ {"text": "Media buy approved and live"}, {"data": { "media_buy_id": "mb_456", "packages": [...], "creative_deadline": "2026-01-30T23:59:59.000Z" }} ] }] } ``` Extract AdCP data using the same last-DataPart pattern. **For webhook authentication, retry patterns, and security**, see [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks). ## File Parts in Responses Creative operations MAY include file references: ```json theme={null} { "status": "completed", "artifacts": [{ "parts": [ {"text": "Creative uploaded and preview generated"}, {"data": { "creative_id": "cr_789", "format_id": { "agent_url": "https://creatives.adcontextprotocol.org", "id": "video_standard_30s" }, "status": "ready" }}, {"url": "https://cdn.example.com/cr_789/preview.mp4", "filename": "preview.mp4", "mediaType": "video/mp4"} ] }] } ``` **File part usage:** Preview URLs, generated assets, trafficking reports. **Not for** raw AdCP response data (always use DataPart). ## Retry and Idempotency ### TaskId-Based Deduplication A2A's `taskId` enables retry detection. Agents SHOULD: * Return cached response if `taskId` matches a completed operation (within TTL window) * Reject duplicate `taskId` submission if operation is still in progress ```json theme={null} // Duplicate taskId during active operation { "taskId": "task_123", "status": "failed", "message": { "role": "ROLE_AGENT", "parts": [{ "text": "Task 'task_123' is already in progress. Use tasks/get to check status." }] } } ``` ## Examples
Product Discovery Success ```json theme={null} { "status": "completed", "taskId": "task_001", "contextId": "ctx_abc", "artifacts": [{ "name": "product_catalog", "parts": [ { "text": "Found 8 CTV products targeting sports fans under $50 CPM" }, { "data": { "products": [ { "product_id": "ctv_sports_premium", "name": "Premium Sports CTV" } // ... 7 more products ] } } ] }] } ```
Media Buy with Approval Required ```json theme={null} { "status": "input-required", "taskId": "task_002", "contextId": "ctx_def", "artifacts": [{ "name": "approval_request", "parts": [ { "text": "Media buy exceeds auto-approval limit ($100K). Please approve to proceed." }, { "data": { "media_buy_id": "mb_pending_456", "packages": [ { "package_id": "pkg_pending_001" }, { "package_id": "pkg_pending_002" } ], "total_budget": 150000, "currency": "USD", "creative_deadline": "2026-02-01T23:59:59.000Z" } } ] }] } ```
Signal Discovery with Partial Failure ```json theme={null} { "status": "completed", "taskId": "task_003", "contextId": "ctx_ghi", "artifacts": [{ "name": "signal_results", "parts": [ { "text": "Found 3 signals for luxury automotive. Note: No data available for Australia region." }, { "data": { "signals": [ { "signal_id": "lux_auto_us", "name": "Luxury Auto Intenders - US", "reach": 2500000 } ], "total": 3, "errors": [{ "code": "NO_DATA_IN_REGION", "message": "No signal data available for requested region: Australia", "field": "countries[1]", "details": { "requested_country": "AU", "available_countries": ["US", "CA", "GB"] } }] } } ] }] } ```
Platform Authorization Issue (Task-Level Error) Platform/operation-specific authorization failures are task-level errors: ```json theme={null} { "status": "completed", "taskId": "task_004", "contextId": "ctx_jkl", "artifacts": [{ "name": "signal_activation_result", "parts": [ { "text": "Signal activation failed: Account not authorized for Peer39 data on PubMatic" }, { "data": { "errors": [{ "code": "PLATFORM_UNAUTHORIZED", "message": "Account 'brand-456-pm' not authorized for Peer39 data on PubMatic. Contact your PubMatic account manager to enable access.", "details": { "platform": "pubmatic", "account_id": "brand-456-pm", "data_provider": "peer39" } }] } } ] }] } ```
Protocol-Level Failure (Fatal) Authentication failures are protocol-level errors: ```json theme={null} { "taskId": "task_005", "status": "failed", "message": { "role": "ROLE_AGENT", "parts": [{ "text": "Authentication failed: Invalid or expired API token. Please refresh your credentials and retry." }] } } ```
## Implementation Checklist When implementing A2A responses for AdCP: **Final Responses (status: "completed" or "failed") - Use `Task` object:** * [ ] **Always include status field** from TaskState enum * [ ] **Use `.artifacts` array with at least one DataPart** containing AdCP response payload * [ ] **Include TextPart** with human-readable message (recommended for UX) * [ ] **Use single artifact with multiple parts** (not multiple artifacts) * [ ] **Use last DataPart as authoritative** if multiple exist * [ ] **Never nest AdCP data in custom wrappers** (no `{ response: {...} }` objects) * [ ] **DataPart content MUST match AdCP schemas** (validate against `[task]-response.json`) **Interim Responses (status: "working", "submitted", "input-required") - Use `TaskStatusUpdateEvent`:** * [ ] **Use `status.message.parts[]` for optional data** (not `.artifacts`) * [ ] **TextPart** is recommended for human-readable status updates * [ ] **DataPart** is optional but follows AdCP schemas when provided (`[task]-async-response-[status].json`) * [ ] **Interim schemas are work-in-progress** - clients may handle more loosely * [ ] **Include progress indicators** when applicable (percentage, current\_step, ETA) **Error Handling:** * [ ] **Use `status: "failed"` for protocol errors only** (auth, invalid params, system errors) * [ ] **Use `errors` array for task failures** (platform auth, partial data) with `status: "completed"` **General:** * [ ] **Include taskId and contextId** for tracking * [ ] **Follow discriminated union patterns** for task responses (check schemas) * [ ] **Use correct payload type**: `Task` for final states, `TaskStatusUpdateEvent` for interim * [ ] **Support taskId-based deduplication** for retry detection ## See Also * [A2A Guide](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide) - Complete A2A integration guide * [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) - Status handling patterns * [Error Handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling) - Fatal vs non-fatal errors * [Protocol Comparison](/dist/docs/3.1.20/building/concepts/protocol-comparison) - MCP vs A2A differences # L0 — Wire & transport Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L0/index Wire-and-transport layer of the AdCP stack. JSON-over-HTTP framing, MCP message envelopes, A2A SSE streams, schema validation, language-native type generation. L0 takes protocol bytes off the wire and turns them into typed in-memory values, or serializes outbound requests against the published schemas. Symmetric on both sides — same primitives, mirrored direction. ## What an SDK at L0 must provide If you're picking an SDK or porting one to a new language, this is the L0 build target: * **Generated language-native types** from the published JSON schemas (one type per request/response pair, plus shared resource types). * **A schema validator** wired against the bundled schemas — so adopters can validate inbound and outbound payloads without hand-rolling the schema-loading dance. * **Transport adapters** for at least one of \{MCP, A2A}; ideally both. These typically wrap upstream protocol SDKs rather than reimplementing them. * **A schema-bundle accessor** that finds the right schema files for the active AdCP version without forcing the adopter to hardcode paths. For the cumulative cross-layer story (what L0+L1+L2+L3 buys you), see the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#l0--wire--transport). ## Pages in this layer * **[Schemas](/dist/docs/3.1.20/building/by-layer/L0/schemas)** — schema bundle, supply-chain verification, type generation, version pinning. * **[MCP guide](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide)** — `tools/call` envelope, JSON-RPC 2.0, transport adapter shape. * **[A2A guide](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide)** — SSE event streams, task framing, artifact extraction. * **[A2A response format](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format)** — A2A wire-format reference. * **[MCP response extraction](/dist/docs/3.1.20/building/by-layer/L0/mcp-response-extraction)** — parsing `tools/call` responses to typed values. * **[A2A response extraction](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction)** — parsing A2A streams and artifacts. # MCP Guide Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L0/mcp-guide AdCP MCP integration guide: tool call patterns, context_id management, response parsing, and wire format for Model Context Protocol implementations. Transport-specific guide for integrating AdCP using the Model Context Protocol. For task handling, status management, and workflow patterns, see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle). ## Testing AdCP via MCP You can test AdCP tasks using the [CLI tools](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk#cli-tools) or by chatting with [Addie](https://agenticadvertising.org), the AgenticAdvertising.org assistant. ## Tool Call Patterns ### Basic Tool Invocation ```javascript theme={null} // Standard MCP tool call const response = await mcp.call('get_products', { brand: { domain: "premiumpetfoods.com" }, brief: "Video campaign for pet owners" }); // All responses include status field (AdCP 1.6.0+) console.log(response.status); // "completed" | "input-required" | "working" | etc. console.log(response.message); // Human-readable summary ``` ### Tool Call with Filters ```javascript theme={null} // Structured parameters const response = await mcp.call('get_products', { brand: { domain: "betnow.com" }, brief: "Sports betting app for March Madness", filters: { channels: ["ctv"], delivery_type: "guaranteed", max_cpm: 50 } }); ``` ### Tool Call with Application-Level Context ```javascript theme={null} // Pass opaque application-level context; agents must carry it back const response = await mcp.call('build_creative', { target_format_id: { agent_url: 'https://creative.agent', id: 'premium_bespoke_display' }, creative_manifest: { /* ... */ }, context: { ui: 'buyer_dashboard', session: '123' } }); // Response includes the same context at the top level console.log(response.context); // { ui: 'buyer_dashboard', session: '123' } ``` ## MCP Response Format **Normative:** AdCP MCP responses use a **flat structure** — envelope fields (`status`, `context_id`, `context`, `task_id`, `timestamp`, `replayed`, `adcp_error`, `governance_context`) and task-body fields appear as siblings at the root of the tool response. The `payload` object defined on [`core/protocol-envelope.json`](https://adcontextprotocol.org/schemas/3.1.20/core/protocol-envelope.json) is a documentary grouping construct, NOT a serialized wire key: body fields are NOT nested under a `payload:` key on MCP. This matches MCP's native `structuredContent` convention. ```json theme={null} { "status": "completed", // envelope: unified task status "message": "Found 5 products", // envelope: human-readable summary "context_id": "ctx-abc123", // envelope: session identifier (server-managed) "context": { "ui": "buyer_dashboard" }, // envelope: per-request opaque echo (caller-owned) "timestamp": "2026-05-19T14:25:30Z", // envelope: response generation time "products": [...], // body: task-specific data, sibling of envelope fields "errors": [...] // body: per-record / payload-level errors (warning severity allowed) } ``` **Producer rule.** MCP tool implementations MUST emit envelope fields and body fields as flat siblings at the root. Nesting body fields under a `payload:` key is non-conformant — receivers parse from the flat root, and a nested representation breaks every shipping SDK. **Receiver rule.** MCP tool consumers MUST parse envelope and body fields from the flat root of the tool response. Receivers MUST NOT require a nested `payload:` key; the schema's `payload` is documentation, not a wire requirement. When `status` is absent on the response (legacy or transport-native state carrier), receivers MUST default to `completed` for non-error responses and inspect `adcp_error` for error envelopes. **`context_id` vs `context` — semantically orthogonal.** * `context_id` is a **server-managed session identifier** for tracking related operations across multiple tool invocations. The server issues it; the caller MAY echo it on subsequent calls to thread a session. Distinct from MCP's transport-level session. * `context` is a **caller-supplied opaque echo object** ([`core/context.json`](https://adcontextprotocol.org/schemas/3.1.20/core/context.json)) — the agent preserves it byte-for-byte without parsing. Used for buyer-side correlation (UI session IDs, trace IDs, custom metadata). * Both MAY appear on the same response. They are NOT aliases. **Status handling**: see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for complete status handling patterns. **Status Handling**: See [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for complete status handling patterns. ## Available Tools All AdCP tasks are available as MCP tools: ### Protocol Tools ```javascript theme={null} await mcp.call('get_adcp_capabilities', {...}); // Discover agent capabilities (start here) ``` ### Media Buy Tools ```javascript theme={null} await mcp.call('get_products', {...}); // Discover inventory await mcp.call('list_creative_formats', {...}); // Get format specs await mcp.call('create_media_buy', {...}); // Create campaigns await mcp.call('update_media_buy', {...}); // Modify campaigns await mcp.call('sync_creatives', {...}); // Manage creative assets await mcp.call('get_media_buy_delivery', {...}); // Performance metrics await mcp.call('provide_performance_feedback', {...}); // Share outcomes ``` ### Signals Tools ```javascript theme={null} await mcp.call('get_signals', {...}); // Discover audience signals await mcp.call('activate_signal', {...}); // Deploy signals to platforms ``` **Task Parameters**: See individual task documentation in [Media Buy](/dist/docs/3.1.20/media-buy) and [Signals](/dist/docs/3.1.20/signals/overview) sections. ## MCP Tasks as a Transport Wrapper AdCP task lifecycle state is application-layer state. MCP Tasks can wrap a `tools/call` request so the MCP client, rather than the LLM, waits for the `CallToolResult`; they do not replace the AdCP `task_id`, status payloads, webhooks, or polling/reconciliation surfaces. A task-augmented MCP call can complete successfully after delivering an AdCP payload whose `status` is still `submitted`. From that point, the media-buy, creative, signals, or governance workflow remains open at the AdCP layer and should be observed with webhooks or AdCP polling. :::warning Client support is limited Most chat-based MCP clients (Claude Desktop, Cursor) do not yet support MCP Tasks. If your client doesn't support task-augmented tool calls, use standard `tools/call` plus **webhooks** or **AdCP polling** instead — these work with any MCP client. See [Async Operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations) and [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for transport-independent patterns. MCP Tasks are useful when you control the MCP client (e.g., building your own orchestrator with `@modelcontextprotocol/sdk`) and want protocol-level waiting for the initial `tools/call` result. They are optional transport plumbing, not the canonical AdCP task store. ::: ### SDK Implementation If you use the `@modelcontextprotocol/sdk` package, MCP Tasks support requires minimal code. Pass an `InMemoryTaskStore` (or your own `TaskStore` implementation) to the Server constructor — the SDK auto-registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, and `tasks/cancel`: ```typescript theme={null} import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { InMemoryTaskStore } from '@modelcontextprotocol/sdk/experimental/tasks'; const taskStore = new InMemoryTaskStore(); const server = new Server( { name: 'my-adcp-agent', version: '1.0.0' }, { capabilities: { tools: {}, tasks: { list: {}, cancel: {}, requests: { tools: { call: {} } }, }, }, taskStore, }, ); ``` In your `tools/call` handler, check for the `task` field and use the store: ```typescript theme={null} server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const taskField = request.params.task; const result = await executeMyTool(request.params); if (!taskField) return result; // Synchronous path // Task-augmented: extra.taskStore handles requestId, sessionId, // and sends notifications/tasks/status on completion const task = await extra.taskStore.createTask({ ttl: taskField.ttl }); await extra.taskStore.storeTaskResult( task.taskId, result.isError ? 'failed' : 'completed', result, ); return { task: await extra.taskStore.getTask(task.taskId) }; }); ``` The SDK handles polling, cancellation, TTL cleanup, and `_meta` injection for `tasks/result` responses. `InMemoryTaskStore` is non-persistent — for production, implement a `TaskStore` backed by your database. If you use `McpServer` instead of `Server`, register task-capable tools with `server.experimental.tasks.registerToolTask()` — the higher-level API enforces this for tools that declare `taskSupport`. :::warning Production task isolation `InMemoryTaskStore` does not scope tasks by session — any client that knows a task ID can read, cancel, or list it. For production, implement a `TaskStore` that filters by `sessionId` on every operation. Also clamp client-provided TTL values server-side and enforce rate limits on task creation. ::: ### Server Capabilities AdCP MCP servers declare `tasks` in their capabilities: ```json theme={null} { "capabilities": { "tools": {}, "tasks": { "list": {}, "cancel": {}, "requests": { "tools": { "call": {} } } } } } ``` ### Tool-Level Task Support Each tool declares whether it supports task-augmented execution via `execution.taskSupport`: | Tool | `taskSupport` | Rationale | | ------------------------ | ------------- | ---------------------------------------------- | | `get_products` | `optional` | Complex searches, HITL clarification | | `create_media_buy` | `optional` | External systems, approval workflows | | `update_media_buy` | `optional` | External system updates | | `build_creative` | `optional` | Human creative review, long production renders | | `sync_creatives` | `optional` | Asset processing and transcoding | | `get_signals` | `optional` | Complex audience discovery | | `activate_signal` | `optional` | Platform deployment | | `sync_plans` | `optional` | Governance plan processing | | `check_governance` | `optional` | External policy evaluation | | `report_plan_outcome` | `optional` | External system updates | | `acquire_rights` | `optional` | Approval workflows | | `update_rights` | `optional` | External updates | | `get_rights` | `optional` | External lookups | | `get_adcp_capabilities` | `forbidden` | Instant, static | | `list_creative_formats` | `forbidden` | Instant catalog lookup | | `preview_creative` | `forbidden` | Renders existing manifest | | `list_creatives` | `forbidden` | Session state lookup | | `get_media_buys` | `forbidden` | Session state lookup | | `get_media_buy_delivery` | `forbidden` | Session state lookup | | `get_creative_delivery` | `forbidden` | Session state lookup | | `get_plan_audit_logs` | `forbidden` | Session state lookup | | `get_brand_identity` | `forbidden` | Instant lookup | Tools with `taskSupport: "optional"` can be called either way: * **Without `task` field**: Synchronous — returns the result directly * **With `task` field**: Returns a `CreateTaskResult` immediately; poll the MCP task via transport-native `tasks/get`, retrieve the `CallToolResult` via transport-native `tasks/result`, then inspect the AdCP payload inside that result. ### Invoking a Tool as a Task Include the `task` field in your `tools/call` request: ```json theme={null} { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_products", "arguments": { "buying_mode": "brief", "brief": "Premium CTV inventory for luxury auto" }, "task": { "ttl": 3600000 } } } ``` The server returns a task handle immediately: ```json theme={null} { "jsonrpc": "2.0", "id": 1, "result": { "task": { "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840", "status": "working", "statusMessage": "Searching inventory for luxury auto CTV placements", "createdAt": "2025-11-25T10:30:00Z", "lastUpdatedAt": "2025-11-25T10:30:00Z", "ttl": 3600000, "pollInterval": 5000 } } } ``` The client polls the MCP transport task with `tasks/get` (respecting `pollInterval`) until the task reaches a terminal state (`completed`, `failed`, or `cancelled`), then retrieves the `CallToolResult` via `tasks/result`. To abort the transport task, send `tasks/cancel` with the MCP `taskId`. After retrieving the `CallToolResult`, inspect the AdCP response payload. If it contains `status: "submitted"` and an AdCP `task_id`, the transport task has delivered the queued AdCP response but the application workflow is still open. Continue with webhooks or AdCP polling (`get_task_status`, or legacy `tasks/get` in 3.x). ### MCP Task Status vs. AdCP Status AdCP uses a richer set of statuses than MCP Tasks. If an implementation mirrors AdCP progress into a transport-native MCP Task, use this mapping only for the MCP wrapper. The AdCP payload remains the source of truth for domain workflow state: | AdCP Status | MCP Task Status | Notes | | ---------------- | ---------------- | --------------------------------------------------------------------------- | | `working` | `working` | Direct mapping | | `submitted` | `working` | Use `statusMessage` to indicate queued state | | `input-required` | `input_required` | Server moves task to `input_required`, sends elicitation via `tasks/result` | | `completed` | `completed` | Direct mapping | | `failed` | `failed` | Direct mapping | | `rejected` | `failed` | Use `statusMessage` for rejection reason | | `canceled` | `cancelled` | Spelling difference (AdCP uses American, MCP uses British) | | `auth-required` | `input_required` | Elicitation requests credentials | ### Webhooks for Long-Lived Operations MCP Tasks handles waiting within the MCP session, but many AdCP operations outlive a single session (e.g., a media buy that takes 24 hours for publisher approval). For these, register `push_notification_config` on the AdCP call: ```json theme={null} { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "create_media_buy", "arguments": { "buyer_ref": "nike_q1_2025", "packages": [], "push_notification_config": { "url": "https://buyer.com/webhooks/adcp/create_media_buy/op_abc123", "authentication": { "schemes": ["HMAC-SHA256"], "credentials": "shared_secret_32_chars" } } }, "task": { "ttl": 86400000 } } } ``` The MCP Task tracks the transport wrapper within the session. The webhook tracks the AdCP application task independently and remains valid even after the MCP session ends. See [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for webhook payload formats and authentication. ## Context Management (MCP-Specific) **Critical**: MCP requires manual context management. You must pass `context_id` to maintain conversation state. ### Context Session Pattern ```javascript theme={null} class McpAdcpSession { constructor(mcpClient) { this.mcp = mcpClient; this.contextId = null; } async call(tool, params, options = {}) { const request = { tool: tool, arguments: { ...params } }; // Include context from previous calls if (this.contextId) { request.arguments.context_id = this.contextId; } // Include webhook config in tool arguments if (options.push_notification_config) { request.arguments.push_notification_config = options.push_notification_config; } // Task augmentation for async operations if (options.task) { request.task = options.task; } const response = await this.mcp.callTool(request); // Save context for next call if (response.context_id) { this.contextId = response.context_id; } return response; } reset() { this.contextId = null; } } ``` ### Usage Examples #### Basic Session with Context ```javascript theme={null} const session = new McpAdcpSession(mcp); // First call - no context needed const products = await session.call('get_products', { brief: "Sports campaign" }); // Follow-up - context automatically included const refined = await session.call('get_products', { brief: "Focus on premium CTV" }); // Session remembers previous interaction ``` #### Async Operations with MCP Tasks For tools with `taskSupport: "optional"`, pass the `task` option to use MCP Tasks: ```javascript theme={null} const session = new McpAdcpSession(mcp); // Synchronous call (no task augmentation) const products = await session.call('get_products', { buying_mode: 'brief', brief: "Sports campaign" }); // Task-augmented call for a long-running operation const result = await session.call('create_media_buy', { packages: [...], }, { task: { ttl: 86400000 }, // 24-hour TTL push_notification_config: { // Webhook backup for session-outliving ops url: "https://buyer.com/webhooks/adcp/create_media_buy/op_abc123", authentication: { schemes: ["HMAC-SHA256"], credentials: "shared_secret_32_chars" } } } ); // result is a CreateTaskResult for the MCP wrapper. // After tasks/result, inspect the AdCP payload; if it is still submitted, // continue via webhook or AdCP get_task_status / legacy tasks/get. ``` **Webhook POST format:** ```json theme={null} { "task_id": "task_456", "status": "completed", "timestamp": "2025-01-22T10:30:00Z", "result": { "media_buy_id": "mb_12345", "packages": [...] } } ``` **Note:** Receivers MUST correlate webhooks using `operation_id` (and `task_type`) from the payload body, **not** by parsing the webhook URL. Buyers MAY embed `operation_id` in the URL path or query for their own server-side routing convenience (the URL structure is opaque to the seller and entirely buyer-defined), but the seller never parses that URL — the seller echoes the buyer-supplied `operation_id` it was given at registration, and the wire-level source of truth for correlation is the payload field. See [`mcp-webhook-payload.json`](https://adcontextprotocol.org/schemas/3.1.20/core/mcp-webhook-payload.json) and [Webhooks — Operation IDs](/dist/docs/3.1.20/building/by-layer/L3/webhooks#operation-ids-and-url-templates). The `result` field contains the AdCP data payload. For `completed`/`failed` statuses, this is the full task response (e.g., `create-media-buy-response.json`). For other statuses, use the status-specific schemas (e.g., `create-media-buy-async-response-working.json`). #### MCP Webhook Envelope Fields The [`mcp-webhook-payload.json`](https://adcontextprotocol.org/schemas/3.1.20/core/mcp-webhook-payload.json) envelope includes: **Required fields:** * `idempotency_key` — Per-fire transport dedup key (see schema for full semantics) * `operation_id` — Buyer-supplied correlation identifier echoed verbatim by the seller. Receivers use this — **not** the URL path — to route notifications to the originating task. Sellers MUST NOT derive this by parsing the URL; the URL structure is implementation-defined from the seller's point of view. * `task_id` — Unique task identifier for correlation * `task_type` — Task name (e.g., `create_media_buy`, `sync_creatives`) for routing to per-task handlers * `status` — Current task status (completed, failed, working, input-required, etc.) * `timestamp` — ISO 8601 timestamp when webhook was generated **Optional fields:** * `notification_id` — Event-layer stable id for re-emission tracking (see schema) * `protocol` — AdCP protocol family (`media-buy` or `signals`) * `context_id` — Conversation/session identifier * `message` — Human-readable context about the status change **Data field:** * `result` — Task-specific AdCP payload (see Data Schema Validation below) #### Webhook Trigger Rules Webhooks are sent when **all** of these conditions are met: 1. **Task type supports async** (e.g., `create_media_buy`, `sync_creatives`, `get_products`) 2. **`pushNotificationConfig` is provided** in the request 3. **Task runs asynchronously** — initial response is `working` or `submitted` If the initial response is already terminal (`completed`, `failed`, `rejected`), no webhook is sent—you already have the result. **Status changes that trigger webhooks:** * `working` → Progress update (task actively processing) * `input-required` → Human input needed * `completed` → Final result available * `failed` → Error details #### Data Schema Validation The `result` field in MCP webhooks uses status-specific schemas: | Status | Schema | Contents | | ---------------- | ------------------------------------------- | ------------------------------------ | | `completed` | `[task]-response.json` | Full task response (success branch) | | `failed` | `[task]-response.json` | Full task response (error branch) | | `working` | `[task]-async-response-working.json` | Progress info (`percentage`, `step`) | | `input-required` | `[task]-async-response-input-required.json` | Requirements, approval data | | `submitted` | `[task]-async-response-submitted.json` | Acknowledgment (usually minimal) | Schema reference: [`async-response-data.json`](https://adcontextprotocol.org/schemas/3.1.20/core/async-response-data.json) #### Webhook Handler Example ```javascript theme={null} const express = require('express'); const app = express(); app.post('/webhooks/adcp/:task_type/:agent_id/:operation_id', async (req, res) => { const { task_type, agent_id, operation_id } = req.params; const webhook = req.body; // Verify webhook authenticity (HMAC-SHA256 example) const signature = req.headers['x-adcp-signature']; const timestamp = req.headers['x-adcp-timestamp']; if (!verifySignature(webhook, signature, timestamp)) { return res.status(401).json({ error: 'Invalid signature' }); } // Handle status changes switch (webhook.status) { case 'input-required': // Alert human that input is needed await notifyHuman({ operation_id, message: webhook.message, context_id: webhook.context_id, data: webhook.result }); break; case 'completed': // Process the completed operation if (task_type === 'create_media_buy') { await handleMediaBuyCreated({ media_buy_id: webhook.result.media_buy_id, packages: webhook.result.packages }); } break; case 'failed': // Handle failure await handleOperationFailed({ operation_id, error: webhook.result?.errors, message: webhook.message }); break; case 'working': // Update progress UI await updateProgress({ operation_id, percentage: webhook.result?.percentage, message: webhook.message }); break; case 'canceled': await handleOperationCanceled(operation_id, webhook.message); break; } // Always return 200 for successful processing res.status(200).json({ status: 'processed' }); }); function verifySignature(payload, signature, timestamp) { const crypto = require('crypto'); const expectedSig = crypto .createHmac('sha256', process.env.WEBHOOK_SECRET) .update(timestamp + JSON.stringify(payload)) .digest('hex'); return signature === `sha256=${expectedSig}`; } ``` #### Task Management and Polling ```javascript theme={null} // Check status of a specific AdCP task const taskStatus = await session.call('get_task_status', { task_id: 'task_456', include_result: true }); if (taskStatus.status === 'completed') { console.log('Result:', taskStatus.result); } // State reconciliation const reconciliation = await session.call('list_tasks', { filters: { statuses: ['submitted', 'working', 'input-required'] } }); if (reconciliation.tasks.length > 0) { console.log('Found open AdCP tasks:', reconciliation.tasks); // Start tracking these tasks } // List all pending operations const pending = await session.listPendingTasks(); console.log(`${pending.tasks.length} operations in progress`); ``` ### Context Expiration Handling ```javascript theme={null} async function handleContextExpiration(session, tool, params) { try { return await session.call(tool, params); } catch (error) { if (error.message?.includes('context not found')) { // Context expired - start fresh session.reset(); return session.call(tool, params); } throw error; } } ``` **Key Difference**: Unlike A2A which manages context automatically, MCP requires explicit context\_id management. ## Handling Async Operations When an AdCP response returns `working` or `submitted` status, you need a way to receive the result. This applies whether or not your MCP client supports MCP Tasks — the patterns below work with any client. | Approach | Best For | Trade-offs | | ------------- | ------------------------------------- | --------------------------------------------------------------------------------------------- | | **Webhooks** | Production systems, any task duration | Handles hours/days, but requires a public endpoint | | **Polling** | Simple integrations, short tasks | Easy to implement, but inefficient for long waits | | **MCP Tasks** | Custom clients using the MCP SDK | Protocol-native wrapper for the initial `tools/call`, but does not replace AdCP task tracking | ### Option 1: Webhooks (recommended) Configure a webhook URL and the server will POST the result when the operation completes. This is the right approach for `submitted` operations that are blocked on external dependencies (publisher approval, human review). ```javascript theme={null} const response = await session.call('create_media_buy', { packages: [...], budget: { total: 150000, currency: "USD" } }, { push_notification_config: { url: "https://buyer.com/webhooks/adcp/create_media_buy/op_abc123", authentication: { schemes: ["HMAC-SHA256"], credentials: "shared_secret_32_chars" } } } ); // If status is 'submitted', the server will POST the result to your webhook // No polling needed — just handle the webhook when it arrives ``` See [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for payload formats and authentication. ### Option 2: Polling (backup) Use AdCP polling as a backup for `submitted` operations, or when you can't expose a webhook endpoint. In 3.x, prefer `get_task_status` when the seller advertises it; otherwise use legacy `tasks/get`: ```javascript theme={null} async function pollForResult(session, taskId, pollInterval = 30000) { while (true) { const response = await session.call('get_task_status', { task_id: taskId, include_result: true }); if (['completed', 'failed', 'canceled'].includes(response.status)) { return response; } if (response.status === 'input-required') { const input = await promptUser(response.message); return session.call('create_media_buy', { context_id: response.context_id, additional_info: input }); } await new Promise(resolve => setTimeout(resolve, pollInterval)); } } ``` ### Handling different statuses ```javascript theme={null} const initial = await session.call('create_media_buy', { packages: [...], budget: { total: 100000, currency: "USD" } }); switch (initial.status) { case 'completed': // Done — result is inline console.log('Created:', initial.media_buy_id); break; case 'working': // Server is actively processing (>30s) — just wait, result will arrive // No polling needed; 'working' is a progress signal, not a polling trigger console.log('Processing:', initial.message); break; case 'submitted': // Blocked on external dependency — use webhook or poll console.log(`Task ${initial.task_id} queued for approval`); break; case 'input-required': // Blocked on user input console.log('Need more info:', initial.message); break; } ``` ## Integration Example ```javascript theme={null} // Initialize MCP session with context management const session = new McpAdcpSession(mcp); // Use unified status handling (see Core Concepts) async function handleAdcpCall(tool, params, options = {}) { const response = await session.call(tool, params, options); switch (response.status) { case 'input-required': // Handle clarification (see Core Concepts for patterns) const input = await promptUser(response.message); return session.call(tool, { ...params, additional_info: input }); case 'working': // Server is actively processing — just wait, result will arrive console.log('Processing:', response.message); return response; case 'submitted': // Blocked on external dependency — webhook or poll console.log(`Task ${response.task_id} submitted, webhook will notify`); return { pending: true, task_id: response.task_id }; case 'completed': return response; // Task-specific fields are at the top level case 'failed': throw new Error(response.message); } } // Example usage const products = await handleAdcpCall('get_products', { brief: "CTV campaign for luxury cars" }); ``` ## MCP-Specific Considerations ### Server-side tool wrappers MUST tolerate envelope fields Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) uniformly across all AdCP tool calls — including read-only tools that don't consume them. MCP tool implementations MUST accept these fields and ignore the ones they don't use; they MUST NOT reject a call because an envelope field is present. Common traps: * **FastMCP / Pydantic strict signatures** — declare `idempotency_key: str | None = None` (and the other envelope fields) as accept-and-ignore optionals, or use `**kwargs` to swallow unknowns. `model_config = ConfigDict(extra='allow')` on input models if you control them. * **Zod / valibot with `.strict()`** on input schemas — drop `.strict()` or use a passthrough variant. * **OpenAPI codegen** that injects `additionalProperties: false` into input models — fix the generator config; the spec's request schemas declare `additionalProperties: true`. A wrapper that raises `unexpected_keyword_argument` on `idempotency_key` will fail compliance against any buyer SDK that follows the envelope contract. See [security.mdx > Server-side tool wrapper conformance](/dist/docs/3.1.20/building/by-layer/L1/security#server-side-tool-wrapper-conformance) for the normative rule. ### Tool Discovery ```javascript theme={null} // List available tools — use get_adcp_capabilities for runtime feature detection const tools = await mcp.listTools(); // Check which tools support async execution const asyncTools = tools.filter(t => t.execution?.taskSupport === 'optional'); ``` ### AdCP Extension via MCP Server Card **Recommended**: Use [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) for runtime capability discovery. The server card extension provides static metadata for tool catalogs and registries. MCP servers can declare AdCP support via a server card at `/.well-known/mcp.json` (or `/.well-known/server.json`). AdCP-specific metadata goes in the `_meta` field using the `adcontextprotocol.org` namespace. ```json theme={null} { "name": "io.adcontextprotocol/media-buy-agent", "version": "1.0.0", "title": "AdCP Media Buy Agent", "description": "AI-powered media buying agent implementing AdCP", "tools": [ { "name": "get_products" }, { "name": "create_media_buy" }, { "name": "list_creative_formats" } ], "_meta": { "adcontextprotocol.org": { "adcp_version": "2.6.0", "protocols_supported": ["media_buy"], "extensions_supported": ["sustainability"] } } } ``` **Discovering AdCP support:** ```javascript theme={null} // Check both possible locations for MCP server card const serverCard = await fetch('https://sales.example.com/.well-known/mcp.json') .then(r => r.ok ? r.json() : null) .catch(() => null) || await fetch('https://sales.example.com/.well-known/server.json') .then(r => r.json()); // Check for AdCP metadata const adcpMeta = serverCard?._meta?.['adcontextprotocol.org']; if (adcpMeta) { console.log('AdCP Version:', adcpMeta.adcp_version); console.log('Supported domains:', adcpMeta.protocols_supported); // ["media_buy", "creative", "signals"] console.log('Typed extensions:', adcpMeta.extensions_supported); // ["sustainability"] } ``` **Benefits:** * Clients can discover AdCP capabilities without making test calls * Declare which protocol domains you implement (media\_buy, creative, signals) * Declare which typed extensions you support (see [Context & Sessions](/dist/docs/3.1.20/building/by-layer/L2/context-sessions#extension-fields-ext)) * Enable compatibility checks based on version :::note The `adcp_version` field in server card metadata is a v2 convention and is not part of the v3 spec. For v3 version negotiation, the buyer sends release-precision `adcp_version` (e.g., `"3.1"`) on every request, and the seller advertises supported releases via `adcp.supported_versions` on [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) and echoes `adcp_version` at the envelope root on every response. The legacy integer-only `adcp_major_version` field is still accepted for backwards compatibility. See [versioning.mdx § Version negotiation](/dist/docs/3.1.20/reference/versioning#version-negotiation) for the full contract. ::: **Note:** The `_meta` field uses reverse DNS namespacing per the [MCP server.json spec](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/generic-server-json.md). AdCP servers should support both `/.well-known/mcp.json` and `/.well-known/server.json` locations. ### Parameter Validation ```javascript theme={null} // MCP provides tool schemas for validation const toolSchema = await mcp.getToolSchema('get_products'); // Use schema to validate parameters before calling ``` ### Error Handling AdCP errors are returned as tool-level responses with `isError: true` and the error in `structuredContent.adcp_error`. For the full extraction logic and JSON-RPC transport codes, see [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors). ```javascript theme={null} try { const response = await session.call('get_products', params); // Check for AdCP application errors (isError: true with structured data) if (response.isError) { const adcpError = response.structuredContent?.adcp_error; if (adcpError) { // Structured error with code, recovery, retry_after, etc. console.log('AdCP error:', adcpError.code, adcpError.recovery); } } } catch (mcpError) { // MCP transport errors (connection, auth, etc.) // Check for AdCP-structured transport errors const adcpError = mcpError.data?.adcp_error; if (adcpError) { console.log('Transport error:', adcpError.code); } else { console.error('MCP Error:', mcpError); } } ``` ## Best Practices 1. **Use session wrapper** for automatic context management 2. **Check status field** before processing response data 3. **Handle context expiration** gracefully with retries 4. **Reference Core Concepts** for status handling patterns 5. **Validate parameters** using MCP tool schemas when available ## Next Steps * **Core Concepts**: Read [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for status handling and workflows * **Task Reference**: See [Media Buy Tasks](/dist/docs/3.1.20/media-buy) and [Signals](/dist/docs/3.1.20/signals/overview) * **Protocol Comparison**: Compare with [A2A integration](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide) * **Examples**: Find complete workflow examples in Core Concepts **For status handling, async operations, and clarification patterns, see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) - this guide focuses on MCP transport specifics only.** # MCP Response Extraction Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L0/mcp-response-extraction How to extract AdCP success response data from MCP tool results: structuredContent, text fallback, and client implementation requirements. This page defines the normative algorithm for extracting AdCP success response data from MCP tool results. For error extraction, see [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors). ## Layer Separation | Path | When | Data Source | | -------------------------------------------------------------------------------------------- | --------------------------- | ----------------------------------------------- | | Success extraction (this page) | `isError` absent or `false` | `structuredContent` or `content[].text` | | Error extraction ([transport-errors](/dist/docs/3.1.20/building/operating/transport-errors)) | `isError: true` | `structuredContent.adcp_error` or text fallback | Clients MUST check `isError` before deciding which extraction path to use. A response with `isError: true` MUST NOT be processed as a success response, even if it contains `structuredContent` with non-error data. ## Extraction Algorithm Clients MUST extract AdCP data from MCP tool results in this order: 1. **Guard: reject error responses.** If `isError` is truthy, return null. Error extraction is a separate path. 2. **`structuredContent`** — If present and is a non-array object, return it. If the only key is `adcp_error`, return null (this is an error response missing the `isError` flag). 3. **Text fallback** — Iterate `content[]` items in array order. For each item where `type === 'text'`, enforce a 1MB size limit, then attempt `JSON.parse`. If the result is a non-array object, return it. Skip items that fail to parse, parse as non-objects, or contain only an `adcp_error` key. 4. **No structured data found** — Return null. The response is plain text with no machine-readable AdCP data. ```javascript MCP Client theme={null} function extractAdcpResponseFromMcp(response) { // 1. Error responses go through transport-errors extraction if (response.isError) return null; // 2. structuredContent (preferred — MCP 2025-03-26+) if (response.structuredContent != null && typeof response.structuredContent === 'object' && !Array.isArray(response.structuredContent)) { const sc = response.structuredContent; // adcp_error-only structuredContent is an error missing isError flag const keys = Object.keys(sc); if (keys.length === 1 && keys[0] === 'adcp_error') return null; return sc; } // 3. Text fallback — JSON.parse content[].text if (response.content && Array.isArray(response.content)) { for (const item of response.content) { if (item.type === 'text' && item.text) { if (item.text.length > 1_048_576) continue; // 1MB size limit try { const parsed = JSON.parse(item.text); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { // Skip adcp_error-only payloads (error missing isError flag) const keys = Object.keys(parsed); if (keys.length === 1 && keys[0] === 'adcp_error') continue; return parsed; } } catch { /* not JSON */ } } } } return null; } ``` ## Extraction Paths ### structuredContent (Preferred) MCP 2025-03-26 introduced `structuredContent` for typed tool results. AdCP servers return the full response payload here: ```json theme={null} { "content": [{"type": "text", "text": "Found 3 products matching your brief."}], "structuredContent": { "status": "completed", "message": "Found 3 products", "products": [ {"product_id": "ctv_sports_premium", "name": "Premium Sports CTV"}, {"product_id": "ctv_news_standard", "name": "Standard News CTV"} ] } } ``` The `structuredContent` object IS the AdCP response — task-specific fields (`products`, `media_buy_id`, `status`, etc.) are at the top level, not nested. ### Text Fallback Older MCP servers (pre-2025-03-26) serialize the response as JSON in `content[].text`: ```json theme={null} { "content": [ {"type": "text", "text": "{\"status\":\"completed\",\"products\":[{\"product_id\":\"ctv_premium\"}]}"} ] } ``` Clients parse the first text item that yields a JSON object. When both `structuredContent` and text JSON exist, `structuredContent` takes precedence. ## Relationship to Error Extraction Success and error extraction are complementary: ```javascript theme={null} function handleMcpResponse(response) { // Try error extraction first (only runs if isError is true) const error = extractAdcpErrorFromMcp(response); if (error) return handleError(error); // Then try success extraction const data = extractAdcpResponseFromMcp(response); if (data) return handleSuccess(data); // Plain text response — no structured data return handlePlainText(response.content); } ``` ## Security Considerations ### Seller-Controlled Data All data in `structuredContent` and `content[].text` is seller-controlled. The same prompt injection and data boundary requirements from [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors#security-considerations) apply. ### Size Limits Clients SHOULD enforce a maximum payload size before processing. A recommended limit is 1MB for `structuredContent`. For text fallback, apply the limit before `JSON.parse` to prevent memory exhaustion from oversized payloads. ### Prototype Pollution Clients MUST NOT merge extracted response objects into application state via `Object.assign` or spread without filtering keys. Seller-controlled keys like `__proto__` or `constructor` can trigger prototype pollution. Validate against the expected task response schema before merging. ### Type Confusion Clients MUST check `isError` before success extraction. Without this guard, a client could process an error response as success data, leading to incorrect business logic (e.g., treating a `RATE_LIMITED` error as product data). ## Client Library Requirements Client libraries that implement this spec MUST: 1. **Check `isError` before extraction.** Return null for error responses. 2. **Prefer `structuredContent`.** Only fall back to text parsing when `structuredContent` is absent. 3. **Validate parsed text.** Only accept non-array objects from `JSON.parse`. Reject arrays, strings, numbers, booleans, and null. 4. **Handle `adcp_error`-only `structuredContent`.** When `structuredContent` contains only an `adcp_error` key, return null — this is an error response that may be missing the `isError` flag. ## Test Vectors Machine-readable test vectors are available at [`/static/test-vectors/mcp-response-extraction.json`](https://adcontextprotocol.org/test-vectors/mcp-response-extraction.json). Each vector contains: * `path`: extraction path (`structuredContent` or `text_fallback`) * `response`: the MCP tool result envelope * `expected_data`: the AdCP data that should be extracted (or `null`) Client libraries SHOULD validate their extraction logic against these vectors. ## See Also * [Transport Error Mapping](/dist/docs/3.1.20/building/operating/transport-errors) — error extraction from MCP and A2A * [A2A Response Extraction](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction) — equivalent spec for A2A * [MCP Guide](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide) — MCP transport integration # Schemas Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L0/schemas AdCP JSON schemas: where to fetch them, the protocol tarball, schema versioning, bundled vs $ref-resolving variants, and how to verify supply-chain provenance via Sigstore. The L0 wire layer is JSON-over-HTTP framed by published JSON Schemas. This page is the reference for getting the schemas — where they live, how to pin a version, how to verify supply-chain provenance, and the directory shape inside a release. If you're picking an SDK rather than the schemas themselves, see [Choose your SDK](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk). ## Schema access AdCP schemas are available from two sources: | Source | URL | Best For | | ------- | ------------------------------------------------------------------ | --------------------------------- | | Website | `https://adcontextprotocol.org/schemas/3.1.20/` | Runtime fetching, version aliases | | GitHub | `https://github.com/adcontextprotocol/adcp/tree/main/dist/schemas` | Offline access, CI/CD pipelines | Both sources contain identical schemas. The GitHub repository includes all released versions with bundled schemas committed directly to the codebase. ## One-shot protocol bundle Syncing hundreds of individual schema files adds up. Every AdCP release also publishes a single gzipped tarball containing the complete protocol — schemas, compliance storyboards, and the OpenAPI registry — so clients can pull one artifact instead of crawling the tree. | Path | Contents | Notes | | ------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `https://adcontextprotocol.org/protocol/latest.tgz` | Current development bundle | Changes with every merge | | `https://adcontextprotocol.org/protocol/{version}.tgz` | Pinned release bundle | Immutable once published | | `https://adcontextprotocol.org/protocol/{version}.tgz.sha256` | SHA-256 checksum | Use to verify download integrity | | `https://adcontextprotocol.org/protocol/{version}.tgz.sig` | Sigstore detached signature | Use to verify publisher identity. Present only when the release was cut via the `release.yml` workflow — absent for out-of-band republishes. | | `https://adcontextprotocol.org/protocol/{version}.tgz.crt` | Fulcio-issued signing certificate | Pairs with `.sig` for `cosign verify-blob`. Present only when the release was cut via the `release.yml` workflow — absent for out-of-band republishes. | Every tarball extracts into a single `adcp-{version}/` directory (safe extraction, no tarbomb). Inside: ``` adcp-{version}/ README.md # quickstart + links CHANGELOG.md # release notes manifest.json # version, generated_at, contents summary schemas/ # full JSON schema tree (same as /schemas/{version}/) compliance/ # protocols/, specialisms/, universal/, test-kits/, index.json openapi/registry.yaml # OpenAPI description ``` Verify the checksum before extracting: ```bash theme={null} curl -OL https://adcontextprotocol.org/protocol/3.1.0.tgz curl -OL https://adcontextprotocol.org/protocol/3.1.0.tgz.sha256 shasum -a 256 -c 3.1.0.tgz.sha256 tar xzf 3.1.0.tgz cd adcp-3.1.0 ``` Pull it once per version, cache by SHA, and you have everything needed to validate requests, run storyboards, and render documentation offline. The `@adcp/sdk` `sync-schemas` command uses this under the hood. Available tarballs are also listed at [`/protocol/`](https://adcontextprotocol.org/protocol/). ### Verifying protocol bundle signatures The SHA-256 sidecar lives on the same origin as the tarball, so it only protects against in-transit tampering. For supply-chain protection — proving the bundle came from the AdCP release workflow and was not swapped for a malicious one even if the host were compromised — every released `{version}.tgz` is also published with a Sigstore detached signature. The signature is produced by the GitHub Actions release workflow using keyless OIDC: there is no long-lived AdCP signing key to leak. The certificate binds the signature to the workflow identity that issued it. ```bash theme={null} # Pull the tarball and the two signature sidecars curl -OL https://adcontextprotocol.org/protocol/3.1.0.tgz curl -OL https://adcontextprotocol.org/protocol/3.1.0.tgz.sig curl -OL https://adcontextprotocol.org/protocol/3.1.0.tgz.crt # Verify (requires cosign 2.x — `brew install cosign`) cosign verify-blob \ --signature 3.1.0.tgz.sig \ --certificate 3.1.0.tgz.crt \ --certificate-identity-regexp '^https://github\.com/adcontextprotocol/adcp/\.github/workflows/release\.yml@refs/(heads|tags)/.*$' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ 3.1.0.tgz ``` `cosign verify-blob` exits non-zero if the signature was made by anything other than the AdCP release workflow, even if the SHA matches and TLS is valid. Use this in any pipeline that ingests the protocol bundle as an enforcement source. The `@adcp/sdk`, `adcp-client-python`, and `adcp-go` SDKs perform this verification automatically when the sidecars are present. The `refs/(heads|tags)/.*` wildcard is intentional — releases sign during the push-triggered workflow run, so the cert subject names the release branch (e.g. `refs/heads/3.0.x` for v3.0.1+, `refs/heads/main` for v3.0.0). The trust gate is upstream `release.yml`'s `on.push.branches` allowlist, not the consumer's regex. Literal-allowlist regexes (`(main|2\.6\.x)`-style) silently break every time a new maintenance branch is added — see [Verifying protocol tarballs](/dist/docs/3.1.20/reference/verifying-protocol-tarballs) for the full trust model and the cert-subject-by-release lookup. Older releases that predate signing, and versions republished out of band (bypassing the signing workflow), remain checksum-only — clients should treat missing sidecars as a "checksum-only" trust level rather than a verification failure. ## Compliance storyboards Storyboards live alongside schemas at `/compliance/{version}/`. They define the test scenarios AAO runs to verify an agent's capability claims. | Path | Purpose | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `/compliance/{version}/universal/` | Required for every agent (capability discovery, error handling, schema validation) | | `/compliance/{version}/protocols/{protocol}/` | Baseline required to claim a protocol (`media-buy`, `creative`, `signals`, `governance`, `brand`, `sponsored-intelligence`) | | `/compliance/{version}/specialisms/{id}/` | Optional specialization claims (e.g. `sales-guaranteed`, `sales-broadcast-tv`) | | `/compliance/{version}/index.json` | Enumerates available protocols, specialisms, and universal storyboards | Declare `supported_protocols` (for protocol baselines) and `specialisms` (for narrow capability claims) in `get_adcp_capabilities` — the compliance runner executes the matching bundles to verify. See the full [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for every protocol and specialism an agent can claim. ## Common schemas | Schema | URL | | --------------- | ------------------------------------------------------------------ | | Product | `https://adcontextprotocol.org/schemas/3.1.20/core/product.json` | | Media Buy | `https://adcontextprotocol.org/schemas/3.1.20/core/media-buy.json` | | Creative Format | `https://adcontextprotocol.org/schemas/3.1.20/core/format.json` | | Schema Registry | `https://adcontextprotocol.org/schemas/3.1.20/index.json` | **For AI coding agents:** point your coding agent to **[https://docs.adcontextprotocol.org/mcp](https://docs.adcontextprotocol.org/mcp)** for MCP integration documentation. ## Schema versioning AdCP uses semantic versioning. Choose the right path for your use case: | Path | Example | Best For | | ------------- | -------------------------------------------------------------- | --------------------------------------- | | Exact version | `/schemas/3.0.0/`, `/compliance/3.0.0/`, `/protocol/3.0.0.tgz` | Production, SDK generation | | Major version | `/schemas/3.1.20/`, `/compliance/v3/` | Development, documentation | | Minor version | `/schemas/v3.0/`, `/compliance/v3.0/` | Stable development (patch updates only) | The same version semantics apply to `/schemas`, `/compliance`, and `/protocol/{version}.tgz` — one release cuts all three. ### Production (recommended) Pin to an exact version for stability: ```javascript theme={null} const SCHEMA_VERSION = '3.0.0'; const schema = await fetch( `https://adcontextprotocol.org/schemas/${SCHEMA_VERSION}/core/product.json` ); ``` ### Development Use the major version alias to stay current with backward-compatible updates: ```javascript theme={null} const schema = await fetch( 'https://adcontextprotocol.org/schemas/3.1.20/core/product.json' ); ``` ### SDK type generation ```bash theme={null} # TypeScript npx json-schema-to-typescript \ https://adcontextprotocol.org/schemas/3.0.0/core/product.json \ --output types/product.d.ts # Python datamodel-codegen \ --url https://adcontextprotocol.org/schemas/3.0.0/core/product.json \ --output models/product.py ``` ## Bundled schemas For tools that don't support `$ref` resolution, use bundled schemas with all references resolved inline. Bundled schemas are available from both the website and GitHub: ### Website access ``` https://adcontextprotocol.org/schemas/3.0.0/bundled/media-buy/create-media-buy-request.json ``` ### GitHub access Bundled schemas are committed to the repository at `dist/schemas/{VERSION}/bundled/`: ```bash theme={null} # Clone and access locally git clone https://github.com/adcontextprotocol/adcp.git ls adcp/dist/schemas/3.0.0/bundled/media-buy/ # Or fetch directly via GitHub raw curl https://raw.githubusercontent.com/adcontextprotocol/adcp/main/dist/schemas/3.0.0/bundled/media-buy/get-products-request.json ``` ### Directory structure ``` dist/schemas/{VERSION}/ ├── bundled/ # Fully dereferenced schemas │ ├── media-buy/ # Media buying tasks │ ├── creative/ # Creative tasks │ ├── signals/ # Signal protocol tasks │ ├── property/ # Property/governance tasks │ ├── content-standards/ # Content standards tasks │ ├── sponsored-intelligence/ # Sponsored intelligence tasks │ ├── protocol/ # Protocol tasks │ └── core/ # Core shared schemas and legacy task lifecycle schemas ├── core/ # Modular schemas with $ref ├── trusted-match/ # Serve-time Context Match and Identity Match schemas ├── media-buy/ └── index.json # Schema registry ``` `index.json` is the directory authority. It declares the bundle's `published_version`, stability metadata, and `protocol_layers`: the negotiation layer (`media-buy`, `creative`, `signals`, `account`, `governance`, `brand`, `sponsored-intelligence`) and the decisioning/serving layer (`trusted-match`). ### Bundled schema categories All request/response task schemas are bundled: | Category | Tasks | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bundled/media-buy/` | get-products, create-media-buy, update-media-buy, list-creative-formats, sync-creatives, build-creative, list-creatives, get-media-buy-delivery, list-authorized-properties, provide-performance-feedback | | `bundled/creative/` | list-creative-formats, preview-creative | | `bundled/signals/` | get-signals, activate-signal | | `bundled/property/` | create-property-list, get-property-list, list-property-lists, update-property-list, delete-property-list, validate-property-delivery | | `bundled/content-standards/` | create-content-standards, get-content-standards, list-content-standards, update-content-standards, calibrate-content, validate-content-delivery, get-media-buy-artifacts | | `bundled/sponsored-intelligence/` | si-get-offering, si-initiate-session, si-send-message, si-terminate-session | | `bundled/protocol/` | get-adcp-capabilities, get-task-status, list-tasks | | `bundled/core/` | tasks-get, tasks-list | See the [schema registry](https://adcontextprotocol.org/schemas/3.1.20/index.json) for all available schemas. ## Version discovery ```bash theme={null} # Get the canonical stable schema bundle and registry URL. curl https://adcontextprotocol.org/schemas/latest.json | jq '{version: .latest_stable, index}' # Or read the full file-based discovery index. curl https://adcontextprotocol.org/schemas/index.json | jq '.aliases' # The versioned registry also carries the full semver of its bundle. # (Note: `published_version` carries full semver including patch. # It's distinct from the per-request/response wire `adcp_version` # field defined in core/version-envelope.json, which uses # release-precision — never send `published_version` on the wire.) curl https://adcontextprotocol.org/schemas/3.1.20/index.json | jq '.published_version' ``` Do not infer the canonical version from directory order or `versions[0]`; pre-release artifacts remain discoverable for pinned historical builds. Use `latest_stable` or the `aliases` map for canonical stable selection. Check [Release Notes](/dist/docs/3.1.20/reference/release-notes) for version history and migration guides. ## Registry API The AgenticAdvertising.org registry provides a public REST API for brand resolution, property resolution, agent discovery, and authorization validation. No authentication required. Resolve brands, discover agents, and validate authorization via REST. # L1 — Identity & signing Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L1/index Identity-and-signing layer of the AdCP stack. RFC 9421 HTTP message signatures, public-key resolution, replay-window enforcement, key rotation. L1 cryptographically verifies that the request came from who the headers claim it did, and that the body wasn't modified in transit. RFC 9421 HTTP message signatures with replay-window enforcement and key rotation. Symmetric on both sides — agents verify inbound and sign outbound webhooks; callers sign outbound and verify inbound webhooks. ## What an SDK at L1 must provide If you're picking an SDK or porting one to a new language, this is the L1 build target: * **RFC 9421 message-signature signing** for outbound requests. * **RFC 9421 verification** for inbound requests, including replay-window enforcement on `created` / `expires` and `keyid`-based key lookup. * **A pluggable signing-provider abstraction** — in-process keys for development, KMS / HSM providers for production. * **Test fixtures or a verifier-test harness** so adopters can assert their signing wiring is correct without booting a full agent. For the cumulative cross-layer story (what L0+L1 buys you), see the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#l1--identity--signing). ## Pages in this layer * **[Security implementation profile](/dist/docs/3.1.20/building/by-layer/L1/security)** — RFC 9421 wire details, KMS integration, replay-window tuning. * **[Webhook verifier tuning](/dist/docs/3.1.20/building/by-layer/L1/webhook-verifier-tuning)** — clock-skew handling, key-rotation transitions, signature failure diagnostics. # Request Signing Guide Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L1/request-signing Step-by-step guide to RFC 9421 request signing in AdCP: key generation, JWKS publication, brand.json setup, client-side signing, server-side verification, webhook signing, key rotation, and conformance testing. AdCP 3.0 supports [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421) for cryptographic request authentication. A buyer signs outbound requests so the seller can verify who sent them and that the payload wasn't tampered with. A seller signs outbound webhooks so the buyer can verify authenticity. Signing is **optional in AdCP 3.0** and becomes **mandatory in AdCP 4.0** for all spend-committing operations. Agents that don't sign yet must still tolerate signature headers (`Signature`, `Signature-Input`, `Content-Digest`) on inbound requests without breaking. This is the practical implementation guide. For the normative specification — covered components, canonicalization rules, the full verifier checklist, replay dedup sizing, and the complete error taxonomy — see [Security: Signed Requests](/dist/docs/3.1.20/building/by-layer/L1/security#signed-requests-transport-layer). Code examples below use **JavaScript/TypeScript**, **Python**, and **Go** SDK helpers in tabs. All three SDKs implement the same RFC 9421 profile against the same conformance vectors — the API surface differs but the wire output is identical. If your language isn't listed, the [conformance vectors](#testing) and the [normative spec](/dist/docs/3.1.20/building/by-layer/L1/security#signed-requests-transport-layer) are language-agnostic. ## When you need this | You are a... | You need to... | Why | | ------------------------------------- | ------------------------------------------------ | --------------------------------------------------- | | **Buyer** (calls seller tools) | Sign outbound requests | Sellers may require proof the request came from you | | **Buyer** (receives webhooks) | Verify inbound webhook signatures | Confirm the webhook came from the seller | | **Seller** (receives tool calls) | Verify inbound request signatures | Confirm the buyer is who they claim to be | | **Seller** (sends webhooks) | Sign outbound webhooks | Let buyers verify webhook authenticity | | **Orchestrator** (proxies to sellers) | Sign outbound requests + verify inbound webhooks | You're the buyer from the seller's perspective | ## Key concepts ### Signature coverage The AdCP signing profile covers these request components: * `@method` — HTTP method * `@target-uri` — full canonicalized request URL * `@authority` — lowercased host header * `content-type` — media type * `content-digest` — SHA-256 or SHA-512 hash of the request body (see `covers_content_digest` capability) If any covered component changes after signing, verification fails. ### Key separation Every agent publishes signing keys with a distinct `kid` and `adcp_use` tag: * `adcp_use: "request-signing"` — for signing outbound tool calls and outbound webhooks * `adcp_use: "webhook-signing"` — deprecated; still accepted on the webhook path for backward compatibility For blast-radius isolation, publish a second `request-signing` key under a separate `kid` for webhook delivery rather than reusing the same key material for tool calls and webhooks. ### Discovery chain Verifiers find your public key through a three-step chain: ``` Your domain (e.g., agent.example.com) -> /.well-known/brand.json # brand manifest with agent declarations -> agents[].jwks_uri # pointer to your key store -> /.well-known/jwks.json # JSON Web Key Set with public keys ``` The `@adcp/client` SDK provides `BrandJsonJwksResolver` which handles this chain automatically with caching and refresh. ## Step 1: Generate a signing key ### CLI ```bash theme={null} adcp signing generate-key --alg ed25519 --kid my-agent-2026 \ --private-out ./private.jwk --public-out ./public-jwks.json ``` This generates an Ed25519 keypair and writes: * `private.jwk` — the private key (JWK with `d` field). Keep this secret. * `public-jwks.json` — the public key in JWKS format. Publish this. ### Programmatic ```typescript theme={null} import { generateKeyPair, exportJWK } from 'jose'; const { publicKey, privateKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519' }); const publicJwk = await exportJWK(publicKey); const privateJwk = await exportJWK(privateKey); const kid = 'my-agent-2026'; publicJwk.kid = kid; publicJwk.use = 'sig'; publicJwk.key_ops = ['verify']; publicJwk.adcp_use = 'request-signing'; ``` ```python theme={null} from adcp.signing import generate_signing_keypair # CLI equivalence: adcp-keygen --alg ed25519 --purpose request-signing --kid my-agent-2026 pem_bytes, public_jwk = generate_signing_keypair( alg="ed25519", purpose="request-signing", kid="my-agent-2026", ) # pem_bytes — write to disk with mode 0600 and O_EXCL, or pass to a secret manager. # public_jwk — publish in your JWKS endpoint. Already includes kid, use, key_ops, and adcp_use. ``` ```go theme={null} import "github.com/adcontextprotocol/adcp-go/adcp/signing" res, err := signing.GenerateKeyForProfile( signing.AlgEd25519, "my-agent-2026", signing.ProfileRequestSigning, ) if err != nil { /* handle */ } // res.PrivateKeyPEM — write to disk with mode 0600, or pass to a secret manager. // res.PublicJWK — serialize and publish in your JWKS endpoint. AdCP-required // fields (kid, kty, crv, alg, use, key_ops, adcp_use) are set. ``` Use `signing.ProfileWebhookSigning` for the webhook RFC 9421 profile. Publish the corresponding public key with `adcp_use: "request-signing"`; if you want webhook-only key material, use a separate `kid`. ### Supported algorithms | Algorithm | `alg` value | Key type | Notes | | ----------- | ---------------------------------------------- | ----------------- | -------------------------------------------------------- | | Ed25519 | `ed25519` (RFC 9421) / `EdDSA` (JWK) | `OKP` / `Ed25519` | Preferred. Fast, small signatures. | | ECDSA P-256 | `ecdsa-p256-sha256` (RFC 9421) / `ES256` (JWK) | `EC` / `P-256` | Edge-runtime friendly (Cloudflare Workers, Vercel Edge). | The algorithm name differs between the JWK entry (`"alg": "EdDSA"`) and the RFC 9421 `Signature-Input` parameter (`alg="ed25519"`). See the [algorithm naming table](/dist/docs/3.1.20/building/by-layer/L1/security#adcp-rfc-9421-profile) in the spec. ### Storing the private key Pick the strongest option your runtime supports. From most to least secure: * **Cloud KMS** (GCP Cloud KMS, AWS KMS, Azure Key Vault): the private key is generated inside the HSM and never leaves it. Signing is performed by calling the KMS API; you only ever hold the JWK reference, not the key bytes. The TypeScript SDK exposes `createKmsSigner` for GCP Cloud KMS — see [`@adcp/client/signing/kms`](https://github.com/adcontextprotocol/adcp-client/tree/main/src/lib/signing/kms). Recommended for any agent handling spend-committing operations. * **Secret manager** (GCP Secret Manager, AWS Secrets Manager, HashiCorp Vault): load at boot, keep in memory for the process lifetime. Easier than KMS but the key material lives in your process — leaks via memory dumps, logging, or compromised dependencies. * **Environment variable**: `ADCP_SIGNING_PRIVATE_KEY='{"kid":"...","kty":"OKP",...}'`. Acceptable for dev and small deployments. Same memory-resident risk as a secret manager. * **File**: development only. Never commit to version control. Use mode `0600` and `O_EXCL` so an existing file is never overwritten — `Path.write_bytes` inherits the process umask (often `0644`, world-readable) and is unsafe for private-key material. Once you choose KMS, signing latency rises (one round-trip to the HSM per request). Profile under load before committing — the TypeScript and Python SDKs cache JWK metadata aggressively and can sustain hundreds of signs/sec against GCP KMS in our internal tests, but your numbers depend on region and concurrency. ## Step 2: Publish your public keys ### JWKS endpoint Serve a JSON Web Key Set at a stable HTTPS URL (defaults to `/.well-known/jwks.json`): ```json theme={null} { "keys": [ { "kid": "my-agent-2026", "kty": "OKP", "crv": "Ed25519", "x": "", "use": "sig", "key_ops": ["verify"], "adcp_use": "request-signing" } ] } ``` Only public keys go here — no `d` field. Set `Cache-Control: max-age=3600` or similar. If you use separate key material for webhook delivery, publish a second `request-signing` JWK with a distinct `kid`. Deprecated `webhook-signing` JWKs remain accepted on the webhook path for backward compatibility. ### brand.json Serve at `/.well-known/brand.json` on your brand domain. The `jwks_uri` is how verifiers find your keys: ```json theme={null} { "name": "My Company", "domain": "example.com", "agents": [ { "url": "https://agent.example.com", "jwks_uri": "https://agent.example.com/.well-known/jwks.json", "capabilities": ["media-buy"], "adcp_use": ["request-signing"] } ] } ``` ## Step 3: Sign outbound requests (buyer / orchestrator) ### Wrapping fetch / HTTP client `createSigningFetch` wraps any `fetch`-compatible function to sign outbound requests automatically: ```typescript theme={null} import { createSigningFetch } from '@adcp/client/signing'; const privateJwk = JSON.parse(process.env.ADCP_SIGNING_PRIVATE_KEY); const signingFetch = createSigningFetch(fetch, { keyid: 'my-agent-2026', alg: 'ed25519', privateKey: privateJwk, }); // Use signingFetch anywhere you'd use fetch. // Signature, Signature-Input, and Content-Digest headers are added automatically. await signingFetch('https://seller.example.com/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); ``` The Python SDK auto-signs every outbound request when `signing` is configured on `ADCPClient`: ```python theme={null} from adcp import ADCPClient from adcp.signing import SigningConfig, load_private_key_pem private_key = load_private_key_pem(open("private-key.pem", "rb").read()) client = ADCPClient( base_url="https://seller.example.com/mcp", signing=SigningConfig( key_id="my-agent-2026", alg="ed25519", private_key=private_key, cover_content_digest=True, ), ) # Every request the client sends carries Signature, Signature-Input, and # Content-Digest headers. ``` For lower-level control (e.g., signing an arbitrary `httpx.Request` outside the client), call `sign_request` directly: ```python theme={null} from adcp.signing import sign_request signed = sign_request( method="POST", url="https://seller.example.com/mcp", headers={"Content-Type": "application/json"}, body=request_body_bytes, private_key=private_key, key_id="my-agent-2026", alg="ed25519", cover_content_digest=True, ) # signed.as_dict() returns the headers to attach to the outgoing request. ``` The Go SDK exposes a `Signer` you wrap your `http.Client` transport with: ```go theme={null} import ( "net/http" "github.com/adcontextprotocol/adcp-go/adcp/signing" ) priv, _, _ := signing.LoadPrivateKey(pemBytes) signer, _ := signing.NewSigner(signing.SignerOptions{ KeyID: "my-agent-2026", Algorithm: signing.AlgEd25519, PrivateKey: priv, }) client := &http.Client{ Transport: signer.RoundTripper(http.DefaultTransport, true /* cover content-digest */), } req, _ := http.NewRequest("POST", "https://seller.example.com/mcp", body) req.Header.Set("Content-Type", "application/json") resp, _ := client.Do(req) // Signature, Signature-Input, and Content-Digest are added by the transport. ``` For one-off signing without the transport, call `signer.SignRequest(req, signing.SignOptions{CoverContentDigest: true})` directly on the request. ### Capability-aware signing `buildAgentSigningFetch` checks whether the target seller supports `signed-requests` and only signs when supported. This is the recommended approach for production: ```typescript theme={null} import { buildAgentSigningFetch, CapabilityCache } from '@adcp/client/signing/client'; const capabilityCache = new CapabilityCache(); const signingFetch = buildAgentSigningFetch({ upstream: fetch, signing: { kid: 'my-agent-2026', alg: 'ed25519', private_key: privateJwk, agent_url: 'https://agent.example.com', sign_supported: true, }, getCapability: () => capabilityCache.get('https://seller.example.com'), }); ``` This avoids sending signatures to agents that don't expect them and caches capability lookups. ## Step 4: Verify inbound signatures (seller) ### Framework middleware For raw Express routes, mount `createExpressVerifier` after a raw-body middleware. Use `mcpToolNameResolver` for the `resolveOperation` callback — it parses the JSON-RPC envelope and returns the MCP tool name: ```typescript theme={null} import { createExpressVerifier, StaticJwksResolver, InMemoryReplayStore, InMemoryRevocationStore, } from '@adcp/client/signing'; import { mcpToolNameResolver } from '@adcp/client/server'; app.post( '/mcp', rawBodyMiddleware(), // req.rawBody must hold the byte-exact body createExpressVerifier({ capability: { supported: true, covers_content_digest: 'required', required_for: ['create_media_buy', 'update_media_buy'], }, jwks: new StaticJwksResolver(buyerPublicKeys), replayStore: new InMemoryReplayStore(), revocationStore: new InMemoryRevocationStore(), resolveOperation: mcpToolNameResolver, }), handler ); // On verify: req.verifiedSigner = { keyid, agent_url?, verified_at }. // On reject: 401 with WWW-Authenticate: Signature error="". ``` The Python SDK ships framework wrappers for Flask and Starlette/FastAPI. Both call into the same `verify_request_signature` and raise `SignatureVerificationError` on rejection — map that to a 401 with `unauthorized_response_headers`: ```python theme={null} from fastapi import FastAPI, Request, HTTPException from adcp.signing import ( VerifyOptions, VerifierCapability, CachingJwksResolver, InMemoryReplayStore, StaticRevocationChecker, ) from adcp.signing.middleware import ( verify_starlette_request, unauthorized_response_headers, ) from adcp.signing.errors import SignatureVerificationError app = FastAPI() verify_options = VerifyOptions( capability=VerifierCapability( supported=True, covers_content_digest="required", required_for={"create_media_buy", "update_media_buy"}, ), jwks_resolver=CachingJwksResolver(), replay_store=InMemoryReplayStore(), revocation_checker=StaticRevocationChecker(set()), ) @app.post("/mcp") async def mcp(request: Request): try: signer = await verify_starlette_request(request, options=verify_options) except SignatureVerificationError as exc: raise HTTPException( status_code=401, detail=exc.code, headers=unauthorized_response_headers(exc), ) # signer.key_id, signer.agent_url, signer.verified_at available for audit. body = await request.body() return await handle_mcp(body, signer) ``` For Flask: swap `verify_starlette_request` for `verify_flask_request` (sync). For non-Starlette ASGI frameworks, call `verify_request_signature` directly. Mount `signing.Middleware` in your `http.Handler` chain. The middleware verifies inbound signatures, populates a `VerifiedSigner` in the request context on success, and writes a `401` with `WWW-Authenticate: Signature error=""` on failure: ```go theme={null} import ( "net/http" "github.com/adcontextprotocol/adcp-go/adcp/signing" ) resolver := signing.NewCachingJWKSResolver() replay := signing.NewMemoryReplayStore(0 /* default cap */) revocation := signing.NewStaticRevocationSource(nil) mw := signing.Middleware(signing.MiddlewareOptions{ Resolver: resolver, Replay: replay, Revocation: revocation, OperationResolver: signing.DefaultOperationResolver, // /adcp/ ContentDigestPolicy: signing.DigestRequired, RequiredFor: []string{"create_media_buy", "update_media_buy"}, }) http.Handle("/mcp", mw(handler)) // Inside handler: func handler(w http.ResponseWriter, r *http.Request) { v := signing.VerifiedSignerFromContext(r.Context()) if v == nil { // Operation not in RequiredFor and request was unsigned — proceed // with bearer auth or whatever fallback you've configured. } // v.KeyID, v.AgentURL, v.VerifiedAt, v.Algorithm — available for audit. } ``` For MCP servers: replace `signing.DefaultOperationResolver` with a custom resolver that parses the JSON-RPC envelope and returns the MCP tool name (the equivalent of `mcpToolNameResolver` in the TS SDK). ### Composing signature + bearer auth with `requireAuthenticatedOrSigned` `requireAuthenticatedOrSigned` bundles the full composition: presence-gated routing (signature auth when headers present, fallback otherwise) plus `requiredFor` enforcement — unauthenticated requests for signing-required operations get `401 request_signature_required` even when no credentials at all are supplied. ```typescript theme={null} import { serve, verifyApiKey, verifySignatureAsAuthenticator, requireAuthenticatedOrSigned, mcpToolNameResolver, MUTATING_TASKS, } from '@adcp/client/server'; import { BrandJsonJwksResolver, InMemoryReplayStore, InMemoryRevocationStore } from '@adcp/client/signing/server'; serve(createAgent, { authenticate: requireAuthenticatedOrSigned({ signature: verifySignatureAsAuthenticator({ capability: { supported: true, required_for: ['create_media_buy'], covers_content_digest: 'either' }, jwks: new BrandJsonJwksResolver(), replayStore: new InMemoryReplayStore(), revocationStore: new InMemoryRevocationStore(), resolveOperation: mcpToolNameResolver, }), fallback: verifyApiKey({ keys: { 'sk_live_abc': { principal: 'acct_42' } } }), requiredFor: [...MUTATING_TASKS], resolveOperation: mcpToolNameResolver, }), }); ``` `MUTATING_TASKS` is the full list of spend-committing and state-changing operations exported from `@adcp/client/server` — use it rather than maintaining your own list. ### JWKS resolver options | Resolver | Use case | | ----------------------- | -------------------------------------------------------------------------------- | | `StaticJwksResolver` | Fixed set of known buyer keys. Good for dev/testing. | | `HttpsJwksResolver` | Fetches JWKS from a URL with caching and refresh. | | `BrandJsonJwksResolver` | Full discovery chain: brand.json → jwks\_uri → JWKS. Recommended for production. | ## Step 5: Verify inbound webhooks (buyer / orchestrator) When sellers send webhooks, verify the signature to confirm authenticity. The webhook profile uses the same RFC 9421 mechanics as request signing but with `tag="adcp/webhook-signing/v1"` and `Content-Digest` always covered (no opt-out). ```typescript theme={null} import { verifyWebhookSignature, BrandJsonJwksResolver, InMemoryReplayStore, } from '@adcp/client/signing/server'; const jwks = new BrandJsonJwksResolver(); const replayStore = new InMemoryReplayStore(); app.post('/webhook', async (req, res) => { try { await verifyWebhookSignature(req, { jwks, replayStore }); } catch { return res.status(401).json({ error: 'invalid webhook signature' }); } // Process the verified webhook... }); ``` ```python theme={null} from adcp.signing import ( WebhookVerifyOptions, BrandJsonJwksResolver, InMemoryReplayStore, verify_webhook_signature, ) from adcp.signing.errors import SignatureVerificationError webhook_options = WebhookVerifyOptions( jwks_resolver=BrandJsonJwksResolver(), replay_store=InMemoryReplayStore(), ) @app.post("/webhook") async def webhook(request: Request): body = await request.body() try: sender = verify_webhook_signature( method=request.method, url=str(request.url), headers=dict(request.headers), body=body, options=webhook_options, ) except SignatureVerificationError: raise HTTPException(status_code=401, detail="invalid webhook signature") # sender.key_id, sender.agent_url available for audit; process the webhook. ``` ```go theme={null} import ( "github.com/adcontextprotocol/adcp-go/adcp/signing" ) // Mount the same Middleware on your webhook receiver, but configure it for // the webhook profile — adcp_use="request-signing" (deprecated // "webhook-signing" also accepted), Content-Digest required, no required_for // gating (webhooks always carry signatures). webhookMW := signing.Middleware(signing.MiddlewareOptions{ Resolver: signing.NewBrandJSONJWKSResolver(), Replay: signing.NewMemoryReplayStore(0), Revocation: signing.NewStaticRevocationSource(nil), Profile: signing.ProfileWebhookSigning, ContentDigestPolicy: signing.DigestRequired, }) http.Handle("/webhook", webhookMW(webhookHandler)) func webhookHandler(w http.ResponseWriter, r *http.Request) { sender := signing.VerifiedSignerFromContext(r.Context()) // sender.KeyID, sender.AgentURL — process the verified webhook. } ``` ## Step 6: Sign outbound webhooks (seller) Pass a `signerKey` to `createAdcpServer` and the framework signs every outbound webhook automatically: ```typescript theme={null} serve(() => createAdcpServer({ name: 'My Seller', version: '1.0.0', webhooks: { signerKey: { keyid: 'my-seller-webhook-2026', alg: 'ed25519', privateKey: webhookPrivateJwk, }, }, mediaBuy: { /* ... */ }, })); ``` Sign each outbound webhook with `sign_webhook` and attach the returned headers before sending: ```python theme={null} from adcp.signing import sign_webhook, load_private_key_pem import httpx, json private_key = load_private_key_pem(open("webhook-private-key.pem", "rb").read()) async def post_webhook(url: str, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") headers = {"Content-Type": "application/json"} signed = sign_webhook( method="POST", url=url, headers=headers, body=body, private_key=private_key, key_id="my-seller-webhook-2026", alg="ed25519", ) headers.update(signed.as_dict()) # adds Signature, Signature-Input, Content-Digest async with httpx.AsyncClient() as client: await client.post(url, content=body, headers=headers) ``` Configure a `Signer` with `ProfileWebhookSigning` and use it via `SignRequest` or its `RoundTripper`: ```go theme={null} priv, _, _ := signing.LoadPrivateKey(webhookPemBytes) webhookSigner, _ := signing.NewSigner(signing.SignerOptions{ KeyID: "my-seller-webhook-2026", Algorithm: signing.AlgEd25519, PrivateKey: priv, Profile: signing.ProfileWebhookSigning, }) webhookClient := &http.Client{ Transport: webhookSigner.RoundTripper(http.DefaultTransport, true /* always cover content-digest for webhooks */), } // Use webhookClient.Post / .Do to deliver webhooks; signatures are added automatically. ``` Publish the webhook signing public key as a JWK with `"adcp_use": "request-signing"`. The webhook verifier still accepts deprecated `"webhook-signing"` keys for backward compatibility, but new signers should use `request-signing`. If you want independent webhook rotation or blast-radius isolation, publish a separate `request-signing` JWK with a webhook-specific `kid`. ## Step 7: Declare the capability If your seller verifies inbound signatures, declare `signed_requests` (alias `request_signing` in the on-wire schema) in your `get_adcp_capabilities` response so buyers know to sign: ```typescript theme={null} createAdcpServer({ capabilities: { overrides: { signed_requests: { supported: true, required_for: ['create_media_buy', 'update_media_buy'], supported_for: ['sync_creatives', 'sync_audiences'], covers_content_digest: 'either', }, }, }, mediaBuy: { /* ... */ }, }); ``` ```python theme={null} from adcp.server.responses import capabilities_response class MySeller(ADCPHandler): async def get_adcp_capabilities(self, params, context=None): return capabilities_response( ["media_buy"], request_signing={ "supported": True, "required_for": ["create_media_buy", "update_media_buy"], "supported_for": ["sync_creatives", "sync_audiences"], "covers_content_digest": "either", }, ) ``` ```go theme={null} // In your get_adcp_capabilities handler, set the request_signing block on // the response builder: return adcp.CapabilitiesResponse(adcp.CapabilitiesData{ SupportedProtocols: []string{"media-buy"}, RequestSigning: &adcp.RequestSigningCapability{ Supported: true, RequiredFor: []string{"create_media_buy", "update_media_buy"}, SupportedFor: []string{"sync_creatives", "sync_audiences"}, CoversContentDigest: "either", }, }), nil ``` Buyers call `get_adcp_capabilities` and read `request_signing.required_for` and `supported_for` to know which operations you expect them to sign. ## Key rotation The JWKS endpoint supports multiple keys simultaneously for zero-downtime rotation: 1. Generate a new keypair with a new `kid` 2. Add the new public key to JWKS (both old and new are published) 3. Update signing configuration to use the new private key 4. After 24–48 hours, remove the old public key from JWKS For emergency rotation (key compromise), add the old `kid` to `revoked_kids` in your revocation list and rotate to a new key immediately. See [Revocation](/dist/docs/3.1.20/building/by-layer/L1/security#revocation) for the revocation list format. ## Testing ### Conformance vectors The spec ships request-signing test vectors at `compliance/cache/3.0.0/test-vectors/request-signing/` (source at `static/compliance/source/test-vectors/request-signing/`): * **Positive vectors**: valid signed requests your verifier must accept (non-4xx) * **Negative vectors**: invalid requests your verifier must reject with `401` and the correct error code ```bash theme={null} # Debug a single vector adcp signing verify-vector \ --vector compliance/cache/3.0.0/test-vectors/request-signing/positive/001-basic-post.json ``` ### Grade your verifier ```bash theme={null} adcp grade request-signing https://agent.example.com/mcp --auth-token $TOKEN ``` ### Error codes When verification fails, return `401` with `WWW-Authenticate: Signature error=""`: | Code | Meaning | | ----------------------- | ----------------------------------------------- | | `missing_signature` | Signature headers not present when required | | `invalid_signature` | Signature doesn't verify against the public key | | `expired_signature` | Signature timestamp too old | | `replayed_nonce` | Nonce was already used | | `revoked_key` | Key has been revoked | | `unknown_key` | Key ID not found in JWKS | | `unsupported_algorithm` | Algorithm not in allowlist | For the full error code taxonomy, see [Transport error taxonomy](/dist/docs/3.1.20/building/by-layer/L1/security#transport-error-taxonomy). ## Related * [Security: Signed Requests](/dist/docs/3.1.20/building/by-layer/L1/security#signed-requests-transport-layer) — normative spec with verifier checklist, canonicalization rules, and replay dedup sizing * [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks) — webhook setup including signature verification * [Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent) — full compliance validation including signing conformance * [Build an Agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent) — SDK setup and storyboard validation * [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) — HTTP Message Signatures specification # Security Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L1/security AdCP security guide: risk classification for financial operations, webhook HMAC verification, replay prevention, access control, and credential management for production deployments. **Critical for Production Use** AdCP handles financial commitments and potentially sensitive campaign data. Implementations managing real advertising budgets must implement the security controls outlined in this document. **Looking for the *why*?** This page is the normative implementation reference — the rules a compliant agent follows. For the threat model, the layered defense narrative, and a checklist for brand IT and CISOs, see the [Security Model](/dist/docs/3.1.20/building/concepts/security-model). ## Overview AdCP operates in a high-stakes environment where: * **Financial transactions** involve real advertising spend * **Multi-party trust** requires coordination between authenticated agents, publishers, and orchestrators * **Sensitive data** includes first-party signals, pre-launch creatives, and competitive targeting strategies * **Asynchronous operations** span multiple systems and protocols ## Risk Classification ### High-Risk Operations (Financial) These operations commit real advertising budgets: | Operation | Risk | Primary Threat | | ------------------ | ---------------------------------------- | ------------------------------ | | `create_media_buy` | Creates financial commitments | Budget fraud, credential theft | | `update_media_buy` | Modifies budgets and campaign parameters | Unauthorized modifications | **Requirements:** * Short-lived credentials — right-sized to the blast radius of a leaked token. ≤1 hour is a reasonable default for tokens that can commit spend; ≤15 minutes is appropriate for tokens that can commit spend above a material threshold or that cross organizational boundaries. Document and justify the chosen window rather than defaulting to the lowest number. * Request signing for transaction integrity * Multi-factor authentication or approval workflows for large budgets * Full audit trail with immutable logging ### Medium-Risk Operations (Data Access) These operations access sensitive business data: | Operation | Risk | | ------------------------ | ---------------------------------------------- | | `get_media_buy_delivery` | Exposes performance metrics and spend data | | `list_creatives` | Access to creative assets | | `sync_creatives` | Uploads potentially sensitive creative content | ### Low-Risk Operations (Discovery) These operations are publicly accessible: | Operation | Risk | | ----------------------- | -------------------------- | | `get_adcp_capabilities` | Agent capability discovery | | `get_products` | Public inventory discovery | | `list_creative_formats` | Public format catalog | ## Webhook Security AdCP 3.0 unifies webhook signing on the [AdCP RFC 9421 profile](#webhook-callbacks) — the seller signs outbound webhooks with a key published through its operator `brand.json` `agents[].jwks_uri`, and the buyer verifies against that JWKS. When the publisher's `adagents.json` pins `signing_keys[]` for that seller, the pin is authoritative. Nothing secret crosses the wire; identity is cryptographically established the same way it is for inbound requests. **9421 webhook signing is baseline-required in 3.0.** Any seller that emits webhooks MUST sign them per the [Webhook callbacks](#webhook-callbacks) profile unless the buyer explicitly opts into the legacy scheme below by populating `push_notification_config.authentication` or `accounts[].notification_configs[].authentication`. ### Legacy HMAC-SHA256 fallback (deprecated, removed in 4.0) Buyers who need to interoperate with receivers that have not yet adopted the 9421 profile MAY opt in by populating `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`. When `authentication` is present on the buyer's request, the seller signs with HMAC-SHA256 using the semantics defined in [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks#legacy-hmac-sha256-fallback). The legacy scheme is a 3.x-only compatibility affordance; sellers MAY decline to support it, and it is removed in AdCP 4.0. Normative rules for the legacy scheme when a seller elects to support it: * **Algorithm**: HMAC-SHA256 only * **Signed message**: `{unix_timestamp}.{raw_http_body_bytes}` — never re-serialize the JSON * **Byte-equality invariant**: The HMAC is computed over raw bytes, not over a parsed JSON value. Signers and verifiers MUST compare the bytes on the wire directly; re-parsing and re-serializing a payload — even with matching libraries and compact separators — is not guaranteed to reproduce the signed bytes, because key ordering, unicode-escape policy, and number representation all diverge across serializers (see "Non-canonicalized aspects" below for concrete examples). This scheme does not define a canonical JSON form; the "Canonical on-wire form" and "Verifier input" rules below narrow the most common byte-drift failures on the signer and verifier sides respectively, but do not eliminate byte-level divergence. * **Canonical on-wire form**: The `{raw_http_body_bytes}` MUST be byte-identical to the bytes the signer puts on the wire as the HTTP body. When the signer constructs the body by serializing a JSON value, it MUST use the JSON compact separators `","` (item separator) and `":"` (key separator) — no whitespace between tokens. The language-level serializers JavaScript `JSON.stringify`, Go `encoding/json` `json.Marshal`, Ruby `JSON.generate`, and Java Jackson `writeValueAsString` produce compact output by default; HTTP clients that wrap them (axios, Go `net/http` with a `json.Marshal`-ed body, Ruby `Net::HTTP` with `JSON.generate`, Java OkHttp with Jackson) inherit those defaults. In Python, `httpx` serializes with compact separators, but stdlib `json.dumps` defaults to `", "` / `": "` and HTTP clients that hand their payload to `json.dumps` without a `separators` kwarg (`requests(json=...)`, `aiohttp`) emit spaced bodies — signers on those paths MUST pass `separators=(",", ":")` explicitly. This enumeration is non-exhaustive; signers MUST verify their HTTP client's actual on-wire serialization (e.g., capture the request body via a proxy or hook) rather than rely on this list. The signature covers the bytes the receiver sees, not the object the signer serialized. * **Non-canonicalized aspects**: Key ordering, unicode-escape policy, and number representation are NOT canonicalized by this scheme. For numbers in particular, language defaults diverge (`JSON.stringify(1.0)` → `1`, Python `json.dumps(1.0)` → `1.0`, Go `json.Marshal(1.0)` → `1`; floats like `0.1` and scientific notation hit similar cliffs), so a signer that serializes with one library and then re-parses / re-serializes with another before sending can produce signer-verifier drift even with compact separators — the byte-equality invariant above is the only thing that holds the scheme together. * **Duplicate object keys**: Signers MUST NOT emit duplicate object keys AND MUST reject duplicate-key input from upstream callers before serialization. The signer-side MUST is load-bearing because it is the only place this failure mode can be caught: a signer that silently collapses a duplicate-key payload emits a cryptographically-clean signed frame whose semantics differ from the caller's intent, and the verifier cannot detect the upstream divergence from the wire — the signed bytes look normal. Signer-side conformance is unverifiable on the wire and is expected to be enforced by out-of-band audit / interop testing, not runtime detection (this shape is routine in signing specs; COSE and JOSE use the same pattern). Verifiers MUST reject bodies containing duplicate object keys after HMAC verification succeeds, returning a structured malformed-body error (distinct from a signature-mismatch error — the signature IS valid; the body is malformed). Per RFC 8259 §4, the names within a JSON object "SHOULD be unique" and the behavior of software that receives an object with non-unique names is unpredictable — so two verifiers parsing the same HMAC-valid bytes can disagree on the parsed value. This is a parser-differential attack class (cf. CVE-2017-12635 where one CouchDB parser read `roles=[]` and another read `roles=["_admin"]` from the same signed body). Every body carried on the legacy HMAC webhook scheme is a state-change notification (creative status, media-buy status, governance transitions), so the MUST applies unconditionally to this scheme. The detection MUST use a parser that exposes duplicate keys — a last-wins/first-wins default that silently discards them does not satisfy this requirement. Per-language strict-parse escape hatches for both signer input-validation and verifier body-checking: see [step 14 of the webhook verifier checklist](#webhook-callbacks) for the canonical non-exhaustive enumeration, including the libraries that only *appear* strict by default but silently collapse data-key duplicates. The verifier-side conformance fixture is `duplicate-keys-conflicting-values` in `static/test-vectors/webhook-hmac-sha256.json`, with `expected_verifier_action: "reject-malformed"`. Signer-side conformance fixtures live in the same file under `signer_side.rejection_vectors`: `signer-upstream-duplicate-key-rejection` (top-level), `signer-upstream-duplicate-key-deep-nested` (verifies the signer's check recurses into nested objects, not only top-level keys), `signer-upstream-duplicate-key-array-contained` (verifies the signer's check descends into objects inside arrays — a blind spot in hand-rolled validators that recurse into objects but not array members), and `signer-upstream-duplicate-key-three-deep` (verifies the walker does not halt at a shallow fixed depth). A positive-case fixture `signer-upstream-clean-input` lives under `signer_side.positive_vectors` so that a signer rejecting everything does not trivially pass the negative fixtures — interop harnesses MUST assert both rejection of the duplicate-key inputs and acceptance of the clean input. Signers that surface upstream-input rejections via logs or error responses MUST apply the same key-name sanitization rules defined in [step 14b of the webhook verifier checklist](#webhook-callbacks) (truncate at first non-printable to ``, truncate to last UTF-8 codepoint at or below 32 bytes, cap count at 4) — the signer-side channel has the same attacker-controlled-byte shape as the verifier-side channel, just with the direction of trust inverted. **Error identifier is normative; error-object internals are not.** When a signer surfaces the rejection via an error, the error identifier (error-code string in a discriminated union, exception class name in typed-throw idioms, tag in a sum type) MUST be `duplicate_key_input` exactly — case-sensitive, no prefix or suffix — so that multi-SDK integrations can write `if (error.code === 'duplicate_key_input') { ... }` and have the dispatch work regardless of which SDK signed the frame. The internal shape of the error carrier (field names for the sanitized key list, overflow-marker string, typed-exception constructor arguments) is implementation-defined. Verifiers that crash / fail-closed are conformant-but-suboptimal (the request is not silently accepted, but senders receive no actionable error code); verifiers SHOULD return a structured malformed-body error instead. The non-conformant failure mode — silent accept where the signature verifier's parse diverges from the downstream business-logic parse — is now forbidden; a verifier that does not detect duplicate keys before handing the payload to business logic does not conform to this scheme. * **Verifier input**: Verifiers MUST use the raw HTTP body bytes as received on the wire, captured before any JSON parse or re-serialize. Every modern HTTP framework exposes a pre-parse raw-body hook (Express `express.raw()`, FastAPI `Request.body()`, aiohttp `Request.read()`, Go `io.ReadAll(r.Body)` before `json.Unmarshal`). The raw-capture hook MUST run before any JSON-parse middleware on the same route; a globally-mounted `express.json()` or FastAPI `BaseModel` body binding that consumes the request body before the verifier runs leaves the verifier operating on a re-stringified payload, not the signed bytes — this is a common deployment mistake. Verifiers SHOULD NOT re-serialize a parsed payload to reconstruct the signed bytes: re-serialization silently fails against signers whose output differs in key order, unicode escapes, or number formatting, and masks signer bugs the verifier should surface. A verifier that genuinely cannot capture raw bytes MUST fail closed and surface the infrastructure gap rather than accept a re-serialized approximation. * **Timestamp source**: The `{unix_timestamp}` in the signed message MUST be the exact ASCII integer sent in the `X-ADCP-Timestamp` header. Signers and verifiers MUST NOT derive it from any body field. * **Timing-safe comparison**: MUST use constant-time comparison (e.g., `timingSafeEqual`) * **Replay window**: Reject requests where `|current_time - timestamp| > 300` seconds * **Minimum secret length**: 32 bytes * **Header format**: `X-ADCP-Signature: sha256=` and `X-ADCP-Timestamp: `. Any body-level `signature` field is a convenience copy and MUST NOT be trusted over the headers. **Verification order** (legacy scheme): 1. Reject if `X-ADCP-Signature` or `X-ADCP-Timestamp` header is missing 2. Reject if timestamp is non-numeric 3. Reject if timestamp is outside the 5-minute window 4. Compute and compare HMAC **Secret rotation** (legacy scheme): * Receivers MUST accept signatures from both current and previous secret during rotation * Rotation window SHOULD NOT exceed the replay window (5 minutes) * Publishers begin signing with the new secret immediately upon rotation ### Webhook URL validation (SSRF) Any URL that a buyer, seller, or governance agent provides for another party to fetch is an SSRF vector. This includes `push_notification_config.url`, `accounts[].notification_configs[].url`, `accounts[].governance_agents[].url` (the seller fetches this when it calls `check_governance`), collection-list `webhook_url`, TMP provider `endpoint`, `adagents.json` `authoritative_location`, and `reporting_bucket.setup_instructions`. Account-level webhook subscribers registered through `sync_accounts.accounts[].notification_configs[]` also require endpoint ownership proof before activation. SSRF validation proves the seller is not calling an internal network address; it does not prove the buyer controls the public HTTPS endpoint. Sellers MUST complete an RFC 9421-signed activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active, and the receiver MUST verify the seller identity, delivery auth metadata, and event type set before echoing the challenge. Paused subscribers (`active: false`) may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and paused subscribers MUST NOT receive fires until reactivated. The standard challenge payload and response shape are defined in [sync\_accounts endpoint proof of control](/dist/docs/3.1.20/accounts/tasks/sync_accounts#endpoint-proof-of-control). Before any outbound fetch to a counterparty-controlled URL, fetchers MUST: 1. **Reject non-HTTPS URLs** in production. 2. **Resolve the hostname** and reject the fetch if the resolved IP falls in any reserved range: * IPv4: RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), RFC 6598 CGNAT (`100.64.0.0/10`), loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16` — explicitly includes `169.254.169.254` used by AWS/GCP/Azure/Alibaba instance metadata), broadcast (`255.255.255.255`), `0.0.0.0/8`, multicast (`224.0.0.0/4`). * IPv6: loopback (`::1`), unique-local (`fc00::/7`), link-local (`fe80::/10`), IPv4-mapped (`::ffff:0:0/96` — the most common bypass, mapping reserved IPv4 into IPv6), multicast (`ff00::/8`), and the AWS IMDSv2 fd00:ec2::254 address. 3. **Pin the connection to the validated IP.** DNS-based filtering alone is vulnerable to DNS rebinding: an attacker serves a public IP at validation time and a private IP at connect time. Fetchers MUST pin the connection. **Preferred**: (a) pass the validated IP directly to the TCP connect call and set the `Host:` header from the URL. **Fallback** (only when the HTTP client cannot accept a pre-resolved IP): (b) validate the socket's post-handshake peer address against the reserved-range list before sending any request body. Note: (b) depends on the client library exposing a peer-address hook that fires before the first body byte ships; many common libraries do not, so implementations choosing (b) MUST verify the hook in testing. Re-resolving DNS without pinning is not sufficient. 4. **Refuse to follow redirects** when fetching counterparty-controlled URLs (a 30x response lets the origin redirect to a reserved address that bypassed the initial check). Two bounded exceptions, each re-validating steps 1–3 on every hop and capping the chain: the [brand.json resolution](#buyer-identity-resolution) (one redirect, no chains), and the initial `/.well-known/adagents.json` fetch, which follows **same-registrable-domain** redirects only (`apex ↔ www`, HTTPS-preserving, ≤ 3 hops, anchored on the originally-requested domain) so standard apex→www hosting resolves — see [managed networks](/dist/docs/3.1.20/governance/property/managed-networks#why-not-http-redirects). The `adagents.json` `authoritative_location` dereference takes no exception: redirects on that second hop MUST be refused. 5. **Cap response size and timeouts.** Recommended: 5 MB body cap, 10 s connect, 10 s read. The only exception is the dereferenced authoritative file in the managed-network indirection pattern — second-hop only, after a pointer file's `authoritative_location` redirects to the network origin — which uses a recommended 20 MB cap because it fans out across a publisher network. Pointer files themselves stay at 5 MB. See [managed networks security](/dist/docs/3.1.20/governance/property/managed-networks#security-considerations). 6. **Do not echo fetch errors to the agent that supplied the URL.** Detailed error messages (connection refused vs. timed out vs. TLS failure) are a side-channel for probing internal network topology. #### Destination port: permissive by default Publishers SHOULD NOT enforce a destination-port allowlist on counterparty-supplied URLs (`push_notification_config.url`, collection-list `webhook_url`, TMP provider `endpoint`, etc.) by default. The URL contract is `format: "uri"` only; the protocol does not constrain ports. Buyers legitimately host webhook receivers on non-standard TLS ports — Tomcat default `:9443`, Spring Boot default `:4443`, path-routed multi-tenant gateways, and per-tenant subdomains-with-port carve-outs — and a default port allowlist silently rejects them with no recourse short of asking the publisher operator to widen the list. The SSRF guard the protocol relies on is the **IP-range check + DNS-rebinding-resistant connect pin** in steps 2–3 above, not port filtering. The reserved-range check covers the realistic SSRF threat (smuggling traffic to internal services on `10.0.0.0/8`, `127.0.0.0/8`, `169.254.169.254`, etc.); port filtering on top of a routable public IP is a marginal defense whose cost (rejecting conformant buyers) typically exceeds its benefit. Operators who want a destination-port allowlist as defense-in-depth — for example, locked-down enterprise environments where the publisher's egress firewall already restricts outbound ports — SHOULD opt in explicitly via SDK or deployment configuration, with `{443, 8443}` as a reasonable hardened-mode starting point. SDKs that ship a `DEFAULT_ALLOWED_PORTS` constant MUST default it to "no restriction" and surface `{443, 8443}` as an opt-in profile, never as a default. Sellers that activate hardened mode MUST document the allowed-port set in their operator-facing documentation so buyers can size their integration before discovering the constraint at first-webhook-delivery time. The wire-level URL contract is **unconstrained beyond `format: "uri"`**; hardened-mode port filtering is an operator-side policy choice, not a protocol-side requirement. Feature-specific security sections extend these rules with their own lifecycle and content-handling requirements: * [Offline reporting buckets](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting#security-considerations-for-offline-delivery) — IAM-layer prefix scoping, credential revocation on account status change. * [Collection lists](/dist/docs/3.1.20/governance/collection/tasks/collection_lists#security-considerations) — `auth_token` scope and revocation, distribution-ID validation, webhook signature normative rules. * [Managed networks `authoritative_location`](/dist/docs/3.1.20/governance/property/managed-networks#security-considerations) — validator fetch semantics, change detection, relationship termination. * [TMP provider registration](/dist/docs/3.1.20/trusted-match/specification#provider-registration-security) — dynamic registration authentication, router-to-provider auth, `/health` info-leakage rules. ## Authentication Best Practices ### Credential Storage ```javascript theme={null} // Use secure key management systems // Never commit credentials to version control // Use environment variables or secret managers // Example: Secure credential retrieval async function getCredentials(agentId) { // Retrieve from secure storage (AWS KMS, Vault, etc.) const encrypted = await secretManager.get(`agent/${agentId}/apiKey`); return decrypt(encrypted); } ``` ### Token Expiration Use short-lived tokens for high-risk operations: ```javascript theme={null} const TOKEN_LIFETIMES = { discovery: 3600, // 1 hour for read operations financial: 900, // 15 minutes for financial operations refresh: 86400 // 24 hours for refresh tokens }; function validateToken(token, operationType) { const decoded = jwt.verify(token, secret); const maxAge = TOKEN_LIFETIMES[operationType] || TOKEN_LIFETIMES.discovery; if (Date.now() - decoded.iat > maxAge * 1000) { throw new Error('Token expired for this operation type'); } return decoded; } ``` ## Agent and Account Isolation Every piece of state — media buys, creatives, idempotency cache entries, session IDs, governance tokens — is scoped to the [account](/dist/docs/3.1.20/reference/glossary#a) that owns it. Cross-account reads MUST return a generic "not found" rather than leak existence. The authenticated [agent](/dist/docs/3.1.20/reference/glossary#a) is how the seller knows *who is calling*; the `account` on the request is *what billing relationship the call is acting on*. Isolation requires both checks. Sales agents MUST: 1. **Bind on create** — permanently associate each object (media buy, creative, session, etc.) with the account used on the request that created it. 2. **Verify on access** — on every subsequent read or modification, verify the authenticated agent has access to the object's bound account. 3. **Fail closed** — when verification fails, return a generic error (status 403 or 404 is acceptable, but the body MUST NOT distinguish "unauthorized" from "not found" or name the account). Never fall through to the resource query. See [Accounts & Security — Data Isolation](/dist/docs/3.1.20/media-buy/advanced-topics/accounts-and-security#data-isolation) for the billing-relationship model these rules enforce, and the glossary for the formal definitions of [Account](/dist/docs/3.1.20/reference/glossary#a) and [Agent](/dist/docs/3.1.20/reference/glossary#a). ### The two-step pattern Every account-scoped request whose schema requires `account` carries an explicit `AccountRef` (via `account_id` for account-id namespaces, or the `{brand, operator}` natural key for buyer-declared accounts). A seller MUST NOT silently replace a missing required `account` with a credential-implied default. For tasks where `account` is optional, omission semantics are task-local and must be documented by that task. Correct isolation is two checks, performed in order: 1. **Auth precheck** — the request's `account` MUST be in the authenticated agent's authorized set. Fail closed with a 403 or a generic "not found" (never "you are not authorized for that account" — that's an existence leak). 2. **Resource query** — filter by the request's `account_id` as the primary key constraint. Not by the whole authorized set — only by the specific account this request is acting on. ```javascript theme={null} // Two-step: precheck request account is authorized, then scope the query to it. // authorizedAccountIds is a Set populated once at auth-time, not an Array. // Set.has() is O(1); Array.includes() is O(n) and scans element-by-element, which // on large authorized-account sets introduces a timing difference between early // and late matches that a caller can probe across requests. async function getMediaBuy(mediaBuyId, requestAccountId, authAgent) { // Step 1: auth precheck if (!authAgent.authorizedAccountIds.has(requestAccountId)) { // Generic error - don't reveal whether the account exists throw new NotFoundError("Media buy not found"); } // Step 2: resource query scoped to the specific account const mediaBuy = await db.mediaBuys.findOne({ id: mediaBuyId, account_id: requestAccountId // Primary filter }); if (!mediaBuy) { // Generic error - same shape as the precheck failure throw new NotFoundError("Media buy not found"); } return mediaBuy; } ``` Filtering by the *whole* authorized set on a by-ID lookup is a regression: a `get_media_buy(X)` issued under account A would succeed for a buy owned by account B if both are in the agent's authorized set. The request-supplied `account_id` is what ties a lookup to the caller's *stated* intent. ### Row-Level Security The most common isolation failure is **IDOR via joined or nested relations**: a query scopes the primary table by `account_id` but joins or returns fields from a related table (line items, creatives, delivery rows) that was never filtered by the same principal. Defend per-principal at the data layer, not just in handler code, so a bug in one handler cannot punch through the wall: ```sql theme={null} -- PostgreSQL example -- app.current_account is set by the auth layer AFTER the precheck above succeeds CREATE POLICY account_isolation ON media_buys USING (account_id = current_setting('app.current_account')::uuid); ALTER TABLE media_buys ENABLE ROW LEVEL SECURITY; ``` For **list endpoints** (`get_media_buys` without an explicit account filter), RLS scopes to the agent's authorized set via a session variable populated at auth time: ```sql theme={null} CREATE POLICY account_isolation_list ON media_buys FOR SELECT USING (account_id = ANY(current_setting('app.authorized_accounts')::uuid[])); ``` ### Client-side isolation: cross-principal tool-call confusion The rules above are server-side enforcement. They protect the seller's data even when a legitimate-but-compromised agent is the caller. The **client-side companion** is the buyer agent's obligation not to let text supplied by principal X drive tool calls that use principal Y's authority. An LLM-driven buyer agent typically holds credentials for multiple principals at once: several sellers (one credential set per seller) and, inside an agency agent, several brand accounts. Any untrusted string the agent processes — product descriptions returned by a seller, campaign names inherited from a brief, rejection reasons in an error envelope, webhook event bodies — is text sourced from *one* of those principals. If the agent's planning loop can call tools across all of them from a single LLM context, a prompt injected in seller X's text can cause the agent to call `create_media_buy` on seller Y's endpoint, or to spend brand A's budget on brand B's inventory. This is the [confused-deputy](https://en.wikipedia.org/wiki/Confused_deputy_problem) problem at tool-call granularity: the attacker doesn't need to escape the sandbox — the agent's own legitimate authority does the damage. Operators running LLM-powered AdCP agents MUST apply at least the following controls: 1. **Tag text with its principal of origin.** Every string the LLM context ingests from the network (tool results, webhook bodies, registry documents, creative metadata) MUST be annotated internally with the `{principal_domain, tool_name, response_field}` triple that produced it. Dropping the annotation at ingest time is where this defense dies. 2. **Restrict tool-call targets to the calling principal.** A tool call whose target principal is not the same as the principal that supplied the string(s) driving the decision MUST either (a) be refused, (b) go through a human approval step, or (c) be mediated by an explicit per-principal policy the operator has declared up front. The default MUST be refuse, not allow. 3. **Segregate credential scopes by LLM context.** A single LLM planning loop MUST NOT hold live credentials for principals whose interests can conflict (e.g., two brands competing for the same inventory; a buyer credential and a governance agent's signing key in one context). The scope-segregation is enforced at the process / tool-registration layer, not by instructing the LLM — the LLM MUST NOT have the affordance to misuse. 4. **Log every cross-principal *attempt*, not just successes.** Refusals under rule 2 are the signal operators MUST monitor — a rising refusal rate from a given principal is the earliest detectable sign of an injection campaign targeting your agent. This threat is distinct from ordinary prompt injection: ordinary injection exfiltrates data or triggers unauthorized tool calls within *one* principal's authority. Cross-principal confusion uses principal X's untrusted text to reach principal Y's authority without the attacker ever holding Y's credentials. The server-side Layer 2 controls above detect the attempt only if principal Y's account isn't already in the buyer agent's authorized set — when it is (the whole point of agency and multi-seller agents), the server sees a legitimate-looking call. The protocol cannot force this discipline on the client agent. The test for it is operational: every LLM-powered AdCP buyer MUST be able to describe, in writing, which principals can appear together in the same planning context and what gates a cross-principal tool call. ## Time Semantics AdCP operates across jurisdictions, ad servers, and daypart calendars. Implementations MUST be precise about time or buyers and sellers will disagree about what "delivered by 5pm" meant. ### Timestamp format All timestamp fields in AdCP requests, responses, and webhook payloads MUST be [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) with an explicit timezone offset. ``` ✅ 2026-04-19T10:00:00Z // UTC, recommended ✅ 2026-04-19T10:00:00-04:00 // explicit offset ❌ 2026-04-19T10:00:00 // no offset — ambiguous ❌ 2026-04-19 10:00:00 // not ISO 8601 ``` Implementations MUST reject ambiguous ("naïve") timestamps with `INVALID_REQUEST`. Implementations SHOULD use UTC (`Z` suffix) on the wire and convert to local time at the presentation layer. ### Intervals Any time window in AdCP — flight dates, reporting windows, daypart targeting, idempotency replay TTLs — uses a **half-open interval**: `[start, end)`. The start timestamp is inclusive; the end timestamp is exclusive. A campaign with `start_time: 2026-04-01T00:00:00Z` and `end_time: 2026-05-01T00:00:00Z` runs for April and stops at the first tick of May. ### Daypart targeting Daypart definitions MUST declare their **timezone semantics** — which of the three meanings the time values carry: * **Buyer-declared zone** — an IANA zone name alongside the daypart (e.g., `timezone: "America/New_York"`). The daypart is evaluated against that zone regardless of viewer or publisher location. Use this when the buyer wants "9–11pm New York time" enforced globally. * **Publisher-local** — the daypart is evaluated in the publisher's declared local zone. Use this when the buyer wants "prime time on the publisher's schedule" and is willing to let the publisher decide what that means. * **Viewer-local** — the daypart is evaluated against each viewer's timezone, resolved at serve time from the viewer's location signal. Use this when the buyer wants "serve at 8pm local" across a global audience. A daypart with no declared semantics is ambiguous and MUST be rejected with `INVALID_REQUEST`. Sellers MUST honor the declared semantics; if a seller cannot support the requested mode (e.g., a publisher operating in a single zone cannot serve viewer-local dayparting), the seller MUST reject with `INVALID_REQUEST` rather than silently converting. Per-agent defaults are non-normative and MUST NOT be relied on. ## Request Safety ### Idempotency `idempotency_key` is **required on every AdCP task request** — read and mutating alike. Keys are scoped per `(authenticated agent, account)` — they have no meaning across agents on the same seller, across accounts under the same agent, or across sellers. Scoping by both dimensions prevents cross-account cache collisions when one agent (e.g. an agency) acts on multiple accounts: an identical-looking `create_media_buy` under account A and account B is two distinct buys, never one cached response replayed across the two. **Enforcement curve.** Sellers MUST reject any **mutating** request that omits `idempotency_key` with `INVALID_REQUEST` from 3.0 onward (unchanged). For **read** requests, the rule phases in across two minors: * **3.1.0** — sellers MUST accept reads that carry `idempotency_key` and process per rules 2–9 (no rejecting on undeclared envelope fields). Sellers SHOULD reject reads that omit it with `INVALID_REQUEST`; sellers MAY accept the omission for the 3.1.x maintenance window. * **3.2.0** — sellers MUST reject reads that omit `idempotency_key` with `INVALID_REQUEST`. The grace window closes at the 3.2 cut. This staged enforcement lets hand-rolled buyer integrations — built via curl, thin MCP clients, or OpenAPI codegen that doesn't include the field uniformly — migrate over a release window rather than at the 3.1 cut. Buyer SDKs (`@adcp/client`, `adcp-py`) already send `idempotency_key` uniformly today, so SDK-using integrators are unaffected by the cut date. **Why universal — including read tools.** Several AdCP tasks are polymorphic. `get_products` is the canonical case: `buying_mode: 'brief'` / `'wholesale'` may complete synchronously (pure read), but the same tool MAY return a `Submitted` envelope when curation requires upstream queries or HITL, and `buying_mode: 'refine'` with `action: 'finalize'` is a commit that transitions a proposal to committed with an `expires_at` hold window (see [refinement guide § Finalize is exclusive](/dist/docs/3.1.20/media-buy/product-discovery/refinement)). Buyers cannot predict at call time whether a given call will be a pure read, an async-task creation, or a commit — so the wire contract requires `idempotency_key` on every call uniformly. For calls that resolve as pure reads, the cache provides byte-stable replay-on-retry within the TTL, which is harmless and gives buyers a uniform retry-safe contract; for calls that resolve as async-task creation or commit, the cache provides the same at-most-once guarantees as on mutating tasks. The alternative — classifying per-call read-vs-mutating in the buyer's SDK — is not feasible when the same task name has both read and write modes. Decoding unknown `error.code` values returned by sellers (whether `INVALID_REQUEST` during the grace window or codes added in later minors) follows the [Forward-compatible decoding](/dist/docs/3.1.20/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) rule. This section applies only to AdCP task requests. OpenRTB bid streams have their own semantics (`BidRequest.id` is a transaction ID, not an idempotency key) and are out of scope. #### Normative seller behavior 1. **Schema validation runs first.** Sellers MUST validate the request against its schema (including presence and format of `idempotency_key`) BEFORE consulting the idempotency cache. A malformed request returns `INVALID_REQUEST` without ever touching the cache — otherwise cache misses become a timing side channel that leaks whether schema validation accepted the key format. Validation errors are never cached (per rule 2). 2. **First call is canonical.** On **task success** (`status: completed` or `status: submitted` for async operations), the seller stores the inner response payload (not the protocol envelope) keyed by `(authenticated_agent, account_id, idempotency_key)` along with a hash of the canonical request payload. **The cache entry is immutable** — replays within the TTL MUST return the originally-cached payload (with `replayed: true`), and state-tracking fields in that payload MUST NOT be refreshed to reflect the resource's current state. This rule applies across both success branches: * **Async tasks** — the cached response is the `submitted` result containing `task_id`. Even if the async task subsequently completes, fails, or is canceled, a replay MUST return the originally-cached `submitted` response, NOT the current terminal state. The buyer uses the returned `task_id` to observe current state via `tasks/get` or webhook, exactly as it would have on the first call. * **Synchronous-success tasks** — when the initial response carries state-tracking fields (e.g., `status`, `packages`, `affected_packages` on `create_media_buy`; per-record `status` arrays on `sync_creatives` / `sync_accounts`; resource snapshots on `acquire_rights` / `activate_signal`), replay MUST return the originally-cached payload regardless of intervening mutations to the resource. A media buy that was created with `status: pending_creatives`, then mutated to `canceled` via `update_media_buy`, replays as `status: pending_creatives` — the cached bytes are a historical snapshot of the create-time response, not a current-state read. Buyers MUST consult the resource's read endpoint (`get_media_buys`, `list_accounts`, `list_creatives`, etc.) for current state; see "Buyer obligations" below. This preserves the byte-stable cache property uniformly and keeps the idempotency layer decoupled from resource lifecycle — sellers don't need to update cache entries when task or resource state changes. The alternative ("refresh state fields on replay") would force every seller to thread the resource state machine through the idempotency cache, multiply the number of valid cache contents for a given key (a single key's replay would no longer be deterministic across calls), and break the canonical-replay invariant the rest of these rules build on. Sellers MUST NOT implement a hybrid where some state-tracking fields refresh on replay and others do not — partial refresh is the worst of both options and is non-conformant. 3. **Only successful responses are cached.** On any error — validation, governance denial, transport failure, internal error — the key is **not** stored. A retry re-executes. This matches buyer intent: a retry after a 5xx should try again, not replay a failure. It also prevents a buyer's malformed request from being locked into a key for its full TTL. 4. **Replay returns the cached response.** A subsequent request with the same `idempotency_key` AND an equivalent canonical-form payload (see "Payload equivalence" below) MUST return the stored inner response without re-executing side effects. The seller injects `replayed: true` onto the outgoing protocol envelope at response time — `replayed` is an envelope-level field produced by the idempotency layer, NOT part of the cached inner response. Injection at replay time keeps the cached payload byte-stable across replays regardless of envelope changes (new `timestamp`, rotated `governance_context`, etc.). Transport-specific note for MCP: MCP tool responses do not have a separate envelope slot; servers MAY expose `replayed` inside the tool result object itself (e.g., at the top of the structured return) or via a response metadata field. REST and A2A responses use the envelope field directly. 5. **Key reuse with a different canonical payload is a conflict.** Same key, different canonical hash within the replay window MUST be rejected with `IDEMPOTENCY_CONFLICT`. Sellers MUST NOT silently apply the second request. 6. **Expired keys are rejected explicitly.** After `replay_ttl_seconds` elapses the seller MAY evict the cache entry. A request arriving after eviction with a key the seller has seen SHOULD be rejected with `IDEMPOTENCY_EXPIRED` rather than silently treated as new — silent re-execution is exactly the double-booking footgun the key was meant to prevent. Sellers SHOULD allow a ±60s clock-skew window at the TTL boundary (the same tolerance applied to JWS `exp` elsewhere in this document) so that a retry arriving seconds after nominal expiry is still replayed from cache rather than treated as fresh. **Durability is normative.** The declared `replay_ttl_seconds` is a durability contract, not a best-effort cache hint. Sellers MUST back the idempotency cache with storage that survives process restarts, pod replacements, region failovers, and operator-initiated cache flushes for the declared TTL. In-memory-only stores (plain `Map`, single-process LRU without a backing tier) are non-conformant whenever `replay_ttl_seconds` exceeds process lifetime — which is always true at the 3600 s floor. The consequence of silent eviction below declared TTL is a **displaced-replay window**: the sender legitimately retries with the same `idempotency_key` under a fresh signature nonce (which is how a signed retry is supposed to work — nonces are per-send, not per-event), passes the signature replay check, and finds the app-layer cache empty because the receiver's in-memory state was dropped. The side effect runs twice. Sellers MUST NOT declare a `replay_ttl_seconds` higher than their cache tier can durably honor, and MUST fail-closed (`IDEMPOTENCY_EXPIRED`) rather than fail-open (silent re-execution) when they cannot distinguish "never seen" from "evicted under declared TTL." A seller whose operational reality is "memory-only, lost on pod restart" is required to declare `replay_ttl_seconds` no higher than the shortest guaranteed pod lifetime — in practice, this forces a durable tier. 7. **Replay window is declared, not inferred.** Sellers MUST declare `capabilities.idempotency.replay_ttl_seconds` on `get_adcp_capabilities` (minimum 3600s / 1h, recommended 86400s / 24h, maximum 604800s / 7d). Clients MUST NOT fall back to an assumed default — a seller with no declaration is non-compliant and MUST be treated as unsafe for retry-sensitive operations. 8. **Cache-growth defense.** Sellers MUST apply per-`(authenticated_agent, account)` rate limits on idempotency cache inserts separately from request rate limits, and MUST return `RATE_LIMITED` (see [error taxonomy](/dist/docs/3.1.20/building/by-layer/L3/error-handling#rate-limit-handling)) when the per-agent insert rate exceeds the configured ceiling rather than let the cache grow unbounded. A buyer submitting N fresh keys per second on a cheap success-path operation (e.g., `log_event`) would otherwise force unbounded storage, with amplification proportional to `replay_ttl_seconds` at the 3600 s floor. The natural bound is `inserts_per_hour × replay_ttl_hours ≤ max_cache_rows_per_agent`. **Recommended ceilings (3.1+):** the original 60/sec sustained / 300/sec burst single-budget ceiling was sized against a write-heavy launch pattern (≤10 media buys/min × 10 packages × 10 creatives with 3–5× headroom). Under universal idempotency, read traffic also contributes to insert rate — a single agentic dashboard polling `get_products(brief)` + `list_creatives` + `list_accounts` across 5 accounts at 1Hz is \~15 inserts/sec on reads alone, before any write activity. Operators SHOULD adopt a **split budget** per `(authenticated_agent, account)`: * **Reads: 300 inserts/sec sustained, 1,500/sec burst over rolling 10s windows.** Dominated by dashboard polling and agentic state re-reads under the [Polling / state re-read](#agent-retry-vs-polling-vs-re-plan) rule. Read traffic is typically bursty during user-driven UI interactions and steady at low rates during agent runs. * **Writes: 60 inserts/sec sustained, 300/sec burst.** Unchanged from the original write-heavy sizing — preserved as a separate budget so a buyer's dashboard polling can't exhaust the write capacity that protects `create_media_buy` / `sync_creatives` / `activate_signal` from double-execution races. * **Combined cap (defense in depth):** total inserts SHOULD NOT exceed 350/sec sustained / 1,700/sec burst per agent — the sum of the two budgets with a small cushion, so an attacker who saturates the read budget cannot starve write capacity. Operators with steady low-volume traffic MAY tighten below these starting values; operators with burst onboarding or trafficking patterns larger than this ceiling MUST raise rather than accept silent rejection of legitimate traffic. The split-budget shape (separate read and write counters) MUST be implemented from 3.1 onward even when operators tighten the magnitudes — a shared single-budget cap is the failure mode this rule prevents. The sustained bound is a rolling 60-second window — a burst that empties a 10-second window still counts toward the next 50 seconds of the 60-second rolling bound. Sellers that adopt a different window shape (fixed-minute bucket, EWMA) MUST document it so buyers with retry logic can predict when `RATE_LIMITED` fires; silent window-shape divergence between sellers means identical buyer traffic passes one seller and is rejected by another on conformant implementations. At the 3600 s TTL floor the combined-cap rates bound per-agent residency to \~1.26M entries — an order of magnitude above the original 216k from the write-only sizing, reflecting the read-traffic addition; per-agent storage budgeting should account for this. The numeric recommendations are SHOULD-level; the rate-limit-and-reject-with-`RATE_LIMITED` behavior itself is MUST. Sellers MUST expose the ceilings as tunable configuration parameters — the 300/60 read/write split numbers are first-deployment starting points for an agentic-buyer dashboard pattern, not frozen defaults. Sellers SHOULD NOT publish exact configured ceiling numerics in capability responses — doing so makes the ceiling an ecosystem-wide attack target. Buyers discover the effective ceilings through the `RATE_LIMITED` + `retry_after` response, not through capability introspection. The ceiling is per `(authenticated_agent, account)` — the same scope as the idempotency key itself (bullet 1) — so a multi-account agency does not have its per-account budgets collapsed into a single shared quota. `RATE_LIMITED` rejections MUST populate `retry_after` (seconds) per the [error handling taxonomy](/dist/docs/3.1.20/building/by-layer/L3/error-handling#rate-limit-handling) and MUST NOT be cached as idempotency responses (rule 3: only successful responses are cached). Sellers SHOULD enforce `retry_after` as a cheap rejection floor — a buyer retrying before `retry_after` elapses SHOULD hit a pre-auth token bucket (e.g., at a reverse-proxy layer) rather than re-entering the full schema-validate-and-cache-check pipeline on every retry. Without this discipline, misbehaving buyers can amplify load on the rate-limiter itself. 9. **Concurrent retries — first-insert-wins.** A second request carrying the same `(authenticated_agent, account_id, idempotency_key)` MAY arrive while the first request is still executing — most commonly when the buyer's transport timeout fires before the seller's downstream call returns, and the buyer retries. Sellers MUST resolve the race deterministically; they MUST NOT execute the side effect twice and MUST NOT silently drop the second request. Resolution is a `(unique constraint, INSERT … ON CONFLICT DO NOTHING)` pattern on the scope tuple: the first row to land owns execution and stores the canonical payload hash on the in-flight row (NOT a sentinel); subsequent requests observe an existing row whose response slot is not yet populated but whose payload hash IS populated. Sellers MUST handle the second request by one of two policies and MUST behave consistently across calls — clients infer the policy from the first response within a session and apply it to subsequent retries: * **Wait-and-replay** (preferred for fast operations, \<5s typical): the seller blocks the second request until the first completes, then returns the cached response with `replayed: true`. Total wall-time for the second call is bounded by the seller's request-timeout budget. * **Reject-and-redirect** (preferred for slow operations involving long-running downstream calls): the seller returns `IDEMPOTENCY_IN_FLIGHT` immediately, with top-level `error.retry_after` (seconds) populated based on the first request's elapsed time and expected completion. Buyers MUST retry with the same `idempotency_key` after the hint elapses — a buyer that mints a fresh key on `IDEMPOTENCY_IN_FLIGHT` turns a safe retry into the exact double-execution race this rule prevents. A second request with the same key AND a *different* canonical payload during the in-flight window MUST return `IDEMPOTENCY_CONFLICT` (rule 5), not `IDEMPOTENCY_IN_FLIGHT` — the canonical-form mismatch is computable at INSERT time against the row's stored hash, so the conflict is detectable without waiting for the first request's response. Sellers whose backing store cannot persist the real canonical hash until the handler completes (e.g., a placeholder-sentinel pattern) MUST upgrade the store to persist the hash at INSERT time before declaring rule 9 conformance — the alternative (returning `IDEMPOTENCY_IN_FLIGHT` on a same-key-different-payload race and only surfacing the conflict after the first request completes) silently delays detection of a real client bug. Per rule 3, if the first request ultimately fails (validation error, downstream timeout, internal error), the `(in_flight)` row is released — the key returns to "never seen" state and a subsequent retry re-executes from scratch. Sellers MUST bound the lifetime of an in-flight row to their declared per-task handler timeout, and MUST release the row (treat as failed per rule 3) when that timeout fires — even if the downstream has not yet responded. Without this bound, a hung handler indefinitely returns `IDEMPOTENCY_IN_FLIGHT` for the same key, locking the buyer out of any safe retry path. Sellers using reject-and-redirect MUST set top-level `error.retry_after` to a value no greater than `replay_ttl_seconds` (declared in `capabilities.idempotency`). A buyer instructed to wait past the seller's own replay window is being told to wait until the response can no longer be replayed — the wait is vacuous and the buyer either ends up minting a fresh key (the failure mode this rule prevents) or hits `IDEMPOTENCY_EXPIRED` on retry. Sellers SHOULD also declare `capabilities.idempotency.in_flight_max_seconds` — the maximum lifetime of an in-flight row, scoped to the seller's per-task handler timeout. Buyers SHOULD use that declared value as the primary retry-budget bound when present; when absent, fall back to the order-of-magnitude heuristic (a value derived from the seller's typical handler latency, an order of magnitude below the replay TTL, never the TTL ceiling itself). Sellers MUST NOT leak the in-flight state across the scope boundary: an attacker probing a candidate key MUST receive the same response shape and timing whether the row exists, is in flight, or has never existed. 10. **Crossing service boundaries — downstream reconciliation.** Sellers commonly invoke downstream systems during request handling — SSP/ad-server calls on `create_media_buy`, payment-provider calls on billing operations, governance-agent calls on `check_governance`. These calls have their own failure modes that can leave the seller in a "downstream unknown" state: the network connection dropped after the downstream accepted the request but before its response arrived; the seller process crashed mid-call; a region failover swapped the worker before the response was persisted. Rule 3 (only successful responses cached) is necessary but not sufficient: a seller that simply doesn't cache and re-executes on retry will double-invoke the downstream and create duplicate side effects there. **Conformance grading.** This rule is reviewer-graded, not programmatically graded by the compliance storyboard suite. Black-box observation cannot distinguish "the seller has a claim row" from "the seller got lucky on the test run." The `parallel_dispatch_runner` test-kit lists rule-10 conformance under `reviewer_checks` — sellers attesting to rule-10 conformance MUST surface their operational runbook describing which pattern applies to which downstream, and reviewers verify the implementation against that runbook. The other normative rules (1–9) are programmatically graded. Sellers MUST adopt one of two reconciliation patterns for every downstream call whose duplicate-invocation has business consequences (resource creation, payment movement, irreversible state change). Read-only downstream calls (cache lookups, eligibility checks that don't write) are exempt — but borderline cases like fraud-scoring lookups that also write to a downstream audit log count as writes for this rule (the audit log entry is the side effect). * **Write-claim-before-invoke (preferred default).** Before invoking the downstream, the seller persists a "claim" row in the same transaction as the idempotency cache row — typically `{idempotency_key, downstream_provider, downstream_request_id, status: 'invoked', invoked_at}` — using the seller-generated `downstream_request_id` it will pass to the downstream as the downstream's own correlation/idempotency identifier. On retry, before invoking the downstream again, the seller MUST look up the claim row by `(idempotency_key, downstream_provider)` and reconcile: query the downstream by `downstream_request_id` to determine the true outcome, then resume cache population from there. The seller MUST NOT treat a missing local record as "downstream call did not happen" — a crash between downstream-accepts and local-persist is exactly the case where it did happen and the local record is missing. If the downstream reports no record of `downstream_request_id` (the claim row was persisted but the seller crashed before invoking), the seller MUST treat the call as not-yet-invoked and proceed with the invocation; the claim row already reserves the `downstream_request_id`, so the downstream's own idempotency will dedup any subsequent retry. On an ambiguous response from the downstream lookup (transient 5xx, network error, malformed response), the seller MUST fail closed — return a transient error to the buyer (so the buyer retries against the same `idempotency_key` per rule 9) rather than proceed with invocation on an unauthenticated "no record" signal. * **Thread-buyer-key (acceptable when the downstream protocol supports it).** The seller passes a per-downstream-provider derivative of the buyer's `idempotency_key` as the downstream's own idempotency key — typically `HMAC(K_provider, idempotency_key)` where `K_provider` is derived from the seller's KMS-managed root keyed by provider identity (one key per downstream, not one shared seller secret across all downstreams). Per-provider derivation prevents cross-provider replay if any single downstream is compromised; a shared seller secret across all downstreams collapses every provider into a single key-exposure blast radius. The downstream's at-most-once guarantee then covers the case the seller's local persistence missed. The seller MUST still write a claim row on the success path so the cached response can be populated correctly, but the downstream itself becomes the source of truth on retry. The seller MUST NOT pass the buyer's raw `idempotency_key` to any downstream operated by a different trust principal — the buyer's key is a capability token within its TTL (see "Keys are security-sensitive" below) and forwarding it across a trust boundary widens the capability surface. "Different trust principal" means any system the seller does not operate under the same security boundary; passing the raw key to a purely intra-tenant microservice the seller owns end-to-end (same KMS, same audit log, same operator) does NOT cross a trust boundary and is permitted, though per-provider derivation is still the better default. Sellers MUST document which pattern applies to which downstream in their operational runbook. Sellers MUST NOT use a third pattern of "best-effort dedup on downstream response inspection" — comparing the downstream's response payload to a cached fingerprint to decide whether the call already happened — because the downstream's response shape changes across versions and the fingerprint is a synchronization bug waiting to happen. A claim row OR a threaded key. Not pattern-match-on-response. Sellers MUST NOT include the buyer's `idempotency_key` (or any reversible derivative thereof) in error envelopes returned to the buyer when those errors originated from the downstream. Downstream errors that mention the seller's per-downstream-provider key (or the buyer's key, if the seller incorrectly threaded it raw) MUST be re-keyed or stripped before propagating to the buyer — otherwise a downstream error message becomes a cross-trust-boundary key-disclosure surface. The buyer-visible consequence of this rule: when a seller invokes a slow downstream and the buyer retries during the window, the seller's response on the second request is determined by the seller's policy under rule 9 (`IDEMPOTENCY_IN_FLIGHT` or wait-and-replay), not by the downstream's behavior. Buyers do not need to know which downstream is in the path — the seller MUST present a uniform retry surface regardless. #### Payload equivalence "Equivalent" means **identical canonical JSON form**, not field-by-field semantic comparison. Sellers MUST determine equivalence by hashing the canonical form and comparing hashes. The canonical form is [RFC 8785 JSON Canonicalization Scheme (JCS)](https://www.rfc-editor.org/rfc/rfc8785) — number serialization, key ordering, and escaping all follow JCS §3 normatively. **Fields excluded from the hash** (closed list — sellers MUST NOT extend it): * `idempotency_key` — the key itself * `context` — buyer-opaque echo data (trace IDs, correlation IDs) changes on retry by design * `governance_context` — on the envelope; may be a refreshed signed token on retry * `push_notification_config.authentication.credentials` — may be a rotated bearer token. The URL and scheme remain in the hash; only the credential value is excluded. Everything else in the request body — including `ext` — is included, and "missing optional field" is NOT equivalent to "field explicitly set to null" (JCS preserves the distinction, and so does the hash). **Buyers MUST NOT place rotating tokens or retry-unstable values inside `ext`.** `ext` is part of the canonical payload; a value that changes between retries will trigger `IDEMPOTENCY_CONFLICT` even when the buyer's intent is unchanged. Rotating credentials belong in the exclusion-list fields above; buyer-side trace data belongs in `context`. Sellers MUST NOT extend the exclusion list via capabilities, config, or extension — the list is fixed by this spec, and drift there silently weakens retry-safety guarantees across the ecosystem. **Any future addition to the exclusion list is a breaking change to payload equivalence** (buyers who put a now-excluded value in `ext` would see previously-distinct retries start deduping against each other), so the list will only grow via a major-version bump with migration notes. New PRs proposing an addition MUST demonstrate why the field is semantically outside the retry contract — not just that a particular buyer happened to rotate it. **Reference implementation**: `SHA-256(JCS(payload - excluded_fields))`. * TypeScript / JavaScript: [`@truestamp/canonify`](https://www.npmjs.com/package/@truestamp/canonify) or [`canonicalize`](https://www.npmjs.com/package/canonicalize) * Python: [`pyjcs`](https://pypi.org/project/pyjcs/) or the reference implementation from [RFC 8785 appendix](https://www.rfc-editor.org/rfc/rfc8785) * Go: [`gowebpki/jcs`](https://github.com/gowebpki/jcs) * Rust: [`serde_jcs`](https://crates.io/crates/serde_jcs) AdCP SDK middleware ships JCS canonicalization so sellers don't roll their own. Rolling your own canonical form is a common source of "works on my machine" idempotency bugs — JCS is precisely specified to avoid that. #### Server-side tool wrapper conformance Buyer SDKs send envelope-level fields (`idempotency_key`, `context_id`, `context`, `governance_context`, `push_notification_config`) **uniformly across all AdCP tool calls** — buyers cannot know per-tool which envelope fields the seller's wrapper happens to declare. Servers MUST tolerate envelope-level fields that arrive in tool params but are not declared in the tool's parameter schema. Concretely: * **`idempotency_key`** is required on every AdCP task request (see rule 1 above — read and mutating alike). Tool wrappers MUST accept it; the idempotency layer routes it per rules 2-9. Wrappers that reject the field with `unexpected_keyword_argument` (FastMCP/Pydantic strict signatures) are non-conformant. * **`context_id`, `context`, `push_notification_config`, `governance_context`** MUST be accepted on every tool, including reads. Tools that don't consume a given field MUST ignore it; they MUST NOT reject the call because the envelope field is present. This is the server-side counterpart to the `additionalProperties: true` default that every published AdCP request schema declares. Configuring a server-side validator in a way that contradicts the schema's own `additionalProperties` declaration is a conformance violation. Common server-implementation traps: * **FastMCP / Pydantic with strict signatures** — a tool wrapper declared as `def get_products(brief: str)` raises `unexpected_keyword_argument` when the buyer sends `idempotency_key` inside the same params object. Fix: declare `idempotency_key: str | None = None` (and the other envelope fields) as accept-and-ignore optional parameters, or use a `**kwargs` catch-all and discard unknown keys. Pydantic-on-input uses `Extra.allow` or `model_config = ConfigDict(extra='allow')`. * **Zod / valibot with `.strict()`** on the inbound request schema rejects unknown keys for the same reason; remove `.strict()` on input schemas, or compose with a passthrough variant. * **OpenAPI-generated server stubs** with `additionalProperties: false` injected by the codegen tool — verify the generated input schema mirrors the spec's `additionalProperties: true` default; some generators flip the default during model emission. The wire-level invariant is: a buyer SDK MUST be able to send the same envelope-field set to every AdCP tool on every seller, and any seller that rejects on envelope fields breaks the cross-seller portability the protocol promises. This rule is normative for 3.1+; pre-existing wrappers that reject envelope fields are non-conformant at the next maintenance bump. Reference: this rule generalizes the per-validator pattern already established for response-side validators in [`runner-output-contract.yaml` > `response_schema_validator_semantics`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/universal/runner-output-contract.yaml) — both rules express the same principle ("validator configuration MUST NOT contradict the schema's own `additionalProperties` declaration") on the two ends of the wire. #### Response-level replay indicator The protocol envelope carries a top-level `replayed` boolean on responses to any request that resolved via the idempotency cache: ```json theme={null} { "status": "completed", "replayed": true, "timestamp": "2026-04-18T14:35:00Z", "payload": { "media_buy_id": "mb_01HW7J8K9P0Q1R2S3T4U5V6W7X" } } ``` `replayed` is produced by the seller's idempotency layer at response time, not stored in the cache. On a fresh execution it is `false` (or omitted — buyers MUST treat omission as `false`). On a cached replay it is `true`; the inner `payload` is byte-for-byte what was stored on the original successful execution. Envelope fields (`timestamp`, `context_id`, etc.) may differ — they describe the current response, not the cached one. Buyers use `replayed` for: * **Agent side-effect suppression** — an agent that acts on response data before a human sees it (notifications, downstream tool calls, memory writes) MUST check `replayed` to avoid re-emitting on retry. "Campaign created!" notifications, LLM memory inserts, and downstream agent calls are exactly what silent replay breaks. * **Side-effect invariants** — downstream systems expecting exactly-once event semantics read `replayed` before treating the response as a new event. * **Billing reconciliation** — "we processed N buys this month" counts `replayed: false` only. * **Logging** — distinguishing "retry succeeded by returning cache" from "retry triggered a new execution" (the latter usually signals a bug in the replay window or key management). * **State-machine routing** — state-tracking fields in the cached `payload` (e.g., `status: pending_creatives` on a replayed `create_media_buy`) are a historical snapshot, not a current-state read (see seller rule 2 and "Replay responses are historical snapshots" under buyer obligations). Buyers MUST re-read via the resource's read endpoint before any state-dependent action. #### IDEMPOTENCY\_CONFLICT response shape Standard AdCP error envelope. The error body: * MUST include `code: "IDEMPOTENCY_CONFLICT"` and a human-readable `message` * MUST NOT include the cached response, the original payload, a canonical-form diff, or any fingerprint derived from them. A `field` json-pointer hint seems harmless but reveals schema shape (e.g., `/packages/0/budget` tells an attacker the victim's payload had a budget in the first package). Sellers MUST NOT emit one. A legitimate buyer debugging a retry can diff their own two payloads — they have both. ```json theme={null} { "errors": [ { "code": "IDEMPOTENCY_CONFLICT", "message": "idempotency_key was used with a different payload within the replay window. Either resend the exact original payload (to return the cached response) or generate a fresh UUID v4 to submit this new payload.", "recovery": "correctable" } ], "context": { "correlation_id": "..." } } ``` Leaking cached state turns key-reuse into a read oracle. An attacker who guesses or steals a victim's key could otherwise probe it to infer payload structure. The error body exposes only the code. #### SI send\_message idempotency model `si_send_message` needs a narrower scope than other mutations because conversational turns advance session state. The key is scoped `(authenticated_agent, account_id, session_id, idempotency_key)`. * **Retry of turn N within the TTL returns the cached response for turn N**, even if turn N+1 has since been accepted. Idempotency returns what you did, not rewinds what the session is. The buyer's retry is asking "did my message get through" — the answer is still "yes, here's what came back." * **A new `si_send_message` with a fresh `idempotency_key` is a new turn**, processed against the current session state. Buyers MUST generate a fresh key per logical turn, not per HTTP attempt. * **If the seller has advanced session state past turn N and cannot reproduce the cached response byte-for-byte** (e.g., the session was pruned for storage), the seller MAY return `SESSION_NOT_FOUND` or `IDEMPOTENCY_EXPIRED` rather than reconstruct. Buyers retrying far past a session timeout should expect this. #### Buyer obligations Buyers MUST generate a unique `idempotency_key` per `(seller, request)` pair. Reusing the same key across sellers allows colluding sellers to correlate requests from the same buyer. Use a fresh UUID v4 for each request. On retry after a network error, buyers MUST resend the exact same payload with the same key — changing either side breaks at-most-once semantics. In particular, buyers MUST NOT change `push_notification_config.url` between retries with the same key; URL is part of the canonical hash and rotating it triggers `IDEMPOTENCY_CONFLICT`. Rotate the key when changing webhook configuration. **Network retry vs. agent re-plan vs. polling / state re-read.** Three cases that look similar but need different handling: * **Network retry** — socket timeout, 5xx, transient failure. The buyer has the *same intent* and sent the *same bytes* — and MUST resend them with the *same key*. This is what idempotency\_key exists for. * **Agent re-plan** — the buyer is an agent whose planner re-ran (prompt re-executed, tool output changed, policy re-evaluated) and produced a *different payload*. The intent has changed. The agent MUST mint a *new key* and treat the prior request as abandoned. Reusing the prior key with a different canonical payload returns `IDEMPOTENCY_CONFLICT`, which is the seller correctly telling the agent "you're not retrying, you're doing something new." * **Polling / state re-read** — a dashboard polling `get_products(brief)`, `list_creatives`, `list_accounts` at intervals; a buyer agent reading `get_media_buys` to fetch fresh state after a mutation; any "give me current state at time T" call. Buyers MUST mint a fresh `idempotency_key` per call. Reusing the prior poll's key would replay the cached snapshot (up to `replay_ttl_seconds`), silently returning stale data — exactly the failure mode the cache exists to prevent on mutations. This rule also governs the re-read step in the [Replay responses are historical snapshots](#replay-responses-are-historical-snapshots) pattern below: the "re-read for current state" call MUST carry a fresh key, never the key from the mutation it's reading state for. When in doubt, ask whether the buyer's intent is **"give me the same answer as before"** (network retry — reuse the key) or **"give me the current answer"** (polling / state re-read — mint a new key) or **"do this new thing"** (agent re-plan — mint a new key). Agentic clients that loop through an LLM to build the request SHOULD freeze and cache the serialized bytes alongside the key on first send for the network-retry case, so retries send the identical payload even if the planner would produce something slightly different on re-execution. **Bootstrap carve-out — `get_adcp_capabilities`.** The discovery call itself is exempt from rules 1–9 of this section. `get_adcp_capabilities` is how the buyer learns whether the seller declares `adcp.idempotency.replay_ttl_seconds`, so a fail-closed rule against the discovery call would deadlock the bootstrap. Buyers MAY omit `idempotency_key` on `get_adcp_capabilities`, and sellers MUST accept the call without it. Buyers that send `idempotency_key` on `get_adcp_capabilities` (e.g., SDKs that include the field uniformly) get the standard cache behavior — but the discovery call carries no state and replay is harmless. Every other AdCP task request remains subject to rules 1–9; the fail-closed obligation below applies once the capability fetch has completed. **When the seller's capability declaration is missing.** A seller whose `get_adcp_capabilities` response omits `adcp.idempotency.replay_ttl_seconds` is non-compliant. After a successful capability fetch, client SDKs MUST fail closed on every subsequent AdCP task request against that seller — raise an error, don't assume a default — so the buyer learns about the non-compliance immediately rather than after a silent double-booking. The fail-closed rule applies to every AdCP task request (other than `get_adcp_capabilities` itself) now that `idempotency_key` is required universally — including calls that resolve as pure reads, because the buyer cannot predict at call time whether a polymorphic task (`get_products` brief vs. refine+finalize vs. async-Submitted) will resolve as a read or a mutation, and the missing TTL declaration means the seller is unsafe to retry against in any mode. **Decoding seller-emitted error codes.** Sellers MAY return error codes (`IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, `IDEMPOTENCY_IN_FLIGHT`, `INVALID_REQUEST`, or codes added in later minor versions) that buyers' pinned vocabulary may not recognize. Receivers MUST decode these per [Forward-compatible decoding (normative)](/dist/docs/3.1.20/building/by-layer/L3/error-handling#forward-compatible-decoding-normative) — read `error.recovery` for the recovery classification, default to `transient` when `recovery` is absent, and never reject the response because the code value is unfamiliar. The retry semantics for `transient`-classified errors are bounded by [§ Retry Logic](/dist/docs/3.1.20/building/by-layer/L3/error-handling#retry-logic) (`maxRetries` and exponential backoff with jitter) — buyers MUST NOT loop indefinitely on a `transient` default. **Replay responses are historical snapshots.** A response carrying `replayed: true` is byte-equivalent to the original first-call response (per seller rule 2) — state-tracking fields in it reflect the resource's state at first-call time, NOT the resource's current state. A buyer that reads `status: pending_creatives` from a replayed `create_media_buy` response and then calls `update_media_buy(canceled: true)` on a resource that has actually been in `canceled` for hours will surface a `NOT_CANCELLABLE` error and a state-machine bug. Buyers requiring current state MUST consult the resource's read endpoint — `get_media_buys` for media buys, `list_accounts` for accounts, `list_creatives` for creatives, `get_signals` for signals, equivalents for other resources. `replayed: true` is the explicit signal that a fresh read is required before any state-dependent decision; SDKs SHOULD surface the flag to caller code rather than transparently unwrap it. Agentic buyers MUST treat `replayed: true` as a stop signal for any planning step whose next action depends on resource state, and MUST re-read before continuing. **The re-read MUST carry a fresh `idempotency_key`.** Reusing the key from the mutation whose state you're re-reading either returns `IDEMPOTENCY_CONFLICT` (if the read payload differs from the mutation payload — almost always true) or, worse, returns the cached mutation response itself (if the payloads happen to match). Reusing a *prior read's* key returns that prior read's cached snapshot — the exact stale-state failure mode this rule exists to prevent. State re-reads fall under the Polling / state re-read case above; mint a new key per call. **TTL boundary for persisted keys.** Some buyers persist `idempotency_key` alongside their own object (e.g., `campaign.pending_idempotency_key` in the buyer's DB) so that retries after a process restart or overnight reconcile still dedup. This works **only within the seller's declared `replay_ttl_seconds`**. Beyond the TTL, the seller will either reject the retry with `IDEMPOTENCY_EXPIRED` (good) or, if the cache was evicted, treat it as a new request (silent double-booking — the failure mode this field exists to prevent). Buyers retrying past the TTL MUST fall back to a natural-key check (e.g., query `get_media_buys` by `context.internal_campaign_id`) before resending. The `idempotency_key` guarantees at-most-once execution within the replay window, not forever. Queue-based retry systems and workflow engines with retry horizons longer than the seller's TTL MUST be designed around this — don't put a key into a dead-letter queue that replays days later without a natural-key re-check. **Keys are security-sensitive.** An `idempotency_key` is a secret capability token within its TTL — anyone who holds one and knows the original payload can replay it and read the cached response. Treat keys the way you treat session tokens: do not log them in full, do not embed them in URLs, do not share them across agents. Log prefix-only (first 8 chars of the UUID) if you need correlation. Buyers persisting `pending_idempotency_key` at rest (e.g., alongside a campaign row in the buyer's DB) MUST encrypt it with the same controls used for bearer tokens, and SHOULD purge the key after success confirmation to minimize the exposure window. **Sellers MUST encrypt the cache tier at rest.** Under universal idempotency (3.1+), the cache holds read-tool responses (`get_products`, `list_accounts`, `list_creatives`, `get_signals`, etc.) in addition to the write receipts it held in 3.0.x. Those read responses carry account-scoped data — brand domains, account names, product allocations, signal references — at the same sensitivity as the seller's underlying resource store. Sellers MUST apply at-rest encryption to the idempotency cache with the same controls used for the resource store the cached data was read from, MUST NOT treat the cache as a transient retry-receipt store exempt from data-at-rest controls, and MUST scope cache reads by `(authenticated_agent, account_id)` at the storage layer (not just at the application layer) so a misconfigured query cannot pull a sibling tenant's cached read response. **Keys MUST be unguessable.** Schema enforces `^[A-Za-z0-9_.:-]{16,255}$` and buyers MUST use UUID v4 (\~122 bits of entropy) or an equivalent CSPRNG-generated value. Low-entropy keys like `retry-001` or monotonic counters turn the cache into an enumerable surface: an attacker can walk the key space and test each one against a target agent. Sellers SHOULD reject keys that fail a basic entropy check (e.g., all-zeros, repeated characters, short ASCII words) with `INVALID_REQUEST` when the authenticated agent is not individually trusted. **The three-state response (`success` / `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_EXPIRED`) is an existence oracle for idempotency keys.** An attacker who holds a candidate key can probe it: `success` means never seen, `IDEMPOTENCY_CONFLICT` means live with a different payload, `IDEMPOTENCY_EXPIRED` means previously used. The per-`(agent, account)` scoping above is the primary defense — an attacker authenticated as agent A cannot probe agent B's keys, and a caller scoped to account A cannot probe account B's keys even under a shared agent credential. Unguessable keys are the secondary defense — an attacker who cannot guess a victim's key cannot probe the oracle usefully. Sellers MUST NOT surface `IDEMPOTENCY_EXPIRED` across scope boundaries or to unauthenticated callers. Sellers SHOULD also avoid distinguishable timing between "key exists" and "key does not exist" lookups in the idempotency layer; a constant-time floor on the negative path closes a side channel that persists even without an error-code oracle. **SI session scope.** For `si_send_message` the key is scoped `(authenticated_agent, account_id, session_id, idempotency_key)`. `session_id` is therefore part of the oracle surface: if session IDs are guessable, an attacker who steals one key can probe it against many sessions. SI sellers MUST generate `session_id` server-side using a CSPRNG with ≥122 bits of entropy (UUID v4 or equivalent) and MUST NOT derive it from anything observable to another agent (request sequence number, user handle, timestamps). The same idempotency\_key sent with a different `session_id` is a different scope tuple — always a new request, never a conflict. **`account_id` entropy for cache-scope safety.** `account_id` is part of every idempotency scope tuple, so it is also part of the oracle surface: an attacker authenticated as agent A with a stolen idempotency key could probe it against candidate account IDs to enumerate accounts in A's authorized set or learn which accounts A has ever operated on. When account IDs are short sequential or semantic values (`acct_123`, `nike-us`), this is a real enumeration channel. Sellers that issue server-assigned account IDs MUST use unguessable values (UUID v4 / ULID, ≥122 bits of entropy) for any account ID that participates in an idempotency cache scope. Sellers operating under the buyer-declared account model (natural-key `{brand, operator}`) MUST hash the natural key with a seller-local salt before using it as a cache-scope component — the natural key is public by design and cannot be used directly as an oracle defense. ```javascript theme={null} import { canonicalize } from "@truestamp/canonify"; // RFC 8785 JCS import { createHash } from "node:crypto"; const EXCLUDED_FROM_HASH = new Set([ "idempotency_key", "context", "governance_context", ]); function payloadHash(request) { const filtered = Object.fromEntries( Object.entries(request).filter(([k]) => !EXCLUDED_FROM_HASH.has(k)), ); // If push_notification_config.authentication.credentials rotates, exclude it too if (filtered.push_notification_config?.authentication) { const { credentials, ...auth } = filtered.push_notification_config.authentication; filtered.push_notification_config = { ...filtered.push_notification_config, authentication: auth, }; } return createHash("sha256").update(canonicalize(filtered)).digest("hex"); } async function createMediaBuy(request, envelope) { if (!request.idempotency_key) { throw new InvalidRequestError("idempotency_key is required"); } const requestHash = payloadHash(request); const existing = await db.findByIdempotencyKey({ agent_id: currentAgent.id, account_id: request.account.account_id, idempotency_key: request.idempotency_key, }); if (existing) { if (existing.expires_at < new Date()) { throw new IdempotencyExpiredError("idempotency_key is past replay window"); } if (existing.request_hash !== requestHash) { throw new IdempotencyConflictError("idempotency_key reused with a different payload"); } // Return the stored INNER payload; replayed: true is injected by the envelope layer envelope.replayed = true; return existing.response; } return db.transaction(async (tx) => { const response = await processMediaBuy(tx, request); // Cache ONLY on success, and cache only the inner response payload await tx.idempotencyKeys.insert({ agent_id: currentAgent.id, account_id: request.account.account_id, key: request.idempotency_key, request_hash: requestHash, response, expires_at: new Date(Date.now() + TTL_SECONDS * 1000), }); envelope.replayed = false; return response; }); } ``` #### Natural-key idempotency is not a substitute Upsert-style tasks (`sync_accounts`, `sync_audiences`, `sync_catalogs`, `sync_event_sources`, `sync_governance`, `sync_plans`) already dedup at the resource level — two calls with the same `account_id` or `audience_id` produce one row, not two. That's **resource idempotency**. `idempotency_key` guarantees something stricter: **envelope idempotency**. The entire request — including its side effects — executes at most once. Retrying the same sync envelope without a key can still fire onboarding webhooks twice, emit duplicate audit log entries, or double-provision pixel endpoints, even though the resource rows end up identical. The key is what makes a retry truly safe. The one exception in the spec is `si_terminate_session`: `session_id` plus the "terminate" verb is fully idempotent — a second call on an already-terminated session returns the same terminal state with no new side effects — so that schema doesn't require `idempotency_key`. ### Signed Governance Context `governance_context` crosses trust boundaries — from governance agent to buyer to seller and back, and ultimately to auditors and regulators who may need to verify an approval long after the original transaction closed. AdCP 3.0 tightens the value format to a compact JWS signed by the governance agent so any party can verify authenticity, binding, and replay without subpoenaing the issuer. **Roles:** * **Governance agents** sign the token. They are the only party that signs. * **Buyers** attach the token they received from their governance agent to the protocol envelope and forward to the seller. Buyers MUST NOT construct, modify, or re-sign the token. Buyers SHOULD retain the `jti` and `check_id` for their own audit record. * **Sellers** persist the token as received and include it verbatim on all subsequent governance calls. Sellers that implement verification MUST verify per the checklist below before acting on the token. Sellers that have not yet implemented verification MUST still persist and forward the token unchanged so that verification-capable parties downstream (auditors, regulators) can act on it later. * **Auditors and regulators** verify independently using the governance agent's published keys — this is the accountability property the signed format exists to deliver. The same string is also the primary correlation key for the governance lifecycle. The governance agent decodes its own token to look up internal state (buyer correlation IDs, policy decision log, etc.) — sellers and buyers never need to parse the payload. #### Scope and dependencies * **In scope (3.0)**: buy-side governance. The `governance_context` token authorizes spend commitments made via AdCP tasks (`create_media_buy`, `acquire_rights`, `activate_signal`, `creative_services`). Sellers that run their own compliance policies (e.g., CTV political-ad rules, publisher brand-safety gates) express those via `conditions` responses on their own governance workflows; they do not issue signed tokens under this profile. * **Out of scope (3.0)**: seller-side governance authorities. A future RFC may extend this profile to cover seller-side signed decisions declared via `adagents.json`. * **Out of scope (ever)**: OpenRTB bid streams. Governance attestation terminates at the AdCP media buy boundary. Threading a signed attestation through per-impression bid requests is operationally infeasible (one token, many recipients, broadcast-fan-out) and unnecessary (spend authorization happens at media buy time, not per-impression). **Dependency on Transport Signing (#2307)**: the anti-spoof property of this profile depends on sellers being able to establish the buyer domain independently of the token's `iss` claim — see [Buyer identity resolution](#buyer-identity-resolution) below. In 3.0 without #2307, sellers MUST either use mTLS or a pre-provisioned buyer API key to establish buyer identity; treating the request's bearer token alone as identity input to brand.json resolution is circular and does not prevent spoofing. 3.1 normatively requires #2307-style signed requests. #### AdCP JWS profile This profile applies to `governance_context` (#2306) and to any future AdCP artifact that is signed as a standalone token. Transport-layer request signing (#2307) uses RFC 9421 HTTP Signatures but shares the JWKS discovery described here. Governance signing keys MUST NOT also be used as #2307 transport-signing keys — the JWKS endpoint is shared, but each key entry MUST declare `"key_ops": ["verify"]` and `"use": "sig"` and occupy a distinct `kid`. Verifiers MUST enforce key-ops separation to prevent cross-purpose key reuse. **Header** * `alg`: `EdDSA` (Ed25519) RECOMMENDED on server-side runtimes. `ES256` (ECDSA P-256) RECOMMENDED on edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy) where Ed25519 may require explicit runtime configuration. Verifiers MUST reject `none`, `HS*`, and any `RS*` variant below 2048-bit. Verifiers MUST enforce the allowlist on the token header; they MUST NOT rely solely on library defaults. * `kid`: REQUIRED. Identifies the signing key in the issuer's JWKS. * `typ`: REQUIRED. MUST be exactly `adcp-gov+jws` (byte-for-byte match; verifiers MUST NOT normalize or strip the `+jws` structured suffix per RFC 6838 §4.2.8). The typed header prevents a governance signing key from being tricked into validating a generic JWT for another purpose. * `crit`: REQUIRED if any `crit`-listed claim is present. Per RFC 7515 §4.1.11, `crit` is an array of header/claim names that MUST be understood by the verifier. Verifiers MUST reject the token if any name in `crit` is not recognized. Governance agents MUST list in `crit` any claim whose omission or misinterpretation would change authorization semantics (e.g., a future `budget_cap` claim). This prevents silent downgrade attacks when the profile adds claims in later versions. **Claims** | Claim | Required | Description | | ---------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `iss` | Yes | Governance agent identifier. MUST be an HTTPS URL that byte-for-byte matches the `url` of a governance-typed entry in the buyer's brand.json, including any path component. Path-level matching is required so multi-tenant SaaS governance agents (e.g., `https://gov.vendor.com/tenant/acme`) cannot be spoofed by sibling tenants sharing the same origin. | | `sub` | Yes | `plan_id` the token authorizes. Note: `sub` is used here as a resource identifier rather than a user or authenticated agent. Implementations that log `sub` as a user ID should be aware of this. | | `plan_hash` | Yes | Audit-layer binding of the attestation to the evaluated plan state. Not part of the seller verification checklist — sellers treat it as opaque cargo. Semantics, canonicalization, and verification paths are defined in [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit). | | `aud` | Yes | Target seller identifier. MUST be the exact URL string from the seller's `adagents.json` entry that authorized this seller for the property being purchased, byte-for-byte including scheme, host, port, and path. Case-sensitive; no path-prefix match. For intent tokens where the buyer is evaluating multiple sellers, the buyer MUST request one token per target seller (see [Intent-phase disclosure](#intent-phase-disclosure) for the privacy trade-off). | | `iat` | Yes | Issued-at timestamp (seconds since epoch). | | `nbf` | No | Not-before timestamp. When present, verifiers MUST reject if now \< nbf (with ±60 s skew). | | `exp` | Yes | Expiration timestamp. Intent tokens SHOULD expire within 15 minutes. Execution-phase tokens (`purchase`, `modification`, `delivery`) MUST expire within 30 days; governance agents refresh longer lifecycles by issuing a new token on each lifecycle check. | | `jti` | Yes | Unique token identifier. Used by sellers for replay detection and by auditors for correlation. RECOMMENDED format: UUID v7 or ULID for time-orderability. | | `phase` | Yes | `intent` (pre-seller), `purchase`, `modification`, or `delivery`. Matches the governance check phase this token authorizes. The operation the seller is performing determines the required phase: `create_media_buy` → `purchase`; `update_media_buy` → `modification`; delivery-reporting callbacks → `delivery`. | | `caller` | Yes | URL of the party that requested the governance check that produced this token. In intent phase, this is the orchestrator/buyer; in execution phases, this is typically the seller itself (as callbacks arrive with the seller as caller). | | `check_id` | Yes | Governance agent's `check_id` for this decision; correlates to `report_plan_outcome` and `get_plan_audit_logs`. | | `media_buy_id` | Conditional | Seller-assigned media buy ID. MUST be present on `purchase`, `modification`, and `delivery` phase tokens. MUST be null or absent on `intent` phase tokens. | | `policy_decisions` | No | Compact array of `{ policy_id, outcome }` entries (may include `confidence`). Visible to the seller. Governance agents SHOULD omit this in privacy-sensitive deployments (see [Privacy considerations](#privacy-considerations)) and use `policy_decision_hash` instead. | | `policy_decision_hash` | No | SHA-256 hash of the canonicalized decision log, hex-encoded. When present, sellers treat it as an opaque integrity anchor; full log is retrievable by auditors via `audit_log_pointer`. Governance agents MUST include either `policy_decisions` or `policy_decision_hash` (both is permitted). | | `audit_log_pointer` | No | HTTPS URL consumable by `get_plan_audit_logs` for the full decision evidence. When present, auditors can fetch the full log using the pointer; access control is governed by the governance agent. | | `status` | No | Optional forward-compatibility hook. When present, MUST be a JSON object conforming to a future IETF JWT Status List mechanism (draft-ietf-oauth-status-list). Verifiers that do not understand `status` MUST NOT reject solely on its presence unless it appears in `crit`. | **Unknown-claim handling**: verifiers MUST ignore claims whose names they do not recognize *unless* those claim names appear in the token's `crit` header, in which case the token MUST be rejected. This asymmetric rule — ignore unknown, but reject unknown-and-critical — is how future versions of the profile add semantically meaningful claims without breaking backward compatibility for verifiers that haven't updated yet. **Size**: a typical token with `policy_decision_hash` fits comfortably under the 4096-character envelope limit. Implementations MUST NOT put large evidence payloads in the token; use `audit_log_pointer` instead. **`plan_hash` is audit-layer, not wire-layer**: the `plan_hash` claim is cryptographic cargo the token carries for off-wire verification by the governance agent, auditors, and buyer-side compliance. It is not part of this profile's seller verification contract and is never listed in `crit`. Canonicalization, excluded fields, retention rules, and test vectors are specified in [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit) (governance spec). Sellers persist and forward `governance_context` verbatim and perform the 15-step verification checklist below — authenticity, authorization scope, freshness — without inspecting `plan_hash`. #### Buyer identity resolution The brand.json cross-check (step 13 of the verification checklist) is the anti-spoofing control. It requires sellers to know *which buyer's brand.json to consult* — the authenticated agent proves who is calling, and the resolution chain maps that agent to the buyer domain whose brand.json the seller should fetch. In 3.0 sellers MUST establish the buyer domain via one of: 1. **mTLS**: buyer presents a client certificate; the certificate Subject/SAN resolves to the buyer's registered domain; the seller fetches `https://{domain}/.well-known/brand.json`. 2. **Pre-provisioned buyer identity**: an API key or OAuth client identifier issued by the seller at onboarding, mapped to the buyer's domain in the seller's records. 3. **Signed requests per #2307** (3.1 normative): RFC 9421 HTTP Signatures with `keyid` resolving to a buyer-declared public key in the buyer's adagents-style agent registry. Sellers MUST NOT derive the buyer identity from an unauthenticated field in the request (including the token's `iss`, `caller`, or any client-supplied header). Doing so creates a circular trust chain: the attacker proves "I am the buyer" by presenting a token signed by an attacker-controlled governance agent declared in an attacker-controlled brand.json. In particular, **the token's `iss` is untrusted input until step 13 of the verification checklist confirms it appears as a governance-typed entry in the *authenticated* buyer's brand.json** — the authentication mechanism (mTLS, API key, or signed request) establishes the buyer domain first, and only the brand.json fetched from *that* domain is trusted to attest which governance agent (`iss`) may sign for this buyer. brand.json resolution follows one redirect (`authoritative_location` or `house` redirect variant) and stops. Sellers MUST NOT follow redirect chains. #### Key discovery (JWKS) Sellers and auditors resolve the governance agent's public keys via JWKS (RFC 7517): 1. Establish the buyer domain via the rules in [Buyer identity resolution](#buyer-identity-resolution). 2. Fetch the buyer's brand.json. Locate the `agents[]` entry whose `type` is `governance` and whose `url` byte-for-byte equals the token's `iss`. Reject if no matching entry exists. 3. Use the entry's `jwks_uri` if declared. If absent, default to `{origin of iss}/.well-known/jwks.json` where origin = scheme+host+port per RFC 6454. Multi-tenant governance agents serving multiple buyers from a shared origin MUST declare explicit per-tenant `jwks_uri` so tenant key material is not pooled across the origin. Sharding is also a size requirement, not only an isolation one: each `jwks_uri` is fetched under the `MAX_JWKS_BYTES` budget (64 KiB — see the verifier pseudocode below), which is the JWKS-specific cap and is deliberately tighter than the generic 5 MB SSRF body ceiling. A single JWKS that pools hundreds of per-tenant keys exceeds 64 KiB and is rejected, so per-tenant `jwks_uri` (each serving a small key set) — not one aggregated document — is the conformant path at scale. 4. Fetch the JWKS over HTTPS. 5. Locate the key in the JWKS whose `kid` matches the token header. On cache miss for a `kid`, refetch the JWKS once (respecting a minimum 30-second cooldown to prevent unbounded refetches) before rejecting. **JWKS cache TTL** MUST be bounded above by the revocation-list polling interval (see [Revocation](#revocation)). Longer cache TTLs defeat revocation: if a compromised `kid` is added to `revoked_kids` but the seller's JWKS cache still serves the revoked key for validation, only the revocation check (performed independently per step 14) catches the fraud. **SSRF protection**: `jwks_uri` and the revocation-list URL are counterparty-supplied. All outbound fetches to these URLs MUST follow the SSRF controls defined in [Webhook URL validation](#webhook-url-validation-ssrf): reject non-HTTPS, reject resolved IPs in reserved ranges (including cloud metadata addresses), pin the connection to the validated IP, refuse redirects, cap response size and timeouts, suppress detailed error messages to the counterparty. A JWS profile without SSRF discipline on key discovery is a metadata-exfiltration vector. #### Seller verification checklist Before treating a request as governance-approved, sellers MUST perform these checks in order, short-circuiting on the first failure: 1. Parse the compact JWS. Reject if malformed. 2. Reject if header `alg` is `none` or not in the allowed list (EdDSA, ES256). Library defaults MUST NOT be relied upon. 3. Reject if header `typ` is not exactly `adcp-gov+jws` (no normalization). 4. Reject if the header contains a `crit` array and any listed name is not recognized by the verifier. 5. Resolve `iss` to a JWKS via the discovery rules above. Reject if the JWKS cannot be fetched (after SSRF validation) or the `kid` is not present after one refetch. 6. Verify the JWKS entry's `use` is `"sig"` and `key_ops` includes `"verify"`. Reject keys marked for other uses. 7. Cryptographically verify the signature. 8. Reject if `aud` does not byte-for-byte equal the seller's own canonical URL as declared in the relevant `adagents.json` entry. 9. Reject if `exp` is in the past or `iat` is more than 60 seconds in the future (±60 s clock-skew tolerance, symmetric on both bounds). If `nbf` is present, reject if `now < nbf − 60 s`. 10. Reject if `sub` does not equal the `plan_id` in the governance call this token is attached to (prevents plan swap). 11. Reject if `phase` does not match the operation: `purchase` for `create_media_buy`; `modification` for `update_media_buy`; `delivery` for delivery-reporting callbacks; `intent` only for pre-seller buyer-side evaluation. 12. For non-intent tokens, reject if `media_buy_id` does not equal the media buy ID in the request. 13. Cross-check: the token's `iss` MUST appear as a governance-typed agent in the buyer's current brand.json (established via [Buyer identity resolution](#buyer-identity-resolution)). Sellers SHOULD cache brand.json with reasonable TTLs (recommend 1 hour) and refresh on verification failure. 14. Check the revocation list (see [Revocation](#revocation)). Reject if `jti` ∈ `revoked_jtis` or if the token header's `kid` ∈ `revoked_kids`. This check runs on every verification, not only on cache miss. 15. Reject if `jti` has been seen before for this `(iss, aud)` tuple. See [Replay dedup](#replay-dedup) for storage guidance. Only after all 15 checks pass does the seller treat the request as governance-approved. Note that sellers do not verify `plan_hash` — that claim is bound at the governance-agent / auditor layer (see [Plan-state binding](#plan-state-binding)). #### Replay dedup Step 15 requires tracking `jti` values to prevent replay. The naive implementation — an unbounded set — is both a memory risk and a DoS vector (attacker floods the seller with unique tokens to exhaust storage). **Scaling recommendations**: * Cap execution-token `exp` at 30 days (enforced by governance agents; sellers reject anything longer). This bounds the dedup window. * Use a bloom filter keyed on `(iss, aud, jti)` with a small false-positive rate (\~1 in 10⁶) as the fast-path check, with authoritative lookup in a bounded store (Redis `SET jti NX EX `, Postgres unique index with TTL cleanup) only on bloom-filter hits. * Governance agents SHOULD issue `jti` values in a time-orderable format (UUID v7 or ULID) so sellers can partition the dedup store by time window and drop expired partitions cheaply. #### Revocation Exp-based expiry alone does not cover execution-phase tokens that live for a media buy's lifecycle. Governance agents MUST publish a revocation list at `{origin of iss}/.well-known/governance-revocations.json` and MUST sign the list itself using a key in the same JWKS: ```json theme={null} { "payload": "", "signatures": [ { "protected": "", "signature": "" } ] } ``` The payload (JWS-flattened JSON serialization; compact form is also acceptable): ```json theme={null} { "version": 1, "issuer": "https://gov.example.com", "updated": "2026-04-18T14:00:00Z", "next_update": "2026-04-18T14:15:00Z", "revoked_jtis": ["01HWZX..."], "revoked_kids": ["gov-2026-03"] } ``` * `revoked_jtis` invalidates individual decisions (e.g., a plan was rescinded). Revocation applies to any token with that `jti`, regardless of signing key. * `revoked_kids` invalidates every token ever signed under that `kid` (before or after the revocation timestamp), not just tokens issued after. * `issuer` MUST match the `iss` origin of tokens this list governs. Prevents cache substitution across issuers by a shared CDN. * The list is signed so a compromised CDN or DNS origin cannot serve a stale or tampered list to un-revoke a compromised key. **Polling cadence**: * Sellers MUST poll the list on the cadence declared in `next_update`. * Floor: 1 minute. Ceiling: 30 minutes for any seller accepting execution-phase tokens. Governance agents MUST NOT declare `next_update` more than 30 minutes in the future for issuers covered by execution-phase traffic. The `next_update` value is a JSON timestamp, not an HTTP cache header — standard HTTP caches will not respect it; sellers MUST parse and honor it themselves. Sellers that prioritize fast key-compromise propagation over DoS tolerance SHOULD poll at or near the floor; the ceiling exists for sellers that accept slower `revoked_kids` propagation in exchange for tolerating longer revocation-endpoint outages. * Polling is optional for intent-phase tokens with ≤15 min `exp` (the intent-token `exp` cap from the JWT claims table above — distinct from the polling ceiling, even though the numbers were previously coincident). * Use HTTP conditional requests (`If-Modified-Since` / `ETag`) to avoid unnecessary body transfers. **Fetch failure safe-default**: if a seller has not successfully refreshed the revocation list within `next_update + grace` (recommend grace = 4× the previous polling interval), the seller MUST reject any new `purchase`, `modification`, or `delivery` phase token until the list is refreshed. This prevents an attacker who DoSes the revocation endpoint from extending the fraud window of a compromised key. Sellers operating at the polling ceiling get \~2.5 h of endpoint-outage tolerance; sellers at the floor get \~5 min. Tune the polling cadence — not the grace constant — to your risk appetite. * Governance agents MUST retain revoked public keys as discoverable for the audit retention period (recommend 7 years) so auditors can verify historical tokens after the current rotation. Revoked keys SHOULD be served at `{origin}/.well-known/jwks-archive.json` (separate from the active JWKS). #### Key rotation * Governance agents rotate by adding a new key to JWKS with a new `kid`, signing fresh tokens with the new `kid`, and leaving the old key published until the longest-lived outstanding token expires. * Seller JWKS caches MUST invalidate and refetch on a missing-`kid` failure before rejecting (with a 30-second cooldown to prevent unbounded refetches). * Emergency rotation (key compromise) proceeds by adding the old `kid` to the signed `revoked_kids` list and rotating to a new key immediately. Short exp on intent tokens, capped exp on execution tokens, and revocation-list polling together bound the fraud window. #### Verification error taxonomy Sellers and client libraries SHOULD surface verification failures with these codes so that retry vs reject semantics are consistent across the ecosystem. AdCP client libraries (`@adcp/sdk` and equivalents) SHOULD expose typed errors that map to this taxonomy. | Failure | Retry? | Code | Notes | | ------------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | JWKS fetch timeout or 5xx | Yes, with backoff | `governance_jwks_unavailable` | Transient. Retry with exponential backoff; abort after N attempts. | | JWKS fetch fails SSRF validation | No | `governance_jwks_untrusted` | Permanent. Indicates misconfigured `jwks_uri` or an attack. | | `kid` not in JWKS after refetch | No | `governance_key_unknown` | Reject. Possibly indicates rotation lag or key revocation. | | Signature invalid, `typ` mismatch, `alg` not allowed, `crit` unknown | No | `governance_token_invalid` | Reject. Indicates tampering or implementation bug. | | `exp` in past, `jti` replayed, `nbf` in future | No | `governance_token_expired` / `_replayed` / `_not_yet_valid` | Reject. Tokens cannot be healed by retry. | | `jti` ∈ `revoked_jtis` or `kid` ∈ `revoked_kids` | No | `governance_token_revoked` | Reject. | | `iss` not in buyer brand.json | No | `governance_issuer_not_authorized` | Reject. Possibly indicates a spoofing attempt. | | Revocation list not refreshed within grace | No (block new) | `governance_revocation_stale` | Reject new tokens until revocation list refreshes. Existing fully-verified tokens may continue to be trusted within their existing grace. | | `aud` mismatch, `sub` mismatch, `phase` mismatch, `media_buy_id` mismatch | No | `governance_token_not_applicable` | Reject. Token valid but not for this operation. | Servers MUST NOT echo internal verification details (e.g., which specific claim mismatched) to the counterparty. Return the stable code above; log the detail server-side. #### Privacy considerations **`policy_decisions` visibility**: the token is a JWS (readable by anyone with the public key), not a JWE (encrypted). If `policy_decisions` contains the full list of policy IDs the governance agent evaluated, every seller who receives the token learns which policies the buyer's governance posture considers — competitive intelligence, and in some cases signaling about sensitive audience characteristics (e.g., a `minors_compliance` policy ID implies targeting of under-18 audiences). Governance agents SHOULD use `policy_decision_hash` in place of `policy_decisions` when the buyer's compliance posture is sensitive; the full log remains available to auditors via `audit_log_pointer` with governance-agent-controlled access. **Intent-phase seller disclosure to GA**: the `aud` binding means a buyer evaluating N sellers in a competitive auction must request N distinct intent tokens, each `aud`-bound to one seller. The governance agent therefore sees the full list of sellers the buyer considered — a privacy regression relative to the opaque-string model where sellers were unknown to the GA at intent time. This is an explicit trade-off: cross-seller replay resistance requires per-seller binding. A future `aud_hash` mechanism (where the token binds a hash of the seller URL with a token-scoped salt, and each seller computes the hash on its own URL to verify) can recover intent-time seller privacy against the GA without sacrificing replay resistance. Not defined in 3.0; tracked as a follow-up. **`caller` URL**: contains the orchestrator's identifier. Sellers and auditors who retain tokens long-term should be aware of the retention policy implied by this. #### Reference implementation **Decoded example token (intent phase)**: Header: ```json theme={null} { "alg": "EdDSA", "kid": "gov-2026-04", "typ": "adcp-gov+jws" } ``` Payload: ```json theme={null} { "iss": "https://gov.scope3.com", "sub": "plan_q1_2026_launch", "plan_hash": "EiCW8FkxgZ2wKqGv3Z9XuT4n2LwcJm1fK7vRaTpQ0sU", "aud": "https://seller.example.com/adcp", "iat": 1744934400, "exp": 1744935300, "jti": "01HWZXABCDEFG1234567890", "phase": "intent", "caller": "https://orchestrator.example.com", "check_id": "chk_001", "policy_decision_hash": "9b2a...f41c", "audit_log_pointer": "https://gov.scope3.com/plans/plan_q1_2026_launch/logs/01HWZXABCDEFG1234567890" } ``` **Seller verifier (TypeScript, \~30 lines with `jose`)**: ```ts theme={null} import { createRemoteJWKSet, decodeProtectedHeader, decodeJwt, jwtVerify } from "jose"; class GovTokenError extends Error { constructor(public code: string) { super(code); } } const jwksCache = new Map>(); function jwksFor(jwksUri: string) { let jwks = jwksCache.get(jwksUri); if (!jwks) { // ssrfValidatedFetch enforces the Webhook URL validation rules on the JWKS URL jwks = createRemoteJWKSet(new URL(jwksUri), { cacheMaxAge: 15 * 60 * 1000, cooldownDuration: 30 * 1000, [Symbol.for("fetch")]: ssrfValidatedFetch }); jwksCache.set(jwksUri, jwks); } return jwks; } export async function verifyGovernanceContext(token: string, ctx: { sellerId: string; planId: string; mediaBuyId?: string; phase: "intent" | "purchase" | "modification" | "delivery"; resolveBrandJsonGovernanceAgent: (iss: string) => Promise<{ jwks_uri: string } | null>; seenJti: (iss: string, aud: string, jti: string) => Promise; isRevoked: (iss: string, jti: string, kid: string) => Promise; revocationFresh: (iss: string) => Promise; }) { const header = decodeProtectedHeader(token); if (header.typ !== "adcp-gov+jws") throw new GovTokenError("governance_token_invalid"); if (!["EdDSA", "ES256"].includes(header.alg ?? "")) throw new GovTokenError("governance_token_invalid"); const { iss } = decodeJwt(token); const agent = await ctx.resolveBrandJsonGovernanceAgent(iss as string); if (!agent) throw new GovTokenError("governance_issuer_not_authorized"); const { payload } = await jwtVerify(token, jwksFor(agent.jwks_uri), { issuer: iss as string, audience: ctx.sellerId, typ: "adcp-gov+jws", algorithms: ["EdDSA", "ES256"], clockTolerance: 60, }).catch(() => { throw new GovTokenError("governance_token_invalid"); }); if (payload.sub !== ctx.planId) throw new GovTokenError("governance_token_not_applicable"); if (payload.phase !== ctx.phase) throw new GovTokenError("governance_token_not_applicable"); if (ctx.phase !== "intent" && payload.media_buy_id !== ctx.mediaBuyId) throw new GovTokenError("governance_token_not_applicable"); if (!(await ctx.revocationFresh(iss as string))) throw new GovTokenError("governance_revocation_stale"); if (await ctx.isRevoked(iss as string, payload.jti as string, header.kid as string)) throw new GovTokenError("governance_token_revoked"); if (await ctx.seenJti(iss as string, ctx.sellerId, payload.jti as string)) throw new GovTokenError("governance_token_replayed"); return payload; } ``` **Migration dual-path (sellers during 3.0)**: ```ts theme={null} const JWS_COMPACT = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/; function handleGovernanceContext(value: string, ctx) { persistOpaque(value); // always persist and forward for auditor use if (!JWS_COMPACT.test(value)) return; // pre-3.0 opaque value, nothing to verify return verifyGovernanceContext(value, ctx); // throws on any failure } ``` #### Migration (3.0 → 3.1) * **3.0**: governance agents MUST emit compact JWS per this profile, including the required `plan_hash` audit-layer claim (see [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit) for semantics). Sellers MAY verify the 15-step checklist; sellers that do not verify MUST persist and forward the token unchanged. Values that are not JWS are deprecated and SHOULD only appear from pre-3.0 governance agents during the transition; governance agents that emit non-JWS values in 3.0 MUST declare this in their capabilities so sellers can detect unverifiable deployments. * **3.1**: all sellers MUST verify per the 15-step checklist. Governance agents MUST emit JWS. Non-JWS values will be rejected end-to-end. `plan_hash` remains audit-layer (governance-agent / auditor / buyer-compliance verification only — not seller verification). The field name and schema shape (single string, ≤4096 chars) do not change between versions. Only the string's internal format is tightened. This preserves the correlation-key semantics from earlier protocol versions — sellers that already treat the value as opaque need no changes to continue forwarding; sellers that want the accountability properties opt in by implementing the verification checklist. ### Signed Requests (Transport Layer) [Signed Governance Context](#signed-governance-context) signs an authorization artifact. Request signing signs the request itself — method, target URI, headers, and (by default) body bytes — establishing cryptographically that a specific agent issued the request, with replay and tampering protection. A valid signature proves only one thing: **the request came from the agent whose key signed it.** Whether that agent is *authorized* to act for the brand named in the request body is a separate concern, governed by the target house's `authorized_operator[]` in brand.json. This section defines authentication only; authorization lookup is specified by the brand.json schema and happens whether requests are signed or not. AdCP 3.0 defines this profile as **optional and capability-advertised** via `request_signing` on `get_adcp_capabilities`. AdCP 4.0 — the next breaking-changes accumulation window — will require it for spend-committing operations. The substrate ships in 3.0 so early adopters can surface canonicalization and proxy interop bugs before enforcement. See [Transport migration timeline](#transport-migration-timeline). **Roles:** * **Agents** sign requests with a key published at their own `jwks_uri` in their operator's brand.json `agents[]` entry. The operator (the domain hosting brand.json) may be a house buying direct or an authorized third party — this profile does not distinguish. The signer is always an agent. * **Sellers** verify the signature against the signing agent's published key, establishing agent identity. Sellers then perform the separate brand-operator authorization check (outside this profile's scope). * **Sellers calling agent-side AdCP endpoints** (e.g., buyer-hosted mutation callbacks that are themselves AdCP protocol calls) sign their outgoing requests symmetrically; the receiving agent verifies against the seller's keys published under the seller operator's brand.json `agents[]` entry. Push-notification webhook callbacks (`push_notification_config.url` and similar asynchronous one-way notifications) are covered by the symmetric [Webhook callbacks](#webhook-callbacks) variant of this profile — the seller signs outbound with its `adcp_use: "request-signing"` key (the deprecated `"webhook-signing"` value is still accepted) and the buyer verifies. **Dependencies:** * Shares JWKS discovery, SSRF rules, alg allowlist, revocation semantics, and key rotation with the [AdCP JWS profile](#adcp-jws-profile) above. Request verification never accepts another key purpose: a request-signing JWK MUST declare `"adcp_use": "request-signing"`, `"use": "sig"`, `"key_ops": ["verify"]`, and a `kid` that does not appear on any other JWKS entry with a different `adcp_use`. Verifiers enforce all four; see [Agent key publication](#agent-key-publication). The webhook path has its own explicit relaxation because the webhook `tag` provides domain separation. * Resolves the identity-bootstrapping dependency in [Buyer identity resolution](#buyer-identity-resolution) for governance: a seller that verifies a request signature has a cryptographically established signing agent identity and MAY use the signing agent's operator domain as the brand.json resolution input for the governance verification step. **Conformance.** Verifier behavior is graded by the universal capability-gated storyboard at [`/compliance/latest/universal/signed-requests`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests), which runs for any agent advertising `request_signing.supported: true`. The storyboard exercises every step in the [verifier checklist](#verifier-checklist-requests) below and every canonicalization-edge rule in this profile, against the test vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/). To run the CLI grader against your own agent, see [Auth Graders](/dist/docs/3.1.20/building/verification/grading). **No general-purpose RFC 9421 response-signing profile.** This profile signs the *request*; AdCP 3.x defines no general-purpose paired profile for signing the synchronous response *transport*. Sellers MUST NOT apply RFC 9421 §2.2.9 response signing to synchronous AdCP responses (whether MCP `tools/call` or A2A non-streaming responses including streaming `artifactUpdate` frames), and buyers MUST NOT rely on an RFC 9421 response signature on the synchronous reply. Integrity of the immediate response transport rests on TLS within the authenticated session that carried the request, modulo the standard edge-termination caveats that govern request-side body integrity at body-modifying CDNs. Durable at-rest attestation for artifacts that need to survive past the session — including specialism-scoped payloads (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts, bilateral non-repudiation receipts such as `plan_receipt`) — is the job of [signed webhooks](#webhook-callbacks) (signed with the `adcp_use: "request-signing"` key). The split is deliberate — see [Security Model: What gets signed](/dist/docs/3.1.20/building/concepts/security-model#what-gets-signed--and-what-doesnt) for the full rationale and the request-the-webhook pattern for tools whose canonical artifact needs to be attestable. **Designated-task payload-envelope response signing.** A closed list of tasks designates their response *payload* as cryptographically signed under `adcp_use: "response-signing"`. The primitive is distinct from RFC 9421 §2.2.9 transport response signing on two load-bearing axes: * **Signature location:** inside the response body, not in HTTP response headers. * **Verification path:** parse the response body, then verify the JWS against the responding agent's `response-signing` JWK published at the agent's `jwks_uri` — not RFC 9421 base reconstruction over transport headers. A task is admitted to the designated list only when its response payload is the canonical attestable artifact AND no webhook-emit restructuring is feasible (see the [request-the-webhook pattern](/dist/docs/3.1.20/building/concepts/security-model#the-request-the-webhook-pattern) for the default path). The list in 3.x is closed at: * **`verify_brand_claim`** and its bulk variant **`verify_brand_claims`** (Brand Protocol). The responding brand-agent signs its response payload as a JWS envelope under the brand's `adcp_use: "response-signing"` key. The signature is load-bearing for the direction-asymmetric trust model — see [`verify_brand_claim` trust model](/dist/docs/3.1.20/brand-protocol/tasks/verify_brand_claim#trust-model) and [Building a brand agent — Signing setup](/dist/docs/3.1.20/brand-protocol/building-a-brand-agent#signing-setup). Any task not on this list MUST NOT sign its response under any signing primitive. General-purpose response-signing helpers applying RFC 9421 §2.2.9 to arbitrary tools (regardless of which `tag` or `adcp_use` string they coin) are operating outside this profile and are not 3.x-conformant; the only response-signing primitive the spec authorizes in 3.x is payload-envelope JWS on the designated-tasks list. The `adcp_use: "response-signing"` value is therefore reserved at the JWK layer for the payload-envelope primitive. **Keys published with `adcp_use: "response-signing"` MUST sign only payload-envelope JWS as defined in this section; using such a key to produce RFC 9421 §2.2.9 transport signatures is a profile violation regardless of the task being signed.** If a future major version scopes RFC 9421 transport response signing for any task, it MUST use a distinct `adcp_use` value (e.g., `"response-transport-signing"`) so verifiers can disambiguate the primitive from the JWK alone — the brand-protocol value cannot be retconned to cover both. List growth and additional primitives are normative decisions deferred to future spec versions. Designated-task success responses MUST carry a `signed_response` member matching [`response-payload-jws-envelope.json`](https://adcontextprotocol.org/schemas/3.1.20/core/response-payload-jws-envelope.json). The envelope payload is the canonical signed task-body object and MUST include `typ: "adcp-response-payload+jws"`, `task`, `brand_domain`, `agent_url`, `request_hash`, `iat`, `exp`, and `response`. The outer task-body fields are convenience fields for ordinary task consumers; verifiers that rely on the signature MUST reject the envelope if any unsigned task-body field disagrees with `signed_response.payload.response`. Protocol/version envelope fields are excluded from this comparison, including `status`, `context_id`, `task_id`, `message`, `timestamp`, `replayed`, `adcp_version`, and `adcp_major_version`. This profile uses ordinary JWS signing, not RFC 7797 unencoded payloads. The JWS Signing Input is `BASE64URL(UTF8(protected)) || "." || BASE64URL(UTF8(JCS(payload)))`, where `payload` is `signed_response.payload` and `protected` decodes to `{ "alg": "EdDSA" | "ES256", "kid": "...", "typ": "adcp-response-payload+jws" }`. The protected header MUST NOT contain `b64`. Response verifiers MUST enforce the shared JWS discovery and hardening rules from the [AdCP JWS profile](#adcp-jws-profile): allowed algorithms, `use: "sig"`, `key_ops` containing `"verify"`, exact `adcp_use: "response-signing"`, missing-`kid` refetch, revocation checks, SSRF-safe JWKS fetches, and duplicate-key rejection before canonicalization. `request_hash` is `sha256:` plus unpadded base64url SHA-256 of the JCS canonical request-binding object `{ task, brand_domain, agent_url, caller_identity, request }`. `caller_identity` MUST be a typed canonical string derived from the authenticated transport or credential mapping, such as `signed-agent-url:`, `api-client-id:`, or `mtls-san:`. If no authenticated caller identity exists, `caller_identity` is `null` and the verifier MUST treat the response as weaker evidence that is not bound to a caller. `brand_domain` is a tenant-binding field, not an echo. A multi-brand agent MUST populate it from its server-side tenant resolution and the brand.json entry whose policy store produced the answer; it MUST NOT copy it from the request body. `agent_url` is the canonical URL of the responding `agents[]` entry whose `response-signing` JWK verifies the envelope. Online verifiers MUST reject envelopes at or after `exp` after applying only small clock-skew tolerance; audit verifiers MAY verify after `exp`, but only as historical evidence that the brand-agent signed that payload during the stated `iat`/`exp` window. Response-signing keys are scoped by brand tenant as well as by purpose. A shared multi-brand fleet MUST publish distinct response-signing key material and distinct `kid` values for each `brand_domain` it serves, even when the same software and `agent_url` serve multiple brands. Cross-brand reuse of a response-signing JWK is a profile violation because it defeats tenant-bound replay analysis; this rule is stricter than ordinary cross-purpose separation and applies only to `adcp_use: "response-signing"` keys. #### Transport scope | Class | 3.0 | 4.0 | | ----------------------------------------------------------------------------------------- | --------------------------------------- | ------------ | | Spend-committing (`create_media_buy`, `update_media_buy`, `acquire_*`, `activate_signal`) | Optional, capability-advertised | Required | | Reversible state changes (`sync_creatives`, `update_creative_status`) | Optional | Recommended | | Read / discovery (`get_products`, `get_media_buy_delivery`, `list_*`) | Not in scope | Not in scope | | TMP `provider_endpoint_url` requests | Out of scope (TMP has its own envelope) | Out of scope | Read calls remain bearer-authenticated. Signing read traffic adds verification cost without proportionate benefit; signing's purpose is integrity of state-changing operations. #### Quickstart: opt into request signing in 3.0 For implementers who want to pilot signing in 3.0 before the 4.0 flip: **As an agent that signs requests:** 0. Call `get_adcp_capabilities` on the target seller. Read `request_signing.supported_for` and `required_for` to see which AdCP operations the seller expects you to sign, and read `request_signing.protocol_methods_supported_for` / `protocol_methods_required_for` to see which JSON-RPC protocol methods (e.g., `tasks/cancel`) the seller's verifier covers. Read `covers_content_digest` (`"required"` / `"forbidden"` / `"either"`) to see whether you must, must not, or may cover `content-digest`. 1. Generate an Ed25519 keypair: `openssl genpkey -algorithm ed25519 -out signing-key.pem`. 2. Export the public key as a JWK. Add `"kid"`, `"use": "sig"`, `"key_ops": ["verify"]`, `"adcp_use": "request-signing"`, and `"alg": "EdDSA"`. 3. Publish the JWK at your agent's `jwks_uri` (the URL declared on your `agents[]` entry in brand.json; defaults to `/.well-known/jwks.json` at your agent URL's origin). 4. Configure your AdCP client with the private key and agent URL. Your SDK signs requests automatically for any operation listed in the seller's `supported_for` or `required_for` capability and any JSON-RPC method listed in `protocol_methods_supported_for` or `protocol_methods_required_for`, honoring the seller's `covers_content_digest` policy. SDKs SHOULD support pluggable signers so the private key can live in a managed key store (KMS / HSM / Vault) rather than in process memory — see [Production key storage](#production-key-storage) below. 5. Validate end-to-end with the conformance vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/) (published per AdCP version; source lives at `static/compliance/source/test-vectors/request-signing/`) — if your client produces signatures that match the positive vectors' `expected_signature_base`, you're done. **As a verifier (seller):** 1. Advertise `request_signing.supported: true` in `get_adcp_capabilities`. Leave `required_for: []` during the pilot; add operations incrementally per counterparty. 2. Enable signature verification middleware on mutating routes. Implement the [verifier checklist](#verifier-checklist-requests) — all 14 checks (13 numbered steps plus sub-step 9a), short-circuit on first failure. 3. Start in shadow mode (verify and log; do not reject on failure) for a pilot counterparty before populating `required_for`. Surface verification failures in monitoring rather than operations for the first few weeks. 4. Run the conformance negative vectors against your verifier — each rejection MUST produce the vector's stated `error_code`. The vector's `failed_step` is informational; an implementation that rejects with the correct error code is conformant even if its internal step numbering differs. **Minimum viable verifier (3.0 shadow mode):** steps 1–9, 9a, and 10 of the checklist, in-memory replay cache, one-minute revocation polling with a lightweight `kid`-membership check (full grace semantics deferred). This is acceptable for log-and-observe shadow mode because no request is being rejected on replay or digest failure. **Before adding any operation to `required_for`, implement steps 11–13** — digest recompute (step 11), replay insert after success (step 13), and the full revocation-stale grace window (part of step 9). Flipping to enforce with an incomplete verifier surfaces replay and body-integrity gaps on live production traffic rather than in shadow logs. Do not skip ahead of step 1 — malformed signatures always reject, never fall back. #### Production key storage Where the signer's private key lives is implementation-defined — the spec is concerned only with the bytes on the wire — but operators SHOULD avoid holding private signing keys in process memory in production. A process compromise leaks the signing key, and the only remedy is rotation across every counterparty that's cached the public key (within their cache TTL). The recommended pattern: an SDK exposes a pluggable signer interface (e.g., `sign(payload: Uint8Array): Promise`), and the operator's adapter delegates the operation to a managed key store — AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault Transit, or an HSM. The key never leaves the managed store; the SDK builds the canonical signature base, the store signs it, the SDK assembles `Signature` and `Signature-Input` headers from the returned bytes. Wire format is identical to in-process signing. Two implementation notes for adapter authors: * ECDSA-P256 signatures returned by most KMS APIs are DER-encoded; this profile and RFC 9421 §3.3.1 require IEEE P1363 (`r‖s`, 64 bytes for P-256). Convert at the adapter boundary. * Treat the KMS key as single-purpose. The `tag` parameter in this profile protects verifiers, not signers — an operator who reuses the same KMS key for AdCP request-signing and any other signing protocol creates a cross-protocol oracle. Bind the KMS access policy (GCP `roles/cloudkms.signer` scoped to the specific cryptoKey, AWS `kms:Sign` conditioned on the key ARN) so only the AdCP signing path can invoke the key. Reference implementations: `@adcp/sdk` (TypeScript) ships a `SigningProvider` interface with sync/async parity, an in-memory provider for tests, and a GCP KMS reference adapter at [`examples/gcp-kms-signing-provider.ts`](https://github.com/adcontextprotocol/adcp-client/blob/main/examples/gcp-kms-signing-provider.ts). See the [SDK signing guide](https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/SIGNING-GUIDE.md#step-35-production-key-storage--kms--hsm--vault) for the full walkthrough. **Tripwire pattern — assert public key at init.** Managed key stores can silently rotate (IAM policy swap, version disable, hostile substitution). If rotation happens without updating the published JWKS, verifiers fetching the unchanged `kid` will reject every signature with no clear error signal — the operator sees counterparty failures, not a KMS mismatch. The defense: commit the expected public key (SPKI bytes, base64-encoded) alongside the code, and at signer init byte-compare it against the key the store returns (`getPublicKey()` or equivalent). A mismatch fails loudly at startup rather than silently on every signed call. Rotation then becomes a deliberate two-step: update the pinned constant, set the new key version path, deploy. **Lifecycle: lazy init, not eager.** Calling `getPublicKey` (or any KMS warm-up call) before the process binds its listener looks clean in review but has a dangerous failure mode: if KMS auth is misconfigured, gRPC / TLS retries inside the KMS client can block indefinitely, the process never opens its port, and the infrastructure health-check times out — surfacing a "service unreachable" alarm rather than the underlying KMS error. The correct lifecycle is lazy init on first sign: call the store the first time a request needs signing, cache the result only on success (never cache errors), and deduplicate concurrent first-call requests with an in-flight promise. Fail-fast misconfig detection belongs in a CI/CD pre-deploy probe that exercises the KMS path with the deployment target's credentials before cutover — not at process startup. **One JWK per `adcp_use` — publication shape.** The single-purpose rule applies to key material **and** to JWKS publication. Note that webhooks do not need their own purpose: they are signed with a `"request-signing"` key (see [Webhook callbacks](#webhook-callbacks), step 8), so an operator that signs both requests and webhooks with the same key publishes a single `"request-signing"` entry. An operator that wants separate key material for webhooks (blast-radius isolation) publishes a **second `"request-signing"` key with a distinct `kid`** — isolation comes from the `kid`, not a distinct `adcp_use`. The `adcp_use` value is always a **string**, not an array — publishing `"adcp_use": ["request-signing","webhook-signing"]` on a single entry is a schema error that receivers will reject: ```json theme={null} { "keys": [ { "kty": "OKP", "crv": "Ed25519", "x": "SRYr8eSvjkZF6dAUquI1sKuU4YGZkoGH-2jwkz4dRJg", "kid": "acme-signing-2026-04", "alg": "EdDSA", "use": "sig", "adcp_use": "request-signing", "key_ops": ["verify"] }, { "kty": "OKP", "crv": "Ed25519", "x": "lHJI-IvBwCE36heDNOyBmCk5UMKRIs4b4BAWJRgao-M", "kid": "acme-webhook-2026-04", "alg": "EdDSA", "use": "sig", "adcp_use": "request-signing", "key_ops": ["verify"] } ] } ``` This second entry is the optional webhook-isolation key from the [Key publication](#webhook-callbacks) section: same `adcp_use: "request-signing"`, distinct `kid`, used to sign webhooks so a webhook-key compromise doesn't extend to request signing. Distinct `kid` values also mean counterparties can cache and rotate the two keys independently. #### AdCP RFC 9421 profile This profile constrains RFC 9421 to a single canonical shape so cross-implementation interop is tractable. **Covered components (REQUIRED on every signed request):** | Component | Notes | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `@method` | Uppercase. | | `@target-uri` | Canonicalized per the algorithm below. Signer MUST apply canonicalization before computing the signature base; verifier MUST apply the same canonicalization to the received request before verifying. | | `@authority` | Lowercased `host[:port]`, default ports (`443` for https, `80` for http) stripped. | | `content-type` | Required on requests with bodies. | | `content-digest` | Governed by the verifier's `request_signing.covers_content_digest` capability — see [Content-digest and proxy compatibility](#content-digest-and-proxy-compatibility). | **`@target-uri` canonicalization** follows the [AdCP URL canonicalization rules](/dist/docs/3.1.20/reference/url-canonicalization) — eight steps applying RFC 3986 §6.2.2 (syntax-based normalization) and §6.2.3 (scheme-based normalization), plus UTS-46 Nontransitional IDN processing and IPv6 zone-identifier rejection. Signers and verifiers apply the same algorithm; malformed authorities rejected there map to `request_target_uri_malformed` on the signing path. The authoritative algorithm, conformance vectors, and pitfalls list live on that page — keeping this profile's treatment thin prevents divergence between the signing-specific copy and the general-purpose copy. **`@authority` canonicalization** produces `host[:port]` from the URL's authority after the canonicalization algorithm's host and port steps (lowercase host / IDN → ACE / IPv6 bracketing preserved; userinfo stripped; default port stripped). IPv6 hosts retain their brackets in `@authority` (`[::1]:8443`). Verifiers MUST derive `@authority` from the HTTP/2+ `:authority` pseudo-header when present, otherwise from the as-received HTTP/1.1 `Host` header — not from reverse-proxy routing state, load-balancer metadata, or any `Host` value a forward proxy may have rewritten in transit. **When both `:authority` and `Host` are present on the as-received request** (HTTP/2→HTTP/1.1 translating intermediaries are permitted to leave both by RFC 7540 §8.1.2.3, which requires equivalence but does not require stripping the source), verifiers MUST reject with `request_target_uri_malformed` if they are not byte-equal after canonicalization; pick-one behavior is a silent downgrade surface. Regardless of the source header, the canonicalized value MUST byte-for-byte match the authority component of the canonical `@target-uri` — the byte-match against the signed `@target-uri` is the load-bearing safety gate, because `Host` can itself be rewritten in transit. Mismatch rejects with `request_target_uri_malformed`. This closes a cross-vhost replay vector: an attacker who intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool (same cert SAN, different `Host`) will fail the authority-match check even though the signature covers `@authority`. Signers that canonicalize and verifiers that canonicalize MUST produce identical bytes for the same logical request. If your 9421 library applies different rules, either configure it to match this profile or normalize before handing the URL to the library. The [`canonicalization.json`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/canonicalization.json) conformance set exercises every rule from the algorithm with fixed inputs and expected outputs, plus malformed-authority rejection cases. SDKs SHOULD run this set on every commit — canonicalization divergence between signers is silent until it isn't, and then it's a production interop bug that's painful to diagnose. Verifiers MUST reject signatures whose covered-component list omits any required component for the request type. Signers MUST NOT cover additional headers without coordination — extra components silently invalidate signatures across implementations that don't include them. **Signature parameters (`Signature-Input` parameters, all REQUIRED):** | Parameter | Notes | | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `created` | Unix seconds. Reject if more than 60 s in the future. | | `expires` | Unix seconds. MUST satisfy `expires > created` and `expires − created ≤ 300` (5-minute max validity). Reject if past, with ±60 s skew tolerance. | | `nonce` | Base64url-encoded, unpadded (no trailing `=`). Verifiers MUST reject if the decoded byte length is less than 16 bytes, or if the value includes padding. This is how the "≥ 128 bits of entropy" requirement is enforced in practice. | | `keyid` | Matches a `kid` in the signer's published JWKS. | | `alg` | MUST be `ed25519` or `ecdsa-p256-sha256`. Verifiers MUST enforce the allowlist independently of library defaults. | | `tag` | MUST be exactly `adcp/request-signing/v1` — byte-for-byte match, no prefix matching, no case-folding. The `tag` sig-param MUST appear exactly once in `Signature-Input`; verifiers MUST reject duplicates. The tag namespace is how the profile versions; future versions bump the tag rather than mutating parameter semantics, and `adcp/request-signing/v2` verifiers will reject `v1` signatures and vice versa. | All six parameters are REQUIRED. Verifiers MUST reject (`request_signature_params_incomplete`) if any is absent. **Algorithm naming — JWK vs RFC 9421.** The two names for each algorithm differ by source spec. Implementations mix these up often enough to warrant a table: | Algorithm | JWK `alg` (in JWKS) | RFC 9421 `alg` (in `Signature-Input`) | | ------------------------ | ------------------- | ------------------------------------- | | Ed25519 | `EdDSA` | `ed25519` | | ECDSA P-256 with SHA-256 | `ES256` | `ecdsa-p256-sha256` | When the verifier resolves a `keyid` and finds `"alg": "EdDSA"` on the JWK, the matching sig-param value is `ed25519`. Implementations should validate that the two match (JWK alg matches the sig-param alg by mapping table) in addition to verifying the allowlist on each independently. Edge-runtime rationale from the governance profile applies — `ES256` is the edge-friendly alternative where `EdDSA` requires runtime configuration. **One signature per request.** Verifiers MUST process exactly one `Signature-Input` label (conventionally `sig1`) and MUST ignore any additional labels present in the request. Intermediaries that need to re-sign a relayed request MUST replace the upstream labels rather than append to them. Full relay-chaining semantics (when a relay wants to preserve the originator's signature) are tracked in [#2324](https://github.com/adcontextprotocol/adcp/issues/2324) and out of scope for 3.0. **Binary value encoding (`Signature`, `Content-Digest`).** RFC 9421 §3.1 and §2.1.3 emit binary values as the RFC 8941 Structured Field `sf-binary` token (`::`), and RFC 8941 §3.3.5 specifies the standard base64 alphabet (RFC 4648 §4) with `+`/`/` and `=` padding. The AdCP profile OVERRIDES this: `Signature` and `Content-Digest` sf-binary values MUST be encoded with **base64url without padding** (RFC 4648 §5), producing tokens whose inner bytes draw from `[A-Za-z0-9_-]` with no trailing `=`. Rationale: URL-safe, pad-free, and symmetric with the `nonce` sig-param which is already specified base64url-unpadded. It avoids the two interop hazards of standard base64 in HTTP header values — `/` that some proxies rewrite and `=` that some header parsers treat as a structured-field parameter delimiter. Verifier requirements: 1. Signers MUST emit base64url-no-padding only. A signer that emits a `Signature` or `Content-Digest` value containing `+`, `/`, or `=` is non-conformant. 2. Verifiers MUST accept base64url-no-padding. Verifiers SHOULD ALSO lenient-decode pure standard-base64 tokens (translate `+`→`-` then `/`→`_`, then strip any trailing `=`, then base64url-decode) for interop with counterparties that predate this clarification. This lenience is a compatibility affordance scheduled for removal in **AdCP 3.2** — signers relying on it MUST migrate to base64url-no-padding before then. 3. Verifiers MUST reject any token that mixes alphabets (any character in `[+/=]` AND any character in `[-_]` within the same token value) with `request_signature_header_malformed`. Mixed-alphabet tokens are ambiguous: `A+B-` could decode to different bytes depending on the order of "translate standard-base64 chars" and "base64url-decode" steps, and differing `Content-Digest` bytes across verifiers let an attacker stage a digest mismatch that one verifier accepts and another rejects. 4. The `expected_signature_base` field in the conformance vectors is independent of binary-value encoding — it contains the canonical signature base bytes, not any header-field encoding. Only the emitted `Signature` token itself is encoded. **Note on `Content-Digest` from non-AdCP upstreams.** RFC 9530 §2 defines `Content-Digest` and defers sf-binary to RFC 8941 (standard base64), so a conformant 9530 emitter from another ecosystem (a CDN, a non-AdCP framework) may populate `Content-Digest` on an inbound request using the RFC 8941 default. The AdCP override above applies to **signed AdCP requests**; verifiers processing such a request MUST use the override rules. Verifiers handling unsigned traffic or `Content-Digest` from non-AdCP upstreams MAY accept either encoding — this is outside the signing profile's scope. **Operation names in `required_for` / `supported_for` are AdCP protocol operation names** (`create_media_buy`, `update_media_buy`, `acquire_rights`, etc.) — not MCP tool names, A2A skill names, or any transport-specific rename. Verifiers MUST NOT accept operation names that are not defined by the AdCP protocol spec. This is how cross-transport verifiers agree on what "signed for `create_media_buy`" means. **Protocol-method coverage (`protocol_methods_*`).** AdCP operations are not the only mutating surface a counterparty calls: A2A 0.3.0 §7.x defines task-lifecycle methods (`tasks/cancel`, `tasks/get`, `tasks/resubscribe`) that traverse the same authenticated channel, and the MCP transport auto-registers the same `tasks/*` JSON-RPC methods when an SDK task store is wired. Sellers declare verifier coverage of these methods in a separate namespace from the AdCP operation list: | Field | Contents | Match semantics | | ------------------------------------------------ | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | | `request_signing.protocol_methods_supported_for` | JSON-RPC method strings (e.g., `"tasks/cancel"`) | Verifier accepts and validates a signature when the JSON-RPC `method` field of the inbound request matches. | | `request_signing.protocol_methods_warn_for` | Same | Shadow-mode mirror of `warn_for`: log failures, do not reject. | | `request_signing.protocol_methods_required_for` | Same | Reject unsigned matches with `request_signature_required`. | The matched value is the JSON-RPC envelope's `method` field (`tasks/cancel`, `tasks/get`, …), **not** the MCP `tools/call` `params.name`. AdCP tool names (no `/`) MUST NOT appear in any `protocol_methods_*` array, and JSON-RPC method names (containing `/`) MUST NOT appear in `supported_for` / `warn_for` / `required_for`. Verifiers MUST reject capability blocks that violate the namespace split with a configuration-time error rather than silently coercing strings between the two. **Verifiers MUST NOT cross-namespace match: a `protocol_methods_required_for` membership MUST NOT be satisfied by a body whose JSON-RPC `method` is `tools/call` (even if `params.name` happens to equal a listed method string), and a `required_for` membership MUST NOT be satisfied by a body whose JSON-RPC `method` is anything other than `tools/call`.** The two buckets are matched against disjoint envelope fields. The signature-base construction is identical for both namespaces: the same RFC 9421 covered components apply (`@target-uri`, `@method`, `content-digest` per the seller's `covers_content_digest` policy, `authorization` when present), with `@target-uri` and `@method` reflecting the actual HTTP request — not the JSON-RPC method string. Buyers signing a `tasks/cancel` POST sign exactly as they would for any other mutating call; the only thing the new fields change is the seller's declaration of which JSON-RPC methods are in scope for verification. **Cross-namespace replay risk on shared transport.** When a single `@target-uri` accepts both `tools/call` envelopes and JSON-RPC protocol methods (the canonical MCP layout — both POST to `/mcp`), `@target-uri` and `@method` alone do not bind which JSON-RPC method the body invokes; the `method` field lives in the body. Without `content-digest` coverage, an on-path attacker who captures a signed `tools/call` request can swap the body to `{"method":"tasks/cancel",...}` (or vice-versa) within the signature window and the verifier will accept it. Sellers that populate `protocol_methods_required_for` (or any `protocol_methods_*`) on a transport shared with `tools/call` therefore SHOULD set `covers_content_digest: 'required'` so the body — and through it the JSON-RPC method — is bound to the signature. Sellers that cannot adopt `'required'` MUST mount AdCP and protocol-method traffic on distinct `@target-uri`s so that `@target-uri` itself partitions the namespaces. Buyers reading capability blocks in 3.x MUST NOT assume protocol-method coverage from `supported_for` / `required_for`: a seller that lists `create_media_buy` in `required_for` and is silent on `protocol_methods_*` is not declaring `tasks/cancel` coverage. Buyer SDKs that sign `tasks/cancel` opportunistically (the only defensible default when the seller is silent) MAY do so without violating the spec, but interoperable enforcement only emerges once the seller populates `protocol_methods_supported_for` or `protocol_methods_required_for`. #### Agent key publication Request-signing keys live at the signing agent's own `jwks_uri` in its operator's brand.json `agents[]` entry, and outbound webhooks are signed with `"request-signing"` keys (optionally with distinct webhook-only key material under a separate `kid`). Every agent that signs — of any `type` — uses the same publication pattern. Publisher `adagents.json` may additionally pin allowed seller keys through `authorized_agents[].signing_keys[]`; when present, that pin is authoritative for the scoped sell-side authorization. **Publisher pin precedence.** When a publisher's `adagents.json` entry for an authorized agent carries a `signing_keys` pin (see [`adagents.json` §`signing_keys`](/dist/docs/3.1.20/governance/property/adagents#signing_keys)), that pin is authoritative: verifiers MUST reject any signature whose `keyid` is not in the pinned set, regardless of `jwks_uri` contents. The agent-hosted JWKS is advisory whenever a publisher pin exists. This closes the agent-domain-compromise window — an attacker who takes over the agent's domain cannot silently swap both the endpoint and its advertised keys because the publisher's pin still governs acceptance. Publishers are required to pin for any agent whose delegated scopes include mutating operations; see the adagents.json rule for rotation and cache semantics. Each request-signing JWK entry MUST declare: | Member | Value | Notes | | ---------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `use` | `"sig"` | Standard JWK signing use. | | `key_ops` | `["verify"]` | Verifier-visible JWKS declares verify-only. The signing operator holds the corresponding private key locally with `["sign"]` per JWK spec. | | `adcp_use` | `"request-signing"` | AdCP-specific purpose discriminator. Distinguishes from `"governance-signing"` (JWS profile) and any future AdCP signing purpose. Verifiers MUST reject any JWK with absent or different `adcp_use` when verifying a request signature. The same `"request-signing"` key (or a second one under a distinct `kid`) also signs outbound webhooks — see [Webhook callbacks](#webhook-callbacks). (`"webhook-signing"` is a deprecated purpose pending removal — see [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); still accepted on the webhook path for backward compatibility.) | | `kid` | distinct | Unique within the JWKS. MUST NOT collide with any other entry's `kid` regardless of `adcp_use`. | | `alg` | `"EdDSA"` or `"ES256"` | Must match the signature's `alg` parameter (JWK `alg` uses JWS names; `alg` in `Signature-Input` uses RFC 9421 names). | Cross-purpose key reuse is forbidden and **locally enforceable** via `adcp_use`, except for the explicit webhook relaxation above: a `"request-signing"` key may sign either requests or webhooks because the RFC 9421 `tag` separates those profiles. A single JWK entry can only declare one `adcp_use` value, so a publisher cannot accidentally (or deliberately) present a governance-signing key as a valid request-signing key. Verifiers check `adcp_use` on the JWK they fetched, not across other JWKS endpoints — no cross-endpoint lookup is required or permitted. **Origin separation (MUST for governance, SHOULD for others).** `adcp_use` is an in-band discriminator — it prevents cross-purpose verification, but it does not defend the publishing origin. An origin compromise on a shared JWKS endpoint simultaneously compromises every signing purpose it publishes. Because a governance-signing key is the highest blast-radius key in the system (its compromise is a multi-tenant breach), governance signing keys MUST be served from a separate origin than transport/webhook RFC 9421 keys. The canonical pattern is: * `governance-keys.{org}.example/.well-known/jwks.json` — governance-signing JWKs only * `keys.{org}.example/.well-known/jwks.json` — request-signing keys (including webhook-only `kid`s), deprecated webhook-signing keys during the backward-compatibility window, and TMP keys Operators SHOULD go further and serve each signing surface from a distinct subdomain. Defense-in-depth: governance keys SHOULD be on offline-rotation (HSM/KMS with manual rotation and human approval), while transport and webhook keys MAY use automated rotation. Operators advertise their separation scheme by publishing an `identity.key_origins` map in `get_adcp_capabilities`; the schema defines `governance_signing`, `request_signing`, `webhook_signing`, and `tmp_signing` origin URIs. `webhook_signing` names the webhook delivery surface, not a required live `adcp_use: "webhook-signing"` purpose; it may point at the same origin as `request_signing` when webhooks use request-signing keys. Implementers SHOULD populate the field so counterparties can verify origin separation at onboarding. **When the field is present, verifiers MUST check that the declared governance-signing origin differs from the declared request/webhook signing origins at onboarding and reject onboarding with a user-actionable error on co-tenancy.** The MUST on origin separation is otherwise unverifiable on the wire — the whole point of publishing the advertisement is to let counterparties enforce it programmatically; accepting a declaration that violates the normative rule would defeat the control. Verifiers MAY additionally fetch each declared JWKS and confirm its `jwks_uri` origin matches the advertised value. **Implementer note:** `adcp_use` is a custom JWK member. Major JOSE libraries (`jose`, `node-jose`, `python-jose`, `go-jose`) preserve unknown members on parse. Strict JWK validators (some modes of `PyJWT`, and Web Crypto API's `SubtleCrypto.importKey`) may reject unknown members. When handing a JWK to `SubtleCrypto.importKey` or equivalent strict consumers, strip `adcp_use` from the JWK object but retain it for the step-8 policy check. The field is for AdCP verifier policy, not for cryptographic libraries. **JWKS discovery for a signed request** — given a `keyid` on an incoming signature: 1. The verifier resolves the signing agent's URL to its brand.json `agents[]` entry. Discovery MAY come from prior onboarding, MAY come from a registry cache, but the canonical on-wire bootstrap is the `identity.brand_json_url` field on the agent's `get_adcp_capabilities` response — see [Discovering an agent's signing keys via `brand_json_url`](#discovering-an-agents-signing-keys-via-brand_json_url). 2. Fetch the agent's `jwks_uri` (or default to `/.well-known/jwks.json` at the origin of the agent's `url`) with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). JWKS cache TTL bounded above by the revocation-list polling interval. 3. If the `kid` is absent from the cached JWKS, refetch the JWKS **immediately** (step 2's first fetch may have been cached). If a refetch was already performed in the last 30 seconds for the same `jwks_uri`, the cooldown applies: the verifier MUST NOT refetch again and MUST reject with `request_signature_key_unknown`. The cooldown is between refetches, not before the first. Verifiers MUST NOT accept signatures from a `keyid` they cannot resolve to a specific `agents[]` entry — anonymous signatures provide no accountability. #### Discovering an agent's signing keys via `brand_json_url` The `identity.brand_json_url` field on `get_adcp_capabilities` (added in 3.x, see schema `static/schemas/source/protocol/get-adcp-capabilities-response.json`) is the on-wire bootstrap for the agent → operator → keys chain. The field name reflects the artifact it points at (the operator's `brand.json` file), independent of whether the operator structure is a single brand, a house with sub-brands, an agency, or a pure operator record. Given only an agent URL `A`, a verifier resolves the agent's signing keys via: 1. Fetch `A`'s `get_adcp_capabilities` response with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf) (HTTPS only — the URL `A` is supplied by the caller and MUST go through the same address-family + private-IP filtering used for webhook callbacks). On unreachable/timeout, reject with `request_signature_capabilities_unreachable`. 2. Read `identity.brand_json_url`. If absent and the request is signed, reject with `request_signature_brand_json_url_missing`. Reject with the same code if the value is non-HTTPS (the schema enforces `^https://` but verifiers MUST restate the check; a 3.x parser tolerating a malformed value MUST NOT proceed). The required-when rule is: `identity.brand_json_url` MUST be present when the agent declares `request_signing.supported_for`/`required_for` non-empty, `webhook_signing.supported === true`, or any field under `identity.key_origins`. This is storyboard-enforced in 3.x. In 4.0 the rule becomes schema-required when the response declares `supported_versions` containing any 4.x release; cross-version verifiers (4.0 talking to a 3.x agent that does not advertise 4.x support) MUST continue to accept absent `identity.brand_json_url`. 3. **Origin binding.** The agent URL `A`'s host eTLD+1 MUST equal the `brand_json_url`'s host eTLD+1. eTLD+1 computation MUST use a pinned, dated [Public Suffix List](https://publicsuffix.org/list/public_suffix_list.dat) snapshot (ICANN+PRIVATE sections both in scope so platforms like `vercel.app`, `pages.dev`, `github.io` are treated as suffixes); two verifiers running different PSL versions are non-conformant against each other. If eTLD+1 mismatches, fetch brand.json and check that `authorized_operators[]` lists `A`'s eTLD+1. If neither holds, reject with `request_signature_brand_origin_mismatch`. This closes the shared-tenancy spoofing vector where an attacker stands up an agent on `attacker.example/mcp` and points its `brand_json_url` at an unrelated operator's brand.json that happens to legitimately list `attacker.example/mcp` (e.g., a SaaS multi-tenant deployment). 4. Fetch brand.json at `brand_json_url` with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). Verifiers MUST NOT follow redirects on this fetch (the single-redirect carve-out for `authoritative_location` documented elsewhere in this profile is scoped to that field and MUST NOT be inherited by the brand.json bootstrap). Recommended budgets: connect 5 s, total deadline 10 s, body cap 256 KiB. Cache TTL on a successful fetch MUST be bounded above by the JWKS revocation polling interval (so a key rotation cannot be masked by a stale brand.json). Negative responses (404, network failure) MUST NOT be cached for more than 60 s — operators fixing a misconfiguration must not be locked out for a full revocation cycle. 5. Find the entry in `agents[]` whose `url` **byte-equals** `A` (no canonicalization at this step — same rule as the `iss`-to-brand.json match for governance JWS, see [Buyer identity resolution](#buyer-identity-resolution); the most common failure mode is a trailing-slash or scheme mismatch, e.g. `https://x.com/mcp` ≠ `https://x.com/mcp/`). If none matches, reject with `request_signature_agent_not_in_brand_json`. If multiple match (operator misconfig — the brand.json schema does not currently constrain `agents[]` to be unique-by-URL), reject with `request_signature_brand_json_ambiguous`. 6. Resolve the JWKS source by **signing surface AND role** (sender-vs-receiver position, not just `adcp_use`): * **Sell-side webhook delivery only** — i.e., the seller signing an outbound webhook to the buyer about media-buy delivery: the publisher's `adagents.json signing_keys` pin (when present) is authoritative per the publisher-pin precedence rule above and overrides everything below. The pin is scoped to (agent, webhook delivery surface, sell-side role) — it does NOT override operator-side webhook deliveries (e.g., a buyer-hosted webhook receiving operator status callbacks), and it does not imply a separate `adcp_use: "webhook-signing"` key purpose. * **All other (surface, role) tuples** — request signing (any direction), operator-side webhook delivery, governance signing, TMP signing: use the matched `agents[]` entry's `jwks_uri`, defaulting to `/.well-known/jwks.json` at the origin of `A` when absent. 7. **`identity.key_origins` consistency check (mandatory when signing).** For every surface/purpose declared under `identity.key_origins` on the capabilities response **whose JWKS source in step 6 was the operator brand.json** (i.e., not a publisher `adagents.json signing_keys` pin), the host of the resolved `jwks_uri` MUST equal the declared origin for that surface/purpose. Mismatch on any surface/purpose → reject with `request_signature_key_origin_mismatch` carrying `{ purpose, expected_origin, actual_origin }`. Skip the check **only** for the specific (agent, surface/role) tuple whose source was a publisher pin — operator-side use of the same surface is still checked. If the agent declares signing without a corresponding `identity.key_origins.{purpose}` entry, reject with `request_signature_key_origin_missing` carrying `{ purpose, posture }`. 8. Fetch JWKS, find the `kid`, verify per the existing RFC 9421 profile (steps 7+ of the [verifier checklist](#verifier-checklist-requests)). **Trust roots.** brand.json is operator-attested ("this agent is mine, here are its keys"). `adagents.json` is publisher-attested ("this agent may sell my inventory; optionally, here is its pinned `signing_keys`"). For sell-side webhook signatures, the publisher pin is authoritative (publisher > operator). For request signatures and operator-side webhook signatures, the operator brand.json `jwks_uri` is authoritative. The agent never self-attests its own keys — a `jwks_uri` field is deliberately NOT carried on the capabilities response; the operator publishes the keys out-of-band via brand.json. **`sponsored_intelligence.brand_url` is distinct.** SI agents may carry a `brand_url` field under `sponsored_intelligence` for rendering purposes (colors, fonts, logos, tone) — the field is named `brand_url` because, in the SI context, it really is "the brand being advertised." That field is a rendering pointer, not a trust-root pointer; an SI agent MAY set its `sponsored_intelligence.brand_url` to a different URL than its `identity.brand_json_url` (e.g., a sub-brand brand.json for rendering while still trusting the operator's brand.json for keys). **Verifiers MUST use `identity.brand_json_url` for key discovery; `sponsored_intelligence.brand_url` MUST NOT be used as a trust-root pointer even when `identity.brand_json_url` is absent.** A verifier consuming SI rendering metadata MAY read `sponsored_intelligence.brand_url`; the same verifier MUST switch to `identity.brand_json_url` for any signature-verification flow. The naming distinction is deliberate: `brand_url` for "the brand being advertised" contexts; `brand_json_url` for "the operator master record" contexts. **Rejection codes for this discovery chain (3.x).** Detail fields sourced from a counterparty document (`brand_json_url`, `matched_entries[]`) MUST be HTML-escaped before rendering in admin UIs that display verifier errors — they are attacker-influenceable strings, even though the structured shape is verifier-controlled. | Code | When | Detail fields | Remediation | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `request_signature_brand_json_url_missing` | Capabilities did not carry `identity.brand_json_url` and a signed request was received, or carried a non-HTTPS value | `agent_url` | Operator: set `identity.brand_json_url` to the HTTPS URL of your operator brand.json (typically `https://{your-domain}/.well-known/brand.json`). Verifier: surface to operations; do not retry. | | `request_signature_capabilities_unreachable` | Capabilities fetch failed (DNS, TCP, TLS, timeout, non-2xx) | `agent_url`, `http_status`, `dns_error`, `last_attempt_at` | Verifier MAY retry once after a 1–5 s jittered backoff, then give up; do not negative-cache for more than 60 s. Surface as transient. | | `request_signature_brand_json_unreachable` | brand.json fetch failed (same conditions) | `brand_json_url`, `http_status`, `dns_error`, `last_attempt_at` | Same retry/cache discipline as `_capabilities_unreachable`. | | `request_signature_brand_json_malformed` | brand.json failed strict-parse (duplicate keys, body cap exceeded, or non-JSON content) | `brand_json_url`, `parse_error` | Operator: serve a strict-JSON brand.json with no duplicate object keys and within the 256 KiB body cap. Verifier: do not retry; surface to operations. | | `request_signature_brand_origin_mismatch` | Agent eTLD+1 ≠ `brand_json_url` eTLD+1 and `authorized_operators[]` does not delegate | `agent_url`, `agent_etld1`, `brand_json_url_etld1` | Operator: either move agent to brand eTLD+1, or add agent eTLD+1 to brand.json `authorized_operators[]`. Not retryable. | | `request_signature_agent_not_in_brand_json` | Agent URL not byte-equal to any `agents[].url` of resolved brand.json | `agent_url`, `brand_json_url` | Operator: add agent URL byte-equal to `agents[].url`. Common cause: trailing slash, scheme mismatch, IDN/punycode normalization. Not retryable. | | `request_signature_brand_json_ambiguous` | Multiple `agents[]` entries match the agent URL | `agent_url`, `brand_json_url`, `matched_count`, `matched_entries[]` | Operator: dedupe `agents[]` entries by URL. Not retryable. | | `request_signature_key_origin_mismatch` | Resolved `jwks_uri` host ≠ declared `identity.key_origins.{purpose}` | `purpose`, `expected_origin`, `actual_origin` | Operator: align `identity.key_origins.{purpose}` with the host of the resolved `jwks_uri`. Not retryable. | | `request_signature_key_origin_missing` | Signing posture declared but `identity.key_origins.{purpose}` absent | `purpose`, `posture` | Operator: add `identity.key_origins.{purpose}` declaration to capabilities. Not retryable. | **Adopting `brand_json_url` while pinned to AdCP 3.0.** The field lands in 3.x's next minor as a strictly-additive schema change; AdCP doesn't ship new fields in patch releases (3.0.x), so a formal backport isn't on the table. But you don't have to wait for the version bump to start using it. The wire shape is forward-compatible: * A 3.0-conformant **seller** MAY populate `identity.brand_json_url` on its `get_adcp_capabilities` response today. A 3.0 verifier ignoring the field continues to work; a 3.x verifier picks it up automatically. No coordination, no version bump. * A 3.0-conformant **verifier** MAY read the field opportunistically (via `caps.identity?.brand_json_url`) and run the 8-step chain when present, falling back to your existing out-of-band agent → operator mapping when absent. The chain itself is just HTTPS fetches and JSON parsing — nothing in it requires a 3.x SDK. This is the recommended path for sellers like [Scope3](https://github.com/scope3) building signature verification today: ship the field on your capabilities response, document the chain for your counterparties, and let the 3.x rollout happen passively. ##### Quickstart: implement a `brand_json_url`-based verifier Mirrors the [request-signing quickstart](#quickstart-opt-into-request-signing-in-30) above. Run-once-per-agent — the resulting `agents[]` entry, `jwks_uri`, and JWKS are cached per the TTL rules in step 4. 1. **Fetch capabilities** for the signing agent's URL `A`. This is a **protocol-level** call — invoke `get_adcp_capabilities` via the agent's declared transport (MCP `tools/call` or A2A skill invocation), not a raw HTTP `GET` against `A`. The agent URL is the protocol endpoint, not a JSON capabilities document. Use SSRF-safe transport per [Webhook URL validation](#webhook-url-validation-ssrf): HTTPS only, address-family + private-IP filtering, no redirects, with budgets `{ connect: 5000, total: 10000, body: MAX_CAPABILITIES_BYTES, maxRedirects: 0 }`. 2. **Read `identity.brand_json_url`.** Reject `request_signature_brand_json_url_missing` if absent (and the request is signed) or non-HTTPS. 3. **eTLD+1 origin binding.** Compute `eTLD+1(A)` and `eTLD+1(brand_json_url)` using a pinned PSL snapshot. Use [`tldts`](https://www.npmjs.com/package/tldts) (TS), [`publicsuffixlist`](https://pypi.org/project/publicsuffixlist/) (Python), or [`golang.org/x/net/publicsuffix`](https://pkg.go.dev/golang.org/x/net/publicsuffix) (Go) with a vendored, dated snapshot. Do NOT fetch the PSL at runtime — a runtime fetch creates a denial-of-service oracle and a non-deterministic eTLD+1 across deployments. If they match, proceed. Otherwise fetch `brand.json` and check `authorized_operators[]` — if `eTLD+1(A)` is delegated, proceed. Else reject `request_signature_brand_origin_mismatch`. Origin comparisons throughout this algorithm MUST canonicalize both sides: ASCII-lowercase the host, then convert to IDNA-2008 A-label form (Punycode) before byte-equality. A non-canonical comparison (e.g., raw `Example.COM` vs `example.com`, or U-label vs A-label) silently rejects legitimate traffic. 4. **Fetch `brand.json`** with the same SSRF rules + no redirects, body cap `MAX_BRAND_JSON_BYTES`, connect 5 s, total 10 s. Parse with a strict JSON parser that rejects duplicate keys (e.g., [`secure-json-parse`](https://www.npmjs.com/package/secure-json-parse) in TS, the stdlib `json.JSONDecoder` in Python with an `object_pairs_hook` that raises on duplicates, [`encoding/json`](https://pkg.go.dev/encoding/json) `Decoder.DisallowUnknownFields` paired with a duplicate-key check in Go) — duplicate keys are the parser-differential vector that step 14 closes on the request surface, and the same trust-root document MUST NOT parse to two different shapes across verifiers. On duplicate-key detection, reject `request_signature_brand_json_malformed`. Cache successful responses up to (but no longer than) the JWKS revocation polling interval; cache failures for at most 60 s. 5. **Find the `agents[]` entry** whose `url` byte-equals `A` (no canonicalization). Reject `request_signature_agent_not_in_brand_json` on miss; `request_signature_brand_json_ambiguous` on multiple matches. 6. **Resolve `jwks_uri`** from the matched entry — for sell-side webhook delivery only, prefer the publisher's `adagents.json signing_keys` pin (when present) over the operator's `jwks_uri`. For all other (surface, role) tuples, use the matched entry's `jwks_uri` (default: `/.well-known/jwks.json` at the origin of `A`). 7. **Consistency check.** For every surface/purpose declared under capabilities `identity.key_origins`, apply `canonicalizeOrigin()` (ASCII-lowercase + IDNA-2008 A-label) to both the resolved `jwks_uri` host and the declared origin, then byte-compare (skip only the specific (agent, surface/role) tuple sourced from a publisher pin). Reject `request_signature_key_origin_mismatch` / `_missing` as appropriate. 8. **Hand off to step 8+ of the [verifier checklist](#verifier-checklist-requests)** — fetch the JWKS (with the same byte budget `MAX_JWKS_BYTES` and 5/10 s connect/total deadlines), find the `kid` (already resolved here in step 7's preamble — the verifier checklist's step 7 is the discovery preamble itself), verify per RFC 9421. Pseudocode (TypeScript-flavored; SDK helpers below collapse this to a single call): ```ts theme={null} const MAX_CAPABILITIES_BYTES = 65_536; const MAX_BRAND_JSON_BYTES = 262_144; const MAX_JWKS_BYTES = 65_536; const FETCH_BUDGETS = { connect: 5_000, total: 10_000, maxRedirects: 0 }; function canonicalizeOrigin(hostOrUrl: string): string { const host = hostOrUrl.includes('://') ? new URL(hostOrUrl).hostname : hostOrUrl; return toAsciiIdna2008(host.toLowerCase()); // A-label form } async function resolveAgent(agentUrl: string): Promise { const caps = await getAdcpCapabilities(agentUrl, { // step 1: protocol-level call ...FETCH_BUDGETS, body: MAX_CAPABILITIES_BYTES, ssrf: true, }); const brandJsonUrl = caps.identity?.brand_json_url; if (!brandJsonUrl?.startsWith('https://')) throw new Err('brand_json_url_missing'); // step 2 const agentEtld1 = etldPlusOne(new URL(agentUrl).hostname, PINNED_PSL_SNAPSHOT); // step 3 const brandEtld1 = etldPlusOne(new URL(brandJsonUrl).hostname, PINNED_PSL_SNAPSHOT); const brandJson = await safeFetch(brandJsonUrl, { // step 4 ...FETCH_BUDGETS, body: MAX_BRAND_JSON_BYTES, ssrf: true, parse: 'strict-json', }); if (agentEtld1 !== brandEtld1 && !brandJson.authorized_operators?.some(o => o.domain === agentEtld1)) { throw new Err('brand_origin_mismatch'); } const entries = brandJson.agents.filter(e => e.url === agentUrl); // step 5 (byte-equal) if (entries.length === 0) throw new Err('agent_not_in_brand_json'); if (entries.length > 1) throw new Err('brand_json_ambiguous'); const entry = entries[0]; const jwksUri = entry.jwks_uri ?? `${origin(agentUrl)}/.well-known/jwks.json`; // step 6 for (const [purpose, declared] of Object.entries(caps.identity?.key_origins ?? {})) { // step 7 if (canonicalizeOrigin(jwksUri) !== canonicalizeOrigin(declared)) { throw new Err('key_origin_mismatch', { purpose }); } } const jwks = await safeFetch(jwksUri, { // step 8 setup ...FETCH_BUDGETS, body: MAX_JWKS_BYTES, ssrf: true, parse: 'strict-json', }); return { agentUrl, brandJsonUrl, agentEntry: entry, jwksUri, jwks, /* trace, freshness */ }; } ``` Validate end-to-end against the brand-discovery test vectors at [`/compliance/latest/test-vectors/brand-discovery/`](https://adcontextprotocol.org/compliance/latest/test-vectors/brand-discovery/) once published; until then, the storyboard at `/compliance/latest/universal/capabilities-brand-url-discovery/` exercises the verifier algorithm against fixture brand.json + JWKS and asserts the right `request_signature_*` codes for each error path. ##### Reference implementations The 8-step algorithm ships in three SDKs — pick the one matching your runtime. All three return the same logical record: the agent URL, the resolved brand.json URL, the matched `agents[]` entry, the JWKS URI, the JWKS itself, the `identity_posture` block from the capabilities response, an `consistency` flag from the step-7 `key_origins` check, a `freshness` timestamp set, and a per-step `trace`. * **TypeScript** ([`@adcp/sdk`](https://github.com/adcontextprotocol/adcp-client)): `resolveAgent(url)` returns `{ agentUrl, brandJsonUrl, agentEntry, jwksUri, jwks, identityPosture, consistency, freshness, trace }`. `getAgentJwks(url)` is the JWKS-only fast path. `createAgentJwksSet(url, opts)` returns a `JWTVerifyGetKey` for handing to `jose`'s `jwtVerify`. * **Python** ([`adcp`](https://github.com/adcontextprotocol/adcp-client-python)): `resolve_agent(url)` returns an `AgentResolution` dataclass with fields `agent_url`, `brand_json_url`, `agent_entry`, `jwks_uri`, `jwks`, `identity_posture`, `consistency`, `freshness`, `trace`. `verify_request_signature(request, *, agent_url, allowed_algs)` is the one-shot helper that runs the discovery chain and the [verifier checklist](#verifier-checklist-requests) in one call. * **Go** ([`adcp-go`](https://github.com/adcontextprotocol/adcp-go)): `ResolveAgent(ctx, agentURL) (*AgentResolution, error)` returns a struct with fields `AgentURL`, `BrandJSONURL`, `AgentEntry`, `JWKSUri`, `JWKS`, `IdentityPosture`, `Consistency`, `Freshness`, `Trace`. `VerifyRequestSignature(ctx, req, opts) (*VerifiedIdentity, error)` mirrors the TS/Python one-shot. Each SDK ships a CLI for dev-loop debugging — `npx @adcp/sdk@latest resolve `, `adcp resolve ` (also `python -m adcp resolve `), `adcp resolve ` (Go binary, same name as the Python one — disambiguate by `$PATH` or vendor) — printing the trace with per-step `fetched_at`/`age_seconds`/`ok` so an operator triaging a `request_signature_brand_*` failure can see exactly which step rejected and why. Both the Python (`[project.scripts]` console\_scripts entry) and Go (binary `adcp`, distinct from the Go module path `github.com/adcontextprotocol/adcp-go`) toolchains install a top-level `adcp` command so a single muscle-memory invocation works across runtimes. #### Agent identity A valid signature establishes exactly one fact: **the request was issued by the agent whose `jwks_uri` contains the `keyid`.** The verifier learns which specific agent signed, not just which operator. The agent's containing brand.json (discovered via the verifier's existing agent mapping) tells the verifier which operator runs that agent. **`agent_url` derivation.** The canonical buyer-agent identifier on the verifier's request context is the `url` field of the `agents[]` entry whose `jwks_uri` resolved the `keyid` at step 7 of the [verifier checklist](#verifier-checklist-requests). `agent_url` is **not** a JWK claim, JWS claim, or signed envelope field — it is the publication coordinate the verifier already used to fetch the JWKS. This makes derivation deterministic from inputs the verifier has fully controlled (the agent mapping established at onboarding, plus the JWKS it just fetched) and removes any wire affordance for the signer to assert a different `agent_url` than the one whose key signed the request. SDKs that surface a resolved-signer object to adopters MUST source `agent_url` from this derivation; they MUST NOT accept a buyer-asserted `agent_url` field on the envelope and treat it as cryptographically established. (Buyer-asserted *verifier* references like `creative.verify_agent.agent_url` and `governance.accepted_verifiers[].agent_url` are a separate construct — they name agents the seller will invoke under a published allowlist, not the signer of the inbound request, and remain permitted.) Authorization — whether this operator is permitted to act for the brand named in the request body — is a separate protocol-level check governed by the target house's brand.json `authorized_operator[]` entries. It happens whether the request is signed or not, and is outside the scope of this profile. Verifiers MUST perform both checks; this section specifies only the first. Verifiers MUST NOT derive signer identity from request body fields. The signature → JWKS → agent entry chain is the only authoritative identity path on the signed transport. On the bearer / API-key / OAuth transport, agent identity comes from the seller's credential-to-agent mapping in its onboarding record — that mapping is the only legitimate identity source. Sellers MUST NOT introduce an envelope-side `buyer_agent_url` (or equivalent self-asserted caller-identity field) as an alternate input to identity resolution: the wire affordance lets a caller assert an identity the credential map would not, with no offsetting check. brand.json discovery follows one redirect (`authoritative_location`) and stops. #### Verifier checklist (requests) **Before applying the checklist, verifiers MUST determine whether the operation requires a signature:** * If the operation is in the verifier's `required_for` capability, AND no `Signature-Input` header is present, AND the caller presents no other credential the verifier accepts for this operation (bearer, API key, or mTLS), THEN reject with `request_signature_required`. Unsigned requests that fall into this branch never enter the checklist. See [Composition with fallback authenticators](#composition-with-fallback-authenticators) for the rule governing unsigned-but-otherwise-authenticated callers. * If either `Signature` or `Signature-Input` is present without the other, reject with `request_signature_header_malformed`. The two headers are a bound pair; one without the other is malformed, not "signed with a missing piece we can guess at." This rule closes a downgrade vector where a proxy strips `Signature-Input` but leaves `Signature`. * If a `Signature-Input` header is present but malformed, reject with `request_signature_header_malformed`. Verifiers MUST NOT fall back to bearer-only authentication when a malformed signature is present, **even for operations not in `required_for`** — a present-but-broken signature signals signer intent; silent fallback enables downgrade attacks. Otherwise, verifiers MUST apply these 15 checks (14 numbered steps plus sub-step 9a) in order, short-circuiting on the first failure. Step 14 decomposes into 14a (strict-parse requirement) and 14b (logging discipline) — both apply whenever step 14 runs; they are elaborations of one check, not separate checks in the count. This checklist establishes agent identity only — brand-operator authorization is a separate, subsequent check governed by the target house's brand.json. 1. Parse `Signature-Input` and `Signature` headers per RFC 9421 §4. Reject if malformed. 2. Reject if any of `created`, `expires`, `nonce`, `keyid`, `alg`, or `tag` is absent from the `Signature-Input` parameters (`request_signature_params_incomplete`). 3. Reject if `tag` is not exactly `adcp/request-signing/v1` (`request_signature_tag_invalid`). 4. Reject if `alg` is not in the allowlist (`ed25519`, `ecdsa-p256-sha256`). Library defaults MUST NOT be relied upon (`request_signature_alg_not_allowed`). 5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`request_signature_window_invalid`). 6. Reject (`request_signature_components_incomplete`) if covered components do not include all of: `@method`, `@target-uri`, `@authority`. If a body is present, reject if `content-type` is not covered. If the verifier's `covers_content_digest` capability is `"required"`, reject if `content-digest` is not covered. If the verifier's `covers_content_digest` capability is `"forbidden"` and `content-digest` IS covered, reject with `request_signature_components_unexpected`. 7. Resolve `keyid` to a JWK via [Agent key publication](#agent-key-publication). If the verifier has no cached agent → JWKS mapping for the signing agent, run [Discovering an agent's signing keys via `brand_json_url`](#discovering-an-agents-signing-keys-via-brand_json_url) before this step — its 8-step preamble (capabilities → `identity.brand_json_url` → brand.json → agents\[] → jwks\_uri) is a precondition for `keyid` resolution and short-circuits with the `request_signature_brand_*` and `request_signature_key_origin_*` codes from that section. On `kid` miss within an established mapping, refetch once (subject to the 30-second cooldown between refetches) before rejecting with `request_signature_key_unknown`. Reject if `keyid` cannot be resolved to a specific `agents[]` entry. 8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` equals `"request-signing"`. Reject (`request_signature_key_purpose_invalid`) on any mismatch — including absent `adcp_use`, which MUST be treated as non-conforming. 9. Check the [Transport revocation](#transport-revocation) list. Reject if `keyid` ∈ `revoked_kids` (`request_signature_key_revoked`). Reject with `request_signature_revocation_stale` if the verifier has not refreshed the revocation list within grace. **9a. Per-keyid cap check.** Check the [per-keyid replay-cache cap](#transport-replay-dedup). Reject with `request_signature_rate_abuse` if the cap has been reached for this `keyid`. Runs before cryptographic verify (step 10) — same rationale as step 9: a compromised or misconfigured signer exhausting its cap MUST NOT force amplified Ed25519/ECDSA work on the verifier. Runs *after* `keyid` resolution (step 7) so the cap-state oracle only responds for keys the verifier has already committed to recognizing — running 9a earlier would let an attacker probe verifier-internal rate-limit state across the full keyid space, including keyids not published in JWKS. 10. Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying `@target-uri` canonicalization AND `@authority` derivation per [the profile above](#adcp-rfc-9421-profile). **The `@authority` rule is load-bearing:** verifiers MUST derive `@authority` from the HTTP/2+ `:authority` pseudo-header when present, otherwise from the as-received HTTP/1.1 `Host` header — NOT from reverse-proxy routing state, load-balancer metadata, or any `Host` value a forward proxy may have rewritten in transit. If both `:authority` and `Host` are present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects with `request_target_uri_malformed`. The canonicalized `@authority` MUST byte-for-byte match the authority component of the canonical `@target-uri`; mismatch rejects with `request_target_uri_malformed`. That byte-match against the signed `@target-uri` — not the choice of source header — is the only safe gate, because `Host` itself can be rewritten in transit. Implementers building from this checklist alone — without cross-referencing the profile's canonicalization section — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated request and replays it to a second vhost on the same verifier pool: same cert SAN, different `Host`). After canonicalization completes, verify the signature against the JWK (`request_signature_invalid` on failure). 11. If `content-digest` is covered, recompute the digest from the received body bytes and compare (`request_signature_digest_mismatch` on mismatch). 12. Check the nonce against the replay cache (see [Transport replay dedup](#transport-replay-dedup)). Reject if `(keyid, nonce)` has been seen within the replay-cache TTL (`request_signature_replayed`). 13. **Only after steps 1–9, 9a, and 10–12 have all passed**, insert `(keyid, nonce)` into the replay cache with TTL = `(expires − now) + 60 s` (the +60 s matches the skew tolerance applied at step 5). This insert MUST happen before the body-well-formedness check at step 14 so that a captured frame carrying a valid signature over a malformed body cannot be replayed to burn crypto-verify CPU on each retry — the nonce is burned on first sighting of a cryptographically-valid frame, regardless of body shape. 14. **Body well-formedness.** Verifiers MUST reject bodies containing duplicate object keys (`request_body_malformed`). Per RFC 8259 §4, duplicate-key parse behavior is unpredictable — the signature is valid over the bytes on the wire, but two parsers can disagree on the parsed value, which is a parser-differential attack class (cf. CVE-2017-12635). This check closes the gap between the signature verifier's view of the payload and the downstream consumer's view. Request bodies carry state-change and spend-committing payloads (`create_media_buy`, `update_media_buy_delivery`, etc.) whose parser-differential blast radius is larger than webhooks' status-flip blast radius, making this check at least as load-bearing here as on the webhook surface. `request_body_malformed` is distinct from `request_signature_digest_mismatch`: the signature IS valid; the body parses to ambiguous state. A verifier that crashes rather than returning a structured `request_body_malformed` error is conformant-but-suboptimal — senders receive no actionable error code. **Idempotency\_key coverage follows from this check**: step 14 runs before schema validation and idempotency-cache lookup (see [idempotency](#idempotency)), so a request body whose `idempotency_key` is itself duplicated (different parsers seeing different keys) is rejected here and never reaches the cache. No separate idempotency-layer audit is required. **14a. Strict-parse requirement.** The check MUST use a parser that exposes duplicate keys — a last-wins/first-wins default that silently discards them does not satisfy this requirement. The per-language strict-parse escape-hatch enumeration in [step 14a of the webhook verifier checklist](#webhook-callbacks) applies identically here. **14b. Logging discipline.** Verifiers SHOULD NOT log full request body bytes on a `request_body_malformed` rejection; log `keyid`, nonce, byte length, and the specific duplicate key names only. The key-name sanitization rules (truncate at first non-printable to ``, truncate to last UTF-8 codepoint at or below 32 bytes, cap count at 4) from [step 14b of the webhook verifier checklist](#webhook-callbacks) apply identically here — the attacker-controlled-byte channel has the same shape on the request surface. Only after all 14 checks pass does the verifier treat the request as cryptographically authenticated. Verifiers SHOULD record `verified_signer: { keyid, agent_url, verified_at }` on the request context so downstream code — including the subsequent brand-operator authorization check — can log and audit by signed agent identity. **Cheap rejections before crypto verify (steps 9 and 9a before step 10) are deliberate.** If a verifier checks crypto first, an attacker replaying a revoked-key signature — or a signer hammering a verifier whose per-keyid cap is full — forces an Ed25519 or ECDSA verification on every rejection, cheap amplification. Moving revocation and the per-keyid cap ahead closes that O(verify) → O(1) gap. Step 9's revocation state is already published externally on the signer's origin; step 9a's cap state is verifier-internal but is observable via traffic-pattern analysis by any sustained attacker. The spec intentionally pairs the distinct `request_signature_rate_abuse` error code with the `SHOULD alert operators` requirement (see [Transport replay dedup](#transport-replay-dedup)) so cap observations surface as incident signal rather than silent oracles — a compromised-key event should be loud for the operator even if it is also legible to the attacker who caused it. **A load-bearing invariant for the cap.** External traffic without the private key cannot grow the cap: the replay-cache insert happens at step 13, *after* crypto verify (step 10) and *before* body well-formedness (step 14), so any request that fails at step 10 never consumes a cap entry, and any request that fails at step 14 has already burned its nonce — a captured frame carrying a valid signature over a malformed body cannot be replayed to force amplified crypto-verify work. This is why 9a is a *reader* of cap state, not a writer — only the legitimate key holder (or anyone who has compromised the key, the case the cap exists to detect) can grow the set. Future edits to the checklist MUST preserve both orderings: moving the insert earlier (before step 10) would let any external party flood the cap using forged structurally-valid signatures; moving the insert later (after step 14) would reopen the malformed-body replay vector. Step 12's `(keyid, nonce)` dedup, by contrast, runs *after* crypto verify so the replay cache is not consumed by invalid signatures. #### Composition with fallback authenticators `required_for` governs the signature requirement **relative to a caller's credential path**, not absolutely. A verifier typically accepts more than one authenticator (bearer, API key, mTLS, 9421) and `required_for` is one lever within that auth chain, not an override that trumps the others. **Terminology for the rule below:** *unauthenticated* means the caller presents neither a valid signature nor any other credential the verifier accepts for this operation. An unrecognized bearer token or API key (one the verifier does not accept) is *not* a valid credential — the caller is unauthenticated and falls into the first rule. The normative rule is: * An **unauthenticated** request to a `required_for` operation MUST be rejected with `request_signature_required`. * An **unsigned but otherwise authenticated** request (valid bearer, API key, or mTLS identity; no `Signature-Input`) to a `required_for` operation MUST NOT be rejected for missing signature. The fallback credential is what the verifier advertised as sufficient for that caller, and `required_for` does not retroactively invalidate the verifier's own authenticator configuration. * A **signed** request enters the [verifier checklist](#verifier-checklist-requests) and is evaluated on its cryptographic merits, whether or not the operation is in `required_for`. * A **malformed signature** blocks fallback regardless, per the malformed-signature rule in the checklist preamble. Broken signatures signal signer intent and MUST NOT downgrade silently to bearer. `warn_for` is unchanged by this rule: it was already non-rejecting for unsigned requests and continues to surface signed-but-invalid signatures as monitoring signal during rollout. **Seller enforcement — pick the posture that matches your capability declaration.** Three enforcement postures are valid; sellers MUST pick one and configure their fallback authenticators accordingly. Advertising `required_for` while letting bearer authentication remain open for the listed operation is security theater — the verifier advertised bearer as valid, and callers are entitled to use it. * **Strict (signing is unconditional for this operation).** Sellers MUST either stop accepting bearer/API-key/mTLS for the operation entirely, *or* gate the fallback authenticator on a per-caller flag that rejects non-signed requests from counterparties who have completed 9421 onboarding. This is the posture where `required_for` rejects everything unsigned. * **Prefer signing, accept fallback (recommended during rollout).** Advertise `required_for` for the operation but leave bearer open. The composition rule applies: unsigned-unauthenticated callers are rejected, unsigned-bearer-authed callers pass. Good for quarters-long migrations where buyers onboard to 9421 at their own pace. * **Advisory only.** Move the operation to `warn_for` (or `supported_for`) rather than `required_for`. The verifier verifies signatures when present and logs failures, but never rejects for missing signature. *Example of the per-caller flag (strict posture):* a seller whose `agents[]` entries carry a `signing_onboarded: true` flag on 9421-ready counterparties configures its bearer authenticator to reject bearer credentials whose resolved agent has `signing_onboarded: true` for operations in `required_for`. Other agents continue to authenticate via bearer until their flag flips. Promotion to `required_for` stays operationally safe — existing bearer traffic continues while onboarded counterparties are held to the stricter bar. Buyers reading `required_for` on a counterparty's capability surface learn **"callers presenting no credential at all will be rejected on this operation; callers presenting a bearer, API key, or mTLS credential the verifier accepts will not be rejected for missing signature."** That is not "all unsigned callers will be rejected." A buyer that wants its own unsigned bearer calls to fail closed on a `required_for` operation MUST negotiate with the seller to revoke bearer credentials for that operation rather than infer the behavior from the capability block. **Why this composition and not the strict reading.** The strict reading ("`required_for` rejects all unsigned requests regardless of fallback credentials") has two practical problems. First, it collides with the 3.0 rollout pattern: sellers promote operations `supported_for → warn_for → required_for` over quarters, and most have live bearer traffic on the same operations during the transition. A strict reading would force every counterparty to migrate to signing in lockstep with the seller's `required_for` flip, or break. Second, it creates an action-at-a-distance bug: a seller enabling `required_for` for operational monitoring purposes would inadvertently 401 every bearer-authed buyer on that operation with no warning and no remediation path short of removing the capability. The composition rule makes `required_for` safe to enable incrementally — its effect is scoped to the unauthenticated branch the verifier actually owns. #### Content-digest and proxy compatibility Covering `content-digest` binds the request body bytes to the signature. For spend-committing operations, this is the whole point: the body specifies the money, and a signature that doesn't commit to the body is not protecting the attack surface that matters. In server-to-server AdCP deployments — which is most of them — body-modifying intermediaries are rare and usually the result of a specific deliberate configuration. Default position: **cover `content-digest` for spend-committing operations; treat transports that prevent body preservation as bugs to fix rather than constraints to accommodate.** **Known body-modifying transport patterns.** These configurations break body-binding signatures and are the single biggest source of 9421 interop bugs in production: * CDN configurations that recompress or buffer-modify POST bodies (uncommon, but specific Cloudflare Workers, Fastly VCL, and CloudFront Lambda\@Edge setups can introduce byte changes). * WAFs that "sanitize" JSON request bodies (whitespace normalization, key reordering, unknown field stripping). Most WAFs inspect without modifying; some do modify. * Reverse proxies or API gateways that re-serialize JSON between client and origin for logging, validation, or transformation. * HTTP/2 → HTTP/1.1 bridges where chunked-encoding framing assumptions differ. * **Signer-side serialization mismatch.** A signer that computes `content-digest` over one JSON serialization (e.g., `json.dumps(payload)` with default spaced separators) while its HTTP client writes a different serialization on the wire (e.g., compact separators) produces a digest over bytes the receiver never sees. Every verifier then rejects with `webhook_signature_digest_mismatch` or `request_signature_digest_mismatch`. **Serialize the body once, then use those exact bytes for both the digest input and the HTTP body** — do not compute the digest from the pre-serialized object and trust the client to reproduce the same bytes. This is the same trap the [legacy HMAC scheme pins via compact separators](#legacy-hmac-sha256-fallback-deprecated-removed-in-40); 9421 fails loud rather than silent (digest mismatch is a hard reject) but the signer-side fix is identical. **If you control the transport**, preserve bodies byte-for-byte end-to-end and cover `content-digest`. **If you don't control the transport**, fix it rather than degrade the security guarantee. Validate end-to-end with a `POST` echo test against a test endpoint before sending real traffic. Verifiers that genuinely cannot preserve body bytes due to legacy infrastructure MAY advertise `covers_content_digest: "forbidden"`; this is an opt-out for the narrow case where the infrastructure cannot be fixed. `"required"` is recommended for all spend-committing operations. `"either"` is the default — signers choose per-request, and the verifier accepts both covered and uncovered forms. **`"required"` is strict.** When a verifier advertises `covers_content_digest: "required"`, a signed request with a body that does not cover `content-digest` is a hard reject with `request_signature_components_incomplete`. Verifiers MUST NOT accept it as a "soft" signed-but-body-unbound request; there is no soft mode. Signers that don't want to cover `content-digest` for a given call MUST route to a verifier whose policy is `"either"` or `"forbidden"`, or not sign the call at all. #### Transport replay dedup Step 12 of the [verifier checklist](#verifier-checklist-requests) requires per-`(keyid, nonce)` deduplication. Unbounded sets are a memory and DoS risk. * TTL on each entry = `(expires − now) + 60 s` to match the symmetric clock-skew tolerance applied at window validation. Typical TTL ≤ 360 s (5 min + 60 s skew). * In-memory LRU keyed on `(keyid, nonce)` with TTL eviction, sized to expected request rate × max signature validity. * Above \~10K req/s per signer: Redis `SETNX` with `EX = remaining_validity_seconds + 60`. * Distributed verifiers (multi-region): per-region replay cache is acceptable. The only attack this enables is a single replay within (expires − now + 60 s) across regions, bounded by \~6 min and only effective if the attacker controls intermediate routing. Verifiers MUST NOT use the request bearer token, IP, or any non-`(keyid, nonce)` value as the replay key — those produce false positives that reject legitimate agent traffic. **Per-keyid cap.** To prevent an abusive or compromised signer from exhausting verifier memory with unique nonces, verifiers MUST enforce a per-keyid entry cap on the replay cache. Recommended ceiling: 1,000,000 entries per `keyid`. On cap exceeded, verifiers MUST reject new signatures from that `keyid` with `request_signature_rate_abuse` — NOT silently evict — and SHOULD alert operators, because hitting the cap indicates either a compromised key or a grossly misconfigured signer. Silent eviction is the dangerous mode: it creates replay windows exactly when the verifier is under attack. The per-keyid cap is distinct from the total cache ceiling; a verifier may legitimately hit its total ceiling via many well-behaved signers, but per-keyid exhaustion is unambiguously an attack signal. The cap check is step 9a of the [verifier checklist](#verifier-checklist-requests) — evaluated **before** crypto verify so an abusive signer cannot force amplified Ed25519/ECDSA work on the verifier. **Single-process vs. distributed enforcement.** In a single-process verifier, step 9a (read) and step 13 (insert) are sequential in one execution and the cap is exact. In a distributed verifier sharing a Redis-backed replay cache, step 9a is a cheap fast-path amplification guard but is not authoritative: two verifiers can both observe `size == cap − 1`, both pass 9a, both pass steps 10–12, and both insert at step 13. To avoid cap drift, the step 13 insert SHOULD be atomic with a cap check (e.g., a Lua script or `SETNX` pattern that returns an over-cap sentinel) — step 9a remains the cheap amplification guard, step 13 is the authoritative enforcement point. A verifier whose atomic insert returns over-cap MUST reject the request with `request_signature_rate_abuse` rather than let it succeed; a cap that is advisory at step 13 is not a cap. #### Transport revocation Operators SHOULD serve a single combined revocation list at the brand.json origin covering governance, request-signing, and any other agent signing keys published under their `agents[]` entries. Format and signing semantics match the governance revocation list (see [Revocation](#revocation) above). For request-signing keys: * `revoked_kids` invalidates every request ever signed under that `kid` (before or after the revocation timestamp). * `revoked_jtis` is not used (request signatures don't have a `jti`; nonce uniqueness is per-key). Verifiers accepting request-signed mutations MUST poll the revocation list on the cadence declared in `next_update` (floor 1 min, ceiling 30 min). The fetch-failure safe-default applies with grace = 4× the previous polling interval: verifiers that have not refreshed within `next_update + grace` MUST reject new request-signed mutations with `request_signature_revocation_stale` until the list is refreshed. #### Transport capability advertisement Verifiers advertise signing support and per-call requirements via the `request_signing` block on `get_adcp_capabilities`: ```json theme={null} { "request_signing": { "supported": true, "covers_content_digest": "either", "required_for": [], "warn_for": ["create_media_buy"], "supported_for": [ "create_media_buy", "update_media_buy", "sync_creatives", "activate_signal" ] } } ``` * `supported`: when true, the verifier validates signatures when present. When false or absent, signatures are ignored. * `covers_content_digest`: one of `"required"`, `"forbidden"`, or `"either"` (default). `"required"`: signers MUST cover `content-digest`; unsigned-body signatures are rejected. `"forbidden"`: signers MUST NOT cover `content-digest`; body-bound signatures are rejected. `"either"`: signer chooses; verifier accepts both. * `required_for`: AdCP protocol operation names (not transport-specific) for which **unsigned requests that present no other valid credential** are rejected with `request_signature_required`. Empty in 3.0 by default. Signers MUST sign any listed operation. Composition with bearer, API key, or mTLS fallbacks is governed by [Composition with fallback authenticators](#composition-with-fallback-authenticators) — in particular, unsigned requests that present a valid fallback credential are accepted, and sellers that intend signing to be unconditional MUST configure their fallback authenticators to reject other credential types for the operation. * `warn_for`: operations for which the verifier verifies signatures when present, logs failures in monitoring, but **does NOT reject**. Used as a shadow-mode bridge from `supported_for` to `required_for`. Enables per-counterparty pilots where the seller watches real-traffic failure rates before enforcing. Precedence: `required_for > warn_for > supported_for`. Signers SHOULD sign operations in `warn_for`; verifiers MUST NOT reject unsigned or failed-verify requests to these operations. * `supported_for`: operations for which signatures are verified when present but not required. Signers SHOULD sign these. Typically a superset of `required_for` and `warn_for`. **Rollout pattern:** 1. Announce signing readiness: add the operation to `supported_for`. Counterparties can begin signing but nothing changes if they don't. 2. Promote to shadow mode: move the operation to `warn_for`. The verifier logs verification failures; traffic is unaffected. Operators monitor the failure rate and debug. 3. Enforce: when the failure rate drops below the operator's threshold, move to `required_for`. Unsigned or invalid-signature requests to that operation are now rejected. In 3.0, verifiers ship with `required_for: []` and populate it selectively. `warn_for` is the recommended pre-production stop before flipping to enforce. In 4.0 the protocol normatively requires `required_for` to include all spend-committing operations the verifier supports, and `covers_content_digest: "required"` is recommended for those operations. #### Transport error taxonomy Stable codes returned in `WWW-Authenticate: Signature error=""` on 401, and surfaced by SDK verifiers as typed errors. Naming pattern matches the [governance taxonomy](#verification-error-taxonomy) so SDK error handling is symmetric. | Failure | Retry? | Code | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | ----------------------------------------- | | Unsigned request where signing is required — either (a) operation is in `required_for`, or (b) request payload carries a field that triggers signing regardless of `required_for` membership (e.g., `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` on a signing-capable seller — see [Webhook callbacks](#webhook-callbacks)) | No | `request_signature_required` | | Request `@target-uri` is syntactically malformed (e.g., empty authority, bare IPv6, IPv6 zone identifier, raw non-ASCII host), OR canonicalized `@authority` does not byte-match the authority component of the canonical `@target-uri` (cross-vhost replay) | No | `request_target_uri_malformed` | | `Signature` or `Signature-Input` header present but malformed | No | `request_signature_header_malformed` | | Required sig-param absent (`created`, `expires`, `nonce`, `keyid`, `alg`, or `tag`) | No | `request_signature_params_incomplete` | | `tag` not `adcp/request-signing/v1` | No | `request_signature_tag_invalid` | | `alg` not in allowlist | No | `request_signature_alg_not_allowed` | | Signature window invalid (`expires ≤ created`, skew, expired, > 5 min validity) | No | `request_signature_window_invalid` | | Required covered components missing | No | `request_signature_components_incomplete` | | Covered components include `content-digest` when capability is `"forbidden"` | No | `request_signature_components_unexpected` | | `keyid` not in signer JWKS after one refetch | No | `request_signature_key_unknown` | | JWK `key_ops` lacks `verify`, `use` ≠ `sig`, or `adcp_use` ≠ `request-signing` | No | `request_signature_key_purpose_invalid` | | `keyid` ∈ `revoked_kids` | No | `request_signature_key_revoked` | | Revocation list not refreshed within grace | No (block new) | `request_signature_revocation_stale` | | Cryptographic verification failed | No | `request_signature_invalid` | | `content-digest` mismatch with recomputed digest | No | `request_signature_digest_mismatch` | | Body contains duplicate object keys (parser-differential vector) | No | `request_body_malformed` | | Nonce already seen within window | No | `request_signature_replayed` | | Per-keyid replay cache exceeded its entry cap | No (block new) | `request_signature_rate_abuse` | | JWKS fetch transient failure | Yes (with backoff) | `request_signature_jwks_unavailable` | | JWKS fetch fails SSRF validation | No | `request_signature_jwks_untrusted` | Servers MUST NOT echo internal verification details beyond the stable code; log the detail server-side. **`WWW-Authenticate` format.** AdCP does NOT define a realm value for request-signing challenges. Verifiers MUST emit `WWW-Authenticate: Signature error=""` with no `realm` parameter and no other parameters. Clients parsing the header MUST tolerate other parameters (RFC 7235 permits implementations to include extras) but SHOULD NOT depend on them. #### Webhook callbacks Push-notification webhooks (POSTs to the `push_notification_config.url` a buyer registers), account-level webhooks (POSTs to `accounts[].notification_configs[].url`), and similar asynchronous seller-initiated callbacks are signed under a symmetric variant of this profile. Role direction is inverted relative to request signing: the **seller signs outbound**, the **buyer verifies**. 9421 webhook signing is baseline-required for any 3.0 seller that emits webhooks, with a deprecated HMAC fallback described in [Webhook Security](#webhook-security). **Baseline with programmatic advertisement.** 9421 webhook signing is baseline-required for any seller that emits webhooks — the default is signed, not a negotiated option. The `webhook_signing` capability block on `get_adcp_capabilities` exists so buyers can detect a non-signing seller *at onboarding* rather than discovering it by traffic inspection (which is how the asymmetry with `request_signing` manifested before this block was restored). A seller whose capability surface advertises mutating-webhook emission elsewhere (e.g., `media_buy.reporting_delivery_methods` includes `webhook`, `media_buy.content_standards.supports_webhook_delivery: true`, or `wholesale_feed_webhooks.supported: true`) MUST include this block with `supported: true`. A seller that emits no webhooks MAY omit the block entirely; `supported: false` is reserved for the unsafe posture of emitting unsigned webhooks and MUST NOT be used to signal absence-of-webhooks. Buyers that integrate with a seller whose surface advertises mutating-webhook emission while the `webhook_signing` block advertises `supported: false` or is omitted MUST fail onboarding with a user-actionable error — a seller that emits but does not sign webhooks is unsafe to integrate with for any mutating-webhook use case. ```json theme={null} { "webhook_signing": { "supported": true, "profile": "adcp/webhook-signing/v1", "algorithms": ["ed25519", "ecdsa-p256-sha256"], "legacy_hmac_fallback": false } } ``` * `supported`: MUST be `true` when the seller advertises mutating-webhook emission elsewhere in its capability surface. Buyers reject onboarding when `supported: false` or the block is missing and the seller's surface advertises webhook emission. Sellers that emit no webhooks SHOULD omit the entire block. * `profile`: MUST be exactly `adcp/webhook-signing/v1` for this profile version. Future profile versions bump the string. * `algorithms`: subset of `["ed25519", "ecdsa-p256-sha256"]` — the algorithm set this seller will sign with. Matches the webhook-signing verifier allowlist (see step 4 of the [verifier checklist](#verifier-checklist-requests), reused for webhooks via the substitutions noted above). Buyers MUST reject onboarding with a user-actionable error if the advertised `algorithms` array contains any value outside this set; an out-of-set algorithm indicates a misconfigured or non-conforming seller and silent acceptance would defeat the allowlist. * `legacy_hmac_fallback`: `true` iff the seller supports the legacy HMAC-SHA256 scheme when the buyer populates `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`. `false` is the recommended posture in 3.x. The buyer opts into the legacy HMAC-SHA256 scheme by populating `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`; otherwise the seller signs with the 9421 webhook profile. Sellers MAY decline to support the legacy scheme — see the `legacy_hmac_fallback` flag above. **Mode selection is a switch, not both.** The presence of `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` selects exactly one signing mode for every webhook delivered to that URL: `authentication` present → legacy HMAC-SHA256 (or Bearer); `authentication` absent → 9421. Sellers MUST NOT sign the same webhook both ways. Buyers MUST NOT attempt "try 9421 first, fall back to HMAC" verification — that pattern creates downgrade oracle behavior and accepts signatures the buyer did not ask for. Verifiers key the verification path strictly off whether the receiver has a configured HMAC secret for the webhook registration. **Key publication.** Signing keys are published by the seller in its **own brand.json** `agents[]` entry at the signing agent's operator domain, at the `jwks_uri` member of that entry — the same publication pattern as any other AdCP agent key. Webhooks are signed with the agent's **`adcp_use: "request-signing"`** key; there is no separate webhook key purpose. Domain separation between requests and webhooks is carried by the signature `tag` (`adcp/request-signing/v1` vs `adcp/webhook-signing/v1`), not by the key purpose. Each signing JWK MUST declare: | Member | Value | | ---------- | ---------------------------------------------------------------------------------------- | | `use` | `"sig"` | | `key_ops` | `["verify"]` | | `adcp_use` | `"request-signing"` | | `kid` | distinct within the JWKS; MUST NOT collide with any other `kid` regardless of `adcp_use` | | `alg` | `"EdDSA"` or `"ES256"` | **Key isolation is optional, via a distinct `kid` — not a distinct purpose.** An operator that wants its webhook traffic signed by separate key material (so a webhook-key compromise does not extend to request signing, or to rotate the two independently) publishes a **second `adcp_use: "request-signing"` key with a distinct `kid`** and signs webhooks with that one. Both keys carry the same `adcp_use`; the verifier resolves the right one by the `kid` in `Signature-Input`. No dedicated webhook key purpose is required to achieve isolation. > **Deprecated:** `adcp_use: "webhook-signing"` is deprecated and scheduled for removal in a future major version (tracked in [#5555](https://github.com/adcontextprotocol/adcp/issues/5555); the exact window is a WG/RFC decision). Verifiers MUST still accept it for backward compatibility (a webhook signed under a `"webhook-signing"` key verifies cleanly), but new signers SHOULD publish and sign with `"request-signing"` keys only. A buyer verifying a webhook MUST accept a JWK whose `adcp_use` is `"request-signing"` (or the deprecated `"webhook-signing"`), and MUST reject any other key-purpose failure — any other `adcp_use` value, absent `adcp_use`, or a missing `verify` key\_op — with `webhook_signature_key_purpose_invalid`. The reverse is still forbidden: request verification requires `adcp_use == "request-signing"` exactly (a request signature is rejected if the key declares any other purpose), and neither `"response-signing"` nor `"governance-signing"` keys are ever valid for webhook delivery. The webhook path is permissive on key purpose because it already carries domain separation in the `tag` (`adcp/webhook-signing/v1`) and mandatory `content-digest` coverage, so the key-purpose check adds no confusion resistance there. **Trust anchor and blast radius.** The trust anchor for webhook authenticity is **the signer's brand.json origin** — the HTTPS origin that hosts the brand.json declaring the signing agent's `agents[]` entry. A compromise of that origin (sub-path takeover, DNS hijack, CDN cache poisoning of `/.well-known/brand.json` or the `jwks_uri`) compromises every webhook that buyer accepts from that signer until the operator publishes a `revoked_kids` entry and buyer verifiers refresh the revocation list. Buyers SHOULD pin the agent's `jwks_uri` URL learned at integration onboarding and alarm on changes to the URL itself (not just on `kid` rotation within a stable URL) — changes to the URL force re-anchoring and SHOULD require operator attention, not silent adoption. `kid` collisions within the same JWKS are forbidden so each `kid` resolves to exactly one key. Webhooks are signed with the agent's `request-signing` key, so by default a request-signing-key compromise extends to webhooks. An operator that needs blast-radius separation publishes a second `request-signing` key with a distinct `kid` dedicated to webhook delivery and signs webhooks with it — isolation comes from separate key material under a separate `kid`, not from a separate `adcp_use`. **Covered components** are identical to request signing: `@method`, `@target-uri`, `@authority`, `content-type`, and `content-digest`. `content-digest` is REQUIRED on webhook callbacks — the body carries the event, and webhook receivers are buyer-controlled endpoints where body preservation is the buyer's own infrastructure problem. There is no `covers_content_digest: "forbidden"` opt-out for webhooks; transports that cannot preserve webhook body bytes MUST be fixed. **Signature parameters** are identical to request signing with one override: | Parameter | Notes | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `created`, `expires`, `nonce`, `keyid`, `alg` | Same semantics as [request signing parameters](#adcp-rfc-9421-profile). | | `tag` | MUST be exactly `adcp/webhook-signing/v1`. Verifiers MUST reject `adcp/request-signing/v1` on a webhook route with `webhook_signature_tag_invalid`. The distinct tag prevents a request signature from being replayed as a webhook signature and vice versa. | **JWKS discovery.** The buyer knows the seller's agent URL from the AdCP integration it's already using. Buyer resolves: 1. Seller agent URL `A` → fetch `/.well-known/brand.json` at the operator domain of `A` with SSRF validation per [Webhook URL validation](#webhook-url-validation-ssrf). brand.json resolution follows one redirect (`authoritative_location` or `house` redirect variant) and stops. 2. In the fetched brand.json, find the `agents[]` entry whose `url` byte-for-byte matches `A`. 3. Fetch that entry's `jwks_uri` (or default to `/.well-known/jwks.json` at the origin of `A`) with SSRF validation. JWKS cache TTL bounded above by the revocation-list polling interval (floor 1 min, ceiling 30 min). Long-running task flows cross JWKS rotations; verifiers MUST NOT pin a single JWKS snapshot for the lifetime of a task. 4. Resolve `keyid` on the incoming `Signature-Input` to a JWK in the fetched set. On `kid` miss, refetch once (subject to the 30-second cooldown between refetches) before rejecting with `webhook_signature_key_unknown`. The refetch-on-miss path is the load-bearing mechanism for handling mid-task key rotation — clients that skip it will reject legitimate post-rotation deliveries. Buyers MUST NOT derive signer identity from webhook payload fields (`task_id`, `operation_id`, etc.) or from `adagents.json` entries — those are publisher authorization, not signer identity. Identity is established solely via the signature → JWKS → seller `agents[]` entry chain. **Downgrade and injection resistance.** The buyer's webhook-signing preference is communicated by the presence or absence of `push_notification_config.authentication` or `accounts[].notification_configs[].authentication` on the inbound request that registers the webhook. In 3.0 that inbound request is frequently bearer-authenticated rather than 9421-signed, so an on-path mutator (misconfigured proxy, compromised intermediary) could strip or inject the `authentication` block silently. The following rules contain the blast radius: * **Sellers MUST log** every request that arrives with a non-empty `authentication` block. Ops alarms on unexpected HMAC selection protect the buyer side when the buyer thought it was getting 9421. * **Sellers that support request signing MUST require** the inbound request to be 9421-signed (per the [request verifier checklist](#verifier-checklist-requests)) when `authentication` is present on `push_notification_config.authentication` or any `accounts[].notification_configs[].authentication`, rejecting with `request_signature_required` (the same code used for `required_for` operations — see [Transport error taxonomy](#transport-error-taxonomy)). When a signed request cryptographically commits to the body, the `authentication` block cannot be injected or stripped without also invalidating the signature. Sellers that do not support request signing at all have no way to enforce this rule and fall back to the log-and-alarm posture in the preceding bullet — 3.0 migration note, not an exemption: the [request-signing migration timeline](#transport-migration-timeline) makes request signing required for spend-committing operations in 4.0, at which point no seller is unsigned-only. * **Buyers MUST reject with `webhook_mode_mismatch` and alarm**, not silently downgrade, when they receive a 9421-signed webhook after registering with `authentication.credentials`, or when they receive HMAC-signed webhooks after registering without `authentication`. Rejection is the safety property; alarming is the telemetry — a buyer that alarms but accepts the payload has already handed authority to the mismatched signing scheme. The rejection surfaces as HTTP `401` with the stable error code so sender-side retry logic can route it to incident response rather than replaying identically. * **Buyers SHOULD negotiate HMAC-mode out-of-band** at onboarding when interoperating with sellers that have not yet implemented 9421. Durable per-counterparty mode selection in operator records is not MITM-mutable the way a per-request field is. **Verifier checklist for webhooks.** Apply these 15 checks (14 numbered steps plus sub-step 9a) in order, short-circuiting on the first failure. Step 14 decomposes into 14a (strict-parse requirement) and 14b (logging discipline) — both apply whenever step 14 runs; they are elaborations of one check, not separate checks in the count. The steps below are the [request verifier checklist](#verifier-checklist-requests) with **two parameter substitutions** — the `tag` value (`adcp/webhook-signing/v1` instead of `adcp/request-signing/v1`) and the direction-of-trust resolution (seller's brand.json `agents[]` entry instead of the buyer's). Step 14 (body well-formedness) is identical across the two profiles; only the error-code prefix differs (`webhook_body_malformed` vs `request_body_malformed`). Implementations SHOULD share verifier code between the two profiles, branch on the two parameter substitutions, and configure the profile-specific error codes — NOT fork the implementation. Error codes are prefixed `webhook_*` — most carry the `webhook_signature_*` infix, plus structural codes without it (currently `webhook_target_uri_malformed`, `webhook_mode_mismatch`, `webhook_body_malformed`) — so caller-side error handling distinguishes the two profiles. 1. Parse `Signature-Input` and `Signature` headers per RFC 9421 §4. Reject if malformed (`webhook_signature_header_malformed`). If `Signature` or `Signature-Input` is present without the other, reject with the same code — a bound pair, not a guessable one. 2. Reject if any of `created`, `expires`, `nonce`, `keyid`, `alg`, or `tag` is absent from the `Signature-Input` parameters (`webhook_signature_params_incomplete`). 3. Reject if `tag` is not exactly `adcp/webhook-signing/v1` (`webhook_signature_tag_invalid`). Byte-for-byte match; no case-folding. 4. Reject if `alg` is not in the allowlist (`ed25519`, `ecdsa-p256-sha256`). Library defaults MUST NOT be relied upon (`webhook_signature_alg_not_allowed`). 5. Reject if `expires ≤ created`, `created > now + 60 s`, `expires < now − 60 s`, or `expires − created > 300 s` (`webhook_signature_window_invalid`). 6. Reject if covered components do not include ALL of: `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest` (`webhook_signature_components_incomplete`). `content-digest` is REQUIRED; there is no policy branch. 7. Resolve `keyid` to a JWK via the JWKS discovery steps above. On `kid` miss, refetch once (30-second cooldown between refetches) before rejecting (`webhook_signature_key_unknown`). Reject if `keyid` cannot be resolved to a specific `agents[]` entry in the signer's brand.json. 8. Verify the JWK's `use` is `"sig"`, `key_ops` includes `"verify"`, and `adcp_use` is `"request-signing"` — webhooks are signed with the agent's request-signing key (see [Key publication](#webhook-callbacks)); the deprecated `"webhook-signing"` value MUST also be accepted for backward compatibility. Reject on any other outcome with `webhook_signature_key_purpose_invalid`: absent `adcp_use`, a missing `verify` key\_op, or any other `adcp_use` value (e.g. `"response-signing"`, `"governance-signing"`). Accepting `"request-signing"` here is safe because cross-protocol confusion is prevented by the `tag` (step 3) and mandatory `content-digest` coverage (step 6), not by the key-purpose discriminator: a captured request signature carries `tag=adcp/request-signing/v1` and is rejected at step 3. (`webhook_mode_mismatch` is reserved for the HMAC-vs-9421 auth-mode selector mismatch — see [Downgrade and injection resistance](#webhook-callbacks) — and is NOT used for key-purpose failures.) 9. Check the [Transport revocation](#transport-revocation) list (reused across signing purposes). Reject if `keyid ∈ revoked_kids` (`webhook_signature_key_revoked`). Reject with `webhook_signature_revocation_stale` if the verifier has not refreshed within grace. **9a. Per-keyid cap check.** Check the [webhook replay-cache cap](#webhook-replay-dedup-sizing). Reject with `webhook_signature_rate_abuse` if exceeded. Runs before cryptographic verify (step 10) for the same cheap-rejection rationale as request signing. 10. Compute the canonical signature base per RFC 9421 §2.5 using the covered components, after applying `@target-uri` canonicalization AND `@authority` derivation per [the request-signing profile](#adcp-rfc-9421-profile). **The `@authority` rule is load-bearing for webhook security:** verifiers MUST derive `@authority` from the HTTP/2+ `:authority` pseudo-header when present, otherwise from the as-received HTTP/1.1 `Host` header — NOT from reverse-proxy routing state, load-balancer metadata, or any `Host` value a forward proxy may have rewritten in transit. If both `:authority` and `Host` are present on the as-received request, they MUST be byte-equal after canonicalization (RFC 7540 §8.1.2.3 equivalence); divergence rejects with `webhook_target_uri_malformed`. The canonicalized `@authority` MUST byte-for-byte match the authority component of the canonical `@target-uri`; mismatch rejects with `webhook_target_uri_malformed`. That byte-match against the signed `@target-uri` — not the choice of source header — is the only safe gate, because `Host` itself can be rewritten in transit. Implementers building from this checklist alone — without cross-referencing the profile — MUST apply this rule; skipping it silently accepts a cross-vhost replay vector (an attacker intercepts a TLS-terminated webhook and replays it to a second vhost on the same verifier pool: same cert SAN, different `Host`). After canonicalization completes, verify the signature against the JWK (`webhook_signature_invalid` on failure). 11. Recompute `content-digest` from the received body bytes and compare (`webhook_signature_digest_mismatch` on mismatch). REQUIRED — no policy branch. 12. Check the nonce against the replay cache. Reject if `(keyid, nonce)` has been seen within the replay-cache TTL (`webhook_signature_replayed`). 13. **Only after steps 1–12 have all passed**, insert `(keyid, nonce)` into the replay cache with TTL = `(expires − now) + 60 s`. This insert MUST happen before the body-well-formedness check at step 14 so that a captured frame carrying a valid signature over a malformed body cannot be replayed to burn crypto-verify CPU on each retry — the nonce is burned on first sighting of a cryptographically-valid frame, regardless of body shape. The load-bearing cap invariant this ordering preserves is documented after step 14b. 14. **Body well-formedness.** Verifiers MUST reject bodies containing duplicate object keys (`webhook_body_malformed`). Per RFC 8259 §4, duplicate-key parse behavior is unpredictable — the signature is valid over the bytes on the wire, but two parsers can disagree on the parsed value, which is a parser-differential attack class (cf. CVE-2017-12635). This check closes the gap between the signature verifier's view of the payload and the downstream consumer's view. A verifier that crashes rather than returning a structured `webhook_body_malformed` error is conformant-but-suboptimal — senders receive no actionable error code. The conformance fixture for this check is the `duplicate-keys-conflicting-values` vector in `static/test-vectors/webhook-hmac-sha256.json` — the 9421 profile MUST apply the same body-well-formedness rule after signature verification succeeds. `webhook_body_malformed` is distinct from `webhook_signature_digest_mismatch`: the signature IS valid; the body parses to ambiguous state. **14a. Strict-parse requirement.** The check MUST use a parser that exposes duplicate keys — a last-wins/first-wins default that silently discards them does not satisfy this requirement. Query libraries that happily return a value on duplicate-key input without surfacing the collision also do not satisfy this requirement, regardless of marketing as "safe" or "strict" (cf. `tidwall/gjson` in Go — a query library, not a validator). Per-language strict-parse escape hatches, canonical non-exhaustive list: * **Python**: stdlib `json.loads(..., object_pairs_hook=...)` — detect duplicates inside the hook and raise. Satisfies the check. * **Node**: no strict mode in `JSON.parse`. Use a streaming parser (`stream-json`, `jsonparse`) with a duplicate-key event handler. `secure-json-parse` is NOT sufficient by default: its protections target prototype-pollution keys (`__proto__`, `constructor`), not data-key duplicates, which it still collapses last-wins. Configure it to reject data-key duplicates explicitly or layer a streaming parser underneath. * **Go**: `encoding/json` has no strict mode and does not detect duplicates. Use `json.Decoder` token-walk with an explicit `map[string]struct{}` unique-key guard per object scope, OR `goccy/go-json` with `decoder.DisallowDuplicateKey()` explicitly enabled (NOT the default). Do NOT use `tidwall/gjson` for this check — it is a query library that returns the last value on duplicate-key input without signaling the collision. * **Java**: Jackson `DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY` (disabled by default, enable explicitly). * **Ruby**: stdlib `JSON.parse` has no detection hook. Use `Oj.load(..., mode: :strict)` with the `allow_nan: false` / duplicate-rejection options explicitly configured. **14b. Logging discipline.** Verifiers SHOULD NOT log full request body bytes on a `webhook_body_malformed` rejection; log `keyid`, nonce, byte length, and the specific duplicate key names only. An attacker holding a compromised signer key can otherwise force attacker-chosen bytes into defender logs at scale, burning a replay-cache slot per frame but leaving an attacker-controlled log trail for SIEM poisoning or credential exfiltration follow-on attacks. When logging duplicate key names, verifiers MUST sanitize each name with the following rules applied in order: * **(a) Truncate at the first non-printable codepoint** and emit `` where N is the byte length of the truncation prefix. This elides position information (the placement of a non-printable within the key name would otherwise itself be an attacker channel, encodable as bit positions) while preserving the "something was wrong here" diagnostic signal. The non-printable set MUST include at minimum: **C0 controls** (U+0000–U+001F), **DEL** (U+007F), **C1 controls** (U+0080–U+009F, terminal control semantics in multi-byte form), **bidi controls and isolates** (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069 — reverse rendering in terminals and SIEM UIs), **line and paragraph separators** (U+2028, U+2029 — render as line breaks in many log viewers, enabling row-injection), **zero-width characters** (U+200B–U+200D — invisible obfuscation), and the **byte-order mark** (U+FEFF — parser corruption). Implementations MAY extend the set to a broader Unicode non-printable classification but MUST NOT narrow it — an ASCII-only check misses bidi-override and line-separator attacks that reopen exactly the log-injection channel this rule exists to close. * **(b) Truncate to at most 32 bytes** at the last complete UTF-8 codepoint boundary. Realistic AdCP field names top at roughly 24 characters (`signed_authorized_agents`), so 32 is a generous cap while still bounding the attacker-controlled-byte surface. Truncation MUST occur at the last complete UTF-8 codepoint boundary at or below 32 bytes so multi-byte sequences are not split mid-codepoint and invalid-UTF-8 does not land in logs (different verifiers truncating the same input to different invalid-UTF-8 tails would also break log aggregation). * **(c) Cap the number of duplicate key names logged per rejection at 4**, emitting `<...N more>` if exceeded. Diagnostic value of knowing 4 vs 8 vs 16 colliding keys is near zero. Without these constraints, the key-name channel remains an attacker-controlled-byte side channel — smaller than full-body logging but non-zero, and well-precedented as a log-injection vector. Signers that log upstream-input rejections (see [the duplicate-object-keys signer-side rule](#legacy-hmac-sha256-fallback-deprecated-removed-in-40)) MUST apply the same (a)/(b)/(c) sanitization rules to any key names surfaced in signer-side error output; the channel shape is identical even though the wire direction is inverted. **A load-bearing invariant for the webhook cache.** External traffic without the signer's private key cannot grow this cache: every entry admitted at step 13 has already passed step 10's cryptographic verification, so any party driving cache growth is either the legitimate key holder or someone who has compromised the key — the case the per-keyid cap (step 9a) and the new-keyid admission-pressure alarm (see [Webhook replay dedup sizing](#webhook-replay-dedup-sizing)) are designed to detect. The invariant mirrors the [analogous request-signing rule](#verifier-checklist-requests) (see the "load-bearing invariant for the cap" paragraph immediately after step 13 there). Future edits to the webhook checklist MUST preserve this ordering: moving the step 13 insert before step 10's signature verification would let any external party flood the cache using forged structurally-valid signatures. There is no subsequent brand-operator authorization step on the webhook path — the signature establishes the seller's identity, and that identity is sufficient to accept the webhook. Application-layer dedup on `idempotency_key` runs after signature verification (step 13) to protect against duplicate side effects. **One signature per webhook.** Verifiers MUST process exactly one `Signature-Input` label and ignore additional labels. ##### Webhook replay dedup sizing Replay dedup for webhooks reuses the `(keyid, nonce)` key shape and TTL semantics from [Transport replay dedup](#transport-replay-dedup), but the buyer-side cache sees signatures from every seller the buyer integrates with — fundamentally different fan-in from the request-side case. * **Per-keyid entry cap**: recommended 100,000 entries (10× lower than the request-side 1,000,000 ceiling). A seller emitting 100K unique webhooks in a 6-minute window is 275/sec sustained from a single signer — plenty of headroom for normal operations and still a strong signal of misconfiguration or key compromise. * **Aggregate cache cap**: recommended `min(aggregate_memory_budget, 10,000,000)` entries across all signers. On aggregate-cap exceeded, verifiers MUST reject new signatures with `webhook_signature_rate_abuse` and SHOULD alert operators — silent eviction creates replay windows precisely when the verifier is under attack. * **Per-seller budget**: operators SHOULD budget per-seller by integration criticality rather than equal-weighting all sellers at 100K each. A spend-committing seller's webhook fan-in differs from a discovery-only seller's. * **New-keyid admission pressure** (MUST track, SHOULD alert). Verifiers MUST track the rate of cache entries admitted from previously-unseen `keyid`s per unit time (e.g., a 5-minute rolling count of distinct `keyid`s inserting their first entry). A sudden spike in new-keyid admission rate is the signature of a **distributed-compromise attack**: an attacker holding N compromised signer keys can drive N entries per TTL window each, every key staying well within its per-keyid cap (step 9a), while collectively saturating the aggregate cache. Each key's traffic individually looks like a low-volume legitimate signer; the aggregate shape is the signal. Verifiers SHOULD alert when new-keyid admission exceeds **any** of four thresholds (whichever triggers first), each closing a distinct attacker pattern: * **(a)** a **short-window ratio threshold** comparing the current admission rate against a short-horizon moving-average baseline — catches sudden spikes against a stable baseline. * **(b)** a **medium-window ratio threshold** against a medium-horizon percentile baseline — catches multi-week ramp-up attacks, whose traffic is dominated by the baseline tail at that horizon. * **(c)** a **long-window ratio threshold** against a long-horizon percentile baseline — catches multi-month ramp-up attacks that drift the medium-horizon anchor with them. * **(d)** a **proportional ceiling** combining an absolute floor with a fraction of the unique-keyid count over a documented window — catches sparse-traffic verifiers whose ratio baselines are near zero, AND auto-scales to operators of any size (small verifiers get a low proportional floor; enterprise verifiers get a proportionally larger one). **The four categories are normative; the concrete threshold values are NOT.** Operators MUST treat any published example values as starting points, baseline their own traffic, and tune accordingly — published normative threshold numbers would hand attackers an oracle into the detection posture. Concrete starting values, baselining methodology, and attack-scenario walkthroughs are published in the non-normative [Webhook Verifier Tuning Guide](/dist/docs/3.1.20/building/by-layer/L1/webhook-verifier-tuning). Implementations MAY ship the guide's starting values as first-deployment defaults but MUST expose each threshold as a tunable configuration parameter (e.g., environment variable, config file) — hardcoded starting values become de facto operator-visible defaults and re-introduce the attacker oracle. Implementations SHOULD log or alarm a `threshold_tuning_overdue` event when any threshold remains at its shipped starting value more than 30 days past the verifier's first admission; this gives the operator-tuning obligation a testable, auditable hook rather than relying on operator diligence alone. The alarm payload MUST name which clause (a, b, c, or d) tripped so operator triage can respond to the right threat shape. Alarming here catches the slow-burn distributed-compromise pattern *before* the aggregate cap triggers — once `webhook_signature_rate_abuse` fires on the aggregate cap, the cache is already full and every legitimate signer is being rejected. Alarms SHOULD route to incident response, not to automatic revocation: the distinguishing signal between "attack" and "onboarding a batch of new sellers" is operator context, not machine-derivable, and automatic revocation on alarm creates a denial-of-service vector (any party driving legitimate new-signer onboarding can trip the alarm and cause mass revocation). **Cross-endpoint scoping (MUST).** A buyer that exposes more than one webhook endpoint (per-integration, per-environment, per-tenant, or per-pod in a horizontally-scaled fleet) MUST either: 1. **Share a single logical replay cache across every endpoint** a given signer can reach (Redis / shared dedup service — not per-process in-memory), so that a `(keyid, nonce)` inserted by endpoint A is visible to endpoint B before step 12 runs; or 2. **Include the canonical destination URL in the replay key**, scoping dedup to `(keyid, canonical destination URL, nonce)`. The canonical form is the `@target-uri` after normalisation per [the request-signing profile](#adcp-rfc-9421-profile) (scheme lowercased, host IDNA-normalised, default port elided, fragment stripped). Option 1 is stronger — it rejects cross-endpoint replay outright within the ±360 s window. Option 2 is weaker — the same `(keyid, nonce)` is replayable at each distinct endpoint URL, but because the signed `@target-uri` is covered by the signature, the verifier at endpoint B will reject any payload whose `@target-uri` was signed for endpoint A with `webhook_signature_digest_mismatch` (the canonical signature base fails) or `webhook_signature_invalid`. Option 2 is acceptable only when the signer's canonical `@target-uri` is per-endpoint; a signer that signs the same payload for multiple endpoints defeats option 2 and MUST use option 1. Per-pod or per-region *in-memory* replay caches without a shared tier are non-conformant for buyers that run more than one endpoint: they leave a cross-endpoint replay window bounded only by ±360 s and the attacker's ability to route to a different pod. Operators MUST either front the webhook fleet with a shared dedup tier or document and enforce the per-endpoint URL scoping above. All other rules from [Transport replay dedup](#transport-replay-dedup) apply verbatim: in-memory LRU for single-process verifiers, Redis `SETNX` at high volume, atomic insert-with-cap-check at step 13 in distributed deployments. ##### Webhook revocation and rotation Signers MUST publish revocations via the same combined revocation list used for request signing — see [Transport revocation](#transport-revocation). A single list per operator origin covers governance-signing, request-signing, and webhook-signing keys. **HMAC→9421 migration.** A buyer transitioning from HMAC to 9421 MUST disable its HMAC verifier once the seller has acknowledged the cutover. Running both verifiers concurrently leaves the HMAC path exploitable for the original 5-minute replay window plus however long the buyer forgets to turn it off; "just in case" operational posture keeps the deprecated path live past the intended deprecation. Sellers SHOULD reject `authentication` blocks from a counterparty that has previously been migrated to 9421, logging the rejection. During the cutover window, buyers MAY run both verifiers but SHOULD maintain a single dedup keyspace so that the same logical event under either scheme maps to the same `(sender identity, idempotency_key)` tuple — see the [Reliability](/dist/docs/3.1.20/building/by-layer/L3/webhooks#reliability) section for dedup scope under mixed-mode delivery. ##### Webhook error taxonomy Codes parallel the [request-signing error taxonomy](#transport-error-taxonomy), prefixed `webhook_` so SDK error handling distinguishes the two profiles. Buyers MAY return `401` to the seller on any of these; a seller's retry loop will replay with the same signature bytes, so every code in this table is non-retryable to the sender — signature failures, authority-mismatch, and mode-mismatch all produce identical outputs on retry — even though HTTP semantics permit retry. | Failure | Code | | ----------------------------------------------------------------------------------------- | ----------------------------------------- | | `Signature` or `Signature-Input` header malformed or one without the other | `webhook_signature_header_malformed` | | Required sig-param absent | `webhook_signature_params_incomplete` | | `tag` not `adcp/webhook-signing/v1` | `webhook_signature_tag_invalid` | | `alg` not in allowlist | `webhook_signature_alg_not_allowed` | | Signature window invalid | `webhook_signature_window_invalid` | | Required covered components missing (including `content-digest`) | `webhook_signature_components_incomplete` | | `keyid` not in seller JWKS after one refetch | `webhook_signature_key_unknown` | | JWK `adcp_use` ∉ , absent, or `key_ops` lacks `verify` | `webhook_signature_key_purpose_invalid` | | `keyid` ∈ `revoked_kids` | `webhook_signature_key_revoked` | | Revocation list not refreshed within grace | `webhook_signature_revocation_stale` | | Cryptographic verification failed | `webhook_signature_invalid` | | `content-digest` mismatch | `webhook_signature_digest_mismatch` | | Body contains duplicate object keys (parser-differential attack class) | `webhook_body_malformed` | | `@authority` does not match signed `@target-uri` authority component (cross-vhost replay) | `webhook_target_uri_malformed` | | Nonce already seen within window | `webhook_signature_replayed` | | Per-keyid replay cache exceeded cap | `webhook_signature_rate_abuse` | | Registered auth mode does not match signature mode on received webhook | `webhook_mode_mismatch` | **Retry semantics for verification failures.** At-least-once delivery tells senders to retry on any non-2xx response, but a verification failure is not a transient error — the signature bytes and request context arrive identically on every retry, so every retry fails identically. Senders MUST treat a `401` response carrying `WWW-Authenticate: Signature error="webhook_*"` (any code defined in the taxonomy above, including `webhook_signature_*`, `webhook_target_uri_malformed`, and `webhook_mode_mismatch`) as a terminal failure for that specific delivery attempt: stop retrying the current event, log the failure with the error code for operator attention, and continue the normal retry queue for subsequent events. Senders SHOULD route sustained `webhook_*` error rates above an operator-defined threshold to incident response rather than continuing to emit them — persistent signature, authority, or mode failures indicate a key-rotation coordination problem, a misconfigured verifier, or a compromise, all of which need human action. Receivers MUST NOT silently discard these failures; surfacing them in operator logs is part of the security posture. **Editor note on future additions.** The wildcard `webhook_*` terminal-failure classification above is an eager sweep: any new code added to the taxonomy inherits terminal-per-delivery semantics without individual review. Editors adding a new `webhook_*` code that SHOULD be retryable (e.g., a future transient-infrastructure signal) MUST update this paragraph to carve out the exception at the point of addition — do not rely on the pattern match to remain safe for codes not yet defined. ##### Webhook migration timeline | Phase | Behavior | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 3.0 GA | 9421 webhook signing is baseline for any seller that emits webhooks. Legacy HMAC-SHA256 fallback available when buyer populates `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`; sellers MAY decline to support it. | | 3.x | HMAC fallback is deprecated. Sellers SHOULD log warnings when selected. SDKs SHOULD surface a deprecation notice to buyers that still configure `authentication`. | | 4.0 | `authentication` on `push_notification_config` and `accounts[].notification_configs[]` is removed from the schema. 9421 webhook signing is the only supported path. | #### TMP cross-reference **TMP keys MUST declare a distinct `adcp_use` value** (or omit it entirely) so verifiers reject them for request signing via step 8. Publishing TMP keys at the same `jwks_uri` as request-signing and webhook-signing keys is permitted and encouraged — one publication pattern, five signing systems, each `kid`-scoped: * governance JWS — `adcp_use: "governance-signing"` * request signing (RFC 9421) — `adcp_use: "request-signing"` (also signs webhooks; see [Webhook callbacks](#webhook-callbacks)) * webhook signing (RFC 9421) — uses the `request-signing` key; the legacy `adcp_use: "webhook-signing"` value is **deprecated** (still accepted, pending removal — see follow-up issue in the deprecation note) * designated-task response-payload JWS — `adcp_use: "response-signing"` (see [Designated-task payload-envelope response signing](#designated-task-response-signing) above) * TMP envelope — TMP's own future `adcp_use` value Cross-purpose reuse is prevented automatically because every verifier enforces an exact `adcp_use` match on its own profile. Trusted Match Protocol signs match-time requests with its own Ed25519 envelope. TMP's per-request budget (sample-verify at \~5%) is too tight for full RFC 9421 verification on every call. **TMP signing is out of scope for this section**; this profile only constrains how TMP keys are published alongside request-signing keys on the same JWKS. #### Transport migration timeline AdCP 4.0 is the next breaking-changes accumulation window. Mandatory request signing for spend-committing operations is one of its floor requirements — the minimum security bar for AdCP 4.0 spend traffic — not the sole headline feature. Other v4.0 changes will accumulate on the [roadmap](/dist/docs/3.1.20/reference/roadmap#v40-planned). | Phase | Status | Behavior | | ------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 3.0 GA | Optional, capability-advertised | Verifiers MAY validate; `required_for: []` by default. Signers MAY sign. Reference vectors ship; reference SDK pilots begin. | | 3.x | Reference SDKs ship; pilots surface bugs | Conformance test vectors drive cross-SDK interop. Early adopters turn on `required_for` with named counterparties, incrementally. | | 4.0 | Required for spend-committing operations | `required_for` MUST include `create_media_buy`, `acquire_*`, and any spend-committing operation the verifier supports. Signers MUST sign. `covers_content_digest: "required"` recommended for those operations. | Implementations that ship signing in 3.x SHOULD enable verifier-side `required_for` selectively (per-counterparty pilot, then broader rollout) before 4.0 to validate end-to-end paths against real traffic — this is what makes the 4.0 transition feasible without ecosystem-wide breakage. #### Request verifier reference (TypeScript) Illustrative only. The `verify9421` and `parseSignatureInput` callbacks encapsulate protocol-specific canonicalization and signature verification; implementations should pin a specific RFC 9421 library that has been validated against the AdCP conformance test vectors at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/). ```ts theme={null} import { createRemoteJWKSet } from "jose"; class RequestSignatureError extends Error { constructor(public code: string) { super(code); } } const ALLOWED_ALGS = new Set(["ed25519", "ecdsa-p256-sha256"]); const REQUIRED_TAG = "adcp/request-signing/v1"; const REQUIRED_COMPONENTS = new Set(["@method", "@target-uri", "@authority"]); const REQUIRED_PARAMS = ["created", "expires", "nonce", "keyid", "alg", "tag"] as const; export async function verifyAdcpRequestSignature(req: Request, ctx: { operationName: string; requiredFor: Set; contentDigestPolicy: "required" | "forbidden" | "either"; resolveJwk: (keyid: string) => Promise<{ jwk: unknown; agentUrl: string }>; // throws _key_unknown after refetch isKeyRevoked: (keyid: string) => Promise; isRevocationStale: () => Promise; isKeyidAtCapacity: (keyid: string) => Promise; isReplayed: (keyid: string, nonce: string) => Promise; recordNonce: (keyid: string, nonce: string, ttlSeconds: number) => Promise; verify9421: (req: Request, jwk: unknown, covered: string[]) => Promise; // throws on signature or digest failure parseSignatureInput: (header: string) => { keyid?: string; alg?: string; created?: number; expires?: number; nonce?: string; tag?: string; components: string[]; }; }) { const sigInput = req.headers.get("signature-input"); // Pre-check: required_for / downgrade protection. if (!sigInput) { if (ctx.requiredFor.has(ctx.operationName)) throw new RequestSignatureError("request_signature_required"); return; // operation doesn't require a signature; verify nothing. } let parsed; try { parsed = ctx.parseSignatureInput(sigInput); } catch { throw new RequestSignatureError("request_signature_header_malformed"); } // 2: presence for (const p of REQUIRED_PARAMS) { if ((parsed as any)[p] == null) throw new RequestSignatureError("request_signature_params_incomplete"); } // 3: tag if (parsed.tag !== REQUIRED_TAG) throw new RequestSignatureError("request_signature_tag_invalid"); // 4: alg if (!ALLOWED_ALGS.has(parsed.alg!)) throw new RequestSignatureError("request_signature_alg_not_allowed"); // 5: window (including expires > created) const now = Math.floor(Date.now() / 1000); if (parsed.expires! <= parsed.created! || parsed.created! > now + 60 || parsed.expires! < now - 60 || parsed.expires! - parsed.created! > 300) { throw new RequestSignatureError("request_signature_window_invalid"); } // 6: components for (const c of REQUIRED_COMPONENTS) { if (!parsed.components.includes(c)) throw new RequestSignatureError("request_signature_components_incomplete"); } const coversCd = parsed.components.includes("content-digest"); if (ctx.contentDigestPolicy === "required" && !coversCd) { throw new RequestSignatureError("request_signature_components_incomplete"); } if (ctx.contentDigestPolicy === "forbidden" && coversCd) { throw new RequestSignatureError("request_signature_components_unexpected"); } // 7: JWK resolution const { jwk } = await ctx.resolveJwk(parsed.keyid!); // throws _key_unknown // 8: key purpose const j = jwk as any; if (j.use !== "sig" || !Array.isArray(j.key_ops) || !j.key_ops.includes("verify") || j.example_use !== "request-signing") { throw new RequestSignatureError("request_signature_key_purpose_invalid"); } // 9: revocation (BEFORE crypto verify) if (await ctx.isRevocationStale()) throw new RequestSignatureError("request_signature_revocation_stale"); if (await ctx.isKeyRevoked(parsed.keyid!)) throw new RequestSignatureError("request_signature_key_revoked"); // 9a: per-keyid cap (BEFORE crypto verify) — prevents amplified crypto work by abusive/misconfigured signer. if (await ctx.isKeyidAtCapacity(parsed.keyid!)) { throw new RequestSignatureError("request_signature_rate_abuse"); } // 10 + 11: crypto verify, content-digest recompute — both inside verify9421. try { await ctx.verify9421(req, jwk, parsed.components); } catch (e: any) { if (e?.code === "digest_mismatch") throw new RequestSignatureError("request_signature_digest_mismatch"); throw new RequestSignatureError("request_signature_invalid"); } // 12: replay check if (await ctx.isReplayed(parsed.keyid!, parsed.nonce!)) { throw new RequestSignatureError("request_signature_replayed"); } // 13: replay insert (only after all checks pass) await ctx.recordNonce(parsed.keyid!, parsed.nonce!, (parsed.expires! - now) + 60); } ``` ### Budget Validation Validate budgets before committing: ```javascript theme={null} async function validateBudget(request, account) { const { budget } = request; // Check positive amount if (budget.amount <= 0) { throw new ValidationError('Budget must be positive'); } // Check against account limits const limits = await getAccountLimits(account.account_id); if (budget.amount > limits.daily_spend_limit) { throw new BudgetError('Exceeds daily spend limit'); } // Check available balance const balance = await getAvailableBalance(account.account_id); if (budget.amount > balance) { throw new BudgetError('Insufficient balance'); } } ``` ## Transport Security AdCP's application-layer security primitives (9421 signing, JWS governance, idempotency) assume the transport does not help the attacker. A misconfigured TLS stack breaks that assumption — it downgrades a protocol designed to withstand active on-path adversaries into one that trusts every intermediary. This section is normative for every AdCP endpoint — inbound (seller and buyer API surfaces) and outbound (JWKS fetch, brand.json fetch, revocation list fetch, webhook delivery). It is deliberately prescriptive so operators do not have to reason from first principles about cipher suites at 3 a.m. ### TLS version policy * **TLS 1.3 is RECOMMENDED** for every AdCP endpoint. * **TLS 1.2 is the minimum.** Endpoints MUST reject TLS 1.1 and below at the handshake. * **Client-side verifiers** (e.g., an AdCP server fetching a counterparty's JWKS, brand.json, or revocation list) MUST refuse to negotiate below TLS 1.2. Libraries that still default to TLS 1.0 for "compatibility" MUST be configured explicitly. * SSL 2.0, SSL 3.0, TLS 1.0, and TLS 1.1 MUST NOT be enabled — not for any endpoint, not for any legacy partner, not even on a separate port. ### Cipher suites and algorithms * TLS 1.3: use the IETF-defined suites (`TLS_AES_128_GCM_SHA256`, `TLS_AES_256_GCM_SHA384`, `TLS_CHACHA20_POLY1305_SHA256`). All three are AEAD; no other TLS 1.3 suites exist. Do not disable any of them arbitrarily — operators who disable ChaCha20 on "speed" grounds are one client quirk away from broken mobile clients. * TLS 1.2: restrict to **AEAD-only** ECDHE suites. The permitted set is `ECDHE-ECDSA-AES128-GCM-SHA256`, `ECDHE-ECDSA-AES256-GCM-SHA384`, `ECDHE-ECDSA-CHACHA20-POLY1305`, `ECDHE-RSA-AES128-GCM-SHA256`, `ECDHE-RSA-AES256-GCM-SHA384`, `ECDHE-RSA-CHACHA20-POLY1305`. * CBC-MAC, RC4, 3DES, DES, NULL, EXPORT, anonymous DH, and static RSA key-exchange suites MUST be disabled on TLS 1.2 — their presence silently downgrades the security properties of everything built above the handshake. * Server certificates MUST use ECDSA (P-256 or P-384) or RSA ≥ 2048 bits. RSA \< 2048 MUST NOT be used. * Endpoints MUST prefer server-side cipher ordering (OpenSSL `SSL_OP_CIPHER_SERVER_PREFERENCE`, nginx `ssl_prefer_server_ciphers on`) so a weak client cannot force a weak suite when a strong one is mutually available. ### Certificate validation (outbound fetches) Every outbound HTTPS request AdCP makes — JWKS, brand.json, revocation list, webhook callback, aggregator proxy — MUST perform full PKIX validation. The specific checks: * **Trust chain** MUST terminate at a public root the operator has intentionally included. No `--insecure`, no `verify=False`, no `rejectUnauthorized: false` anywhere in production code paths. This is the single most common production compromise — an engineer turns off verification to work around a cert issue in staging, the flag ships. * **SAN match** is the authoritative identity check. The certificate MUST have a Subject Alternative Name entry matching the URL host. CN-only fallback MUST NOT be accepted; major HTTP clients still support it for legacy reasons, but AdCP verifiers MUST require SAN. * **Expiry** MUST be checked against the current clock. Fetching a JWKS from a domain whose TLS cert expired last week is a governance red flag, not a compatibility problem. * **Hostname verification** MUST be enabled in the library config. Several popular HTTP client libraries ship with hostname verification on by default; a surprising number have a flag that disables it. AdCP implementations MUST assert hostname verification is on, not assume it. * **OCSP stapling** SHOULD be accepted when offered; OCSP must-staple on operator-controlled certificates is RECOMMENDED. Must-staple turns a missing staple into a hard failure, which closes the soft-fail-on-OCSP loophole. * **Certificate Transparency (CT)** SCTs SHOULD be checked on endpoints serving regulated spend. Browsers already enforce CT; AdCP SDKs fetching governance JWKS on a regulated-category workflow SHOULD too, so a hidden mis-issued cert is detectable. * **Pinning** is NOT required at the protocol layer and SHOULD be avoided for counterparty-supplied URLs (brand.json, JWKS) because it collides with legitimate operator cert rotation. Pinning to a public-CA chain (intermediate-pin) is acceptable; pinning to a specific leaf cert is discouraged. ### Inbound server-side headers ```javascript theme={null} app.use((req, res, next) => { // HSTS: 1 year, include subdomains, preload-eligible. MUST be on every HTTPS response. res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // No framing of AdCP API responses — even though they're JSON, frame isolation // protects any error or debug HTML that could leak through. res.setHeader('X-Frame-Options', 'DENY'); // MIME sniffing off: responses declare their type, clients MUST respect it. res.setHeader('X-Content-Type-Options', 'nosniff'); // Prevent referrers leaking to external URLs supplied by counterparties. res.setHeader('Referrer-Policy', 'no-referrer'); // AdCP endpoints serve no browser-facing HTML — block script-source loading outright. // If your operator reuses the same origin for a dashboard, adjust this per-path. res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'"); next(); }); ``` **HSTS max-age MUST be ≥ 31536000 (1 year)** for any domain serving an AdCP endpoint. `includeSubDomains` MUST be set unless the operator has a documented reason not to. Domains serving spend-committing AdCP endpoints SHOULD be submitted to the HSTS preload list. ### Client / outbound TLS hardening Outbound-fetch code paths (governance JWKS, brand.json, revocation list, webhook delivery, aggregator proxy) MUST: * Use a connection pool with a fixed per-host cap and a fixed overall cap. Unbounded pools are a resource-exhaustion surface. * Cap TLS handshake time at 10 s and total request time at 30 s by default — counterparty-supplied URLs are a tarpit DoS vector otherwise. * Pin the connection to the IP address that passed the [SSRF controls](#webhook-url-validation-ssrf) — DNS re-resolution between the SSRF check and the actual connect is how TOCTOU bypasses land. * Refuse redirects on security-sensitive fetches. JWKS, brand.json, revocation list, and webhook-callback fetches MUST NOT follow redirects; the [brand.json resolution rule](#buyer-identity-resolution) already says "one redirect (`authoritative_location` or `house` variant), no chains," and the initial `/.well-known/adagents.json` fetch follows same-registrable-domain redirects only (apex↔www, HTTPS-preserving, ≤ 3 hops, anchored on the originally-requested domain) — everywhere else, zero. The `adagents.json` `authoritative_location` dereference is "everywhere else": zero redirects. * Disable session resumption across trust boundaries. Resuming a TLS session with an attacker-controlled counterparty onto a later verified counterparty (same IP via DNS rebind) is a well-known class of confusion; library defaults are usually fine, but the operator MUST audit. ### TLS renegotiation and downgrade * TLS 1.2 **secure renegotiation** (RFC 5746) MUST be enabled if renegotiation is supported at all. Insecure-renegotiation-tolerant stacks are a MUST-disable. * **TLS compression** (CRIME) MUST be off. * **Heartbeat extension** MUST be off on TLS 1.2 endpoints (Heartbleed lineage). * **0-RTT / early-data** on TLS 1.3 MUST NOT be enabled for any endpoint that accepts mutating AdCP operations. 0-RTT is replayable by design; idempotency and signature-nonce dedup are not free rescues once the request has hit application logic. Read-only discovery endpoints (`get_adcp_capabilities`, `list_creative_formats`) MAY use 0-RTT; everything else MUST NOT. ### mTLS transport When [mTLS](/dist/docs/3.1.20/building/by-layer/L2/authentication#mtls) is the authentication mechanism: * The client certificate SAN / Subject MUST match the buyer's registered domain as declared in `adagents.json` or `brand.json`. Relying on any header field (`X-Forwarded-Client-Cert`, `X-Client-DN`, etc.) is [explicitly forbidden](#buyer-identity-resolution) — header fields can be injected across misconfigured proxies. * The terminating edge (load balancer, mesh sidecar) MUST forward the verified certificate identity to the AdCP server over an in-cluster channel the server can authenticate. Unauthenticated sidecar headers are a bypass — deploy mTLS end-to-end, or pin the in-cluster channel. * Client certificates MUST be checked against a CRL or OCSP responder operated by the operator. "Issued by us" is not the same as "still valid." ### Private-network and metadata protection This section's transport controls do not substitute for the [SSRF controls](#webhook-url-validation-ssrf) on counterparty-supplied URLs. Every outbound fetch to a counterparty URL MUST apply the SSRF rules — reject non-HTTPS, reject IPs in reserved ranges (including cloud-metadata addresses), refuse redirects, cap size and time. TLS is useless if the URL points at `169.254.169.254`. ### What this section does NOT replace Transport security is the floor, not the ceiling. Even a flawless TLS stack does not replace: * **Application-layer body integrity** ([request signing](#request-signing) and [webhook callbacks](#webhook-callbacks)) — TLS protects the wire, not the payload after a compromised intermediary. * **Governance attestation** ([signed governance context](#signed-governance-context)) — TLS does not tell the seller whether the buyer's governance agent authorized this spend. * **Idempotency** ([request safety](#request-safety)) — TLS does not prevent the sender from retrying after a network timeout. Operators that confuse "we have a modern TLS configuration" with "our AdCP deployment is secure" are exactly the operators the body-bound signature profile exists to defend against. ## Input Validation ### Request Validation Validate all user-provided input: ```javascript theme={null} const INPUT_LIMITS = { targeting_brief_max_length: 5000, creative_upload_max_size: 100 * 1024 * 1024, // 100MB max_formats_per_request: 50, max_products_per_query: 100 }; function validateRequest(request) { // Check string lengths if (request.brief?.length > INPUT_LIMITS.targeting_brief_max_length) { throw new ValidationError('Brief exceeds maximum length'); } // Validate IDs are proper UUIDs if (request.product_id && !isValidUUID(request.product_id)) { throw new ValidationError('Invalid product_id format'); } // Reject unexpected fields const allowedFields = ['brief', 'product_id', 'budget', 'context_id']; for (const field of Object.keys(request)) { if (!allowedFields.includes(field)) { throw new ValidationError(`Unexpected field: ${field}`); } } } ``` ### SQL Injection Prevention Always use parameterized queries: ```javascript theme={null} // GOOD: Parameterized query (request-supplied account_id after auth precheck) const result = await db.query( 'SELECT * FROM media_buys WHERE id = $1 AND account_id = $2', [mediaBuyId, request.account.account_id] ); // BAD: String concatenation (NEVER do this) // const result = await db.query( // `SELECT * FROM media_buys WHERE id = '${mediaBuyId}'` // ); ``` ## Audit Logging ### Required Log Events Log all security-relevant events: ```javascript theme={null} const LOG_EVENTS = { AUTH_SUCCESS: 'auth_success', AUTH_FAILURE: 'auth_failure', BUDGET_COMMIT: 'budget_commit', BUDGET_MODIFY: 'budget_modify', ACCESS_DENIED: 'access_denied', WEBHOOK_VERIFIED: 'webhook_verified', WEBHOOK_REJECTED: 'webhook_rejected' }; function logSecurityEvent(eventType, details) { console.log(JSON.stringify({ event: eventType, timestamp: new Date().toISOString(), agent_id: details.agentId, account_id: details.accountId, ip_address: details.ipAddress, resource: details.resource, outcome: details.outcome, // NEVER log: credentials, PII, targeting briefs })); } ``` ### Log Retention * Security logs: 90 days minimum (365 days recommended) * Financial logs: 7 years (compliance requirement) * Access logs: 30 days minimum ## Security Checklist ### For Publishers (AdCP Servers) * [ ] Implement strong authentication (OAuth 2.0, API keys, or mTLS) * [ ] Enforce agent and account isolation in all database queries * [ ] Implement idempotency for financial operations * [ ] Validate all input with strict schema validation * [ ] Use TLS 1.3+ for all communications * [ ] Verify webhook signatures cryptographically * [ ] Log all security events immutably ### For Buyer Agents (AdCP Clients) * [ ] Store credentials in secure key management system * [ ] Rotate credentials every 90 days * [ ] Use HTTPS for all AdCP communications * [ ] Validate responses from publishers * [ ] Implement alerts for unusual spending patterns ### For Orchestrators (Multi-Agent, Multi-Account) * [ ] Store each agent's credentials separately (encrypted) * [ ] Enforce agent and account filtering in ALL queries * [ ] Use row-level security in databases * [ ] Log all operations with agent and account identity * [ ] Implement per-agent rate limiting ## Next Steps * **Security Model**: See [Security Model](/dist/docs/3.1.20/building/concepts/security-model) for the threat model and the five-layer defense narrative this reference implements * **Webhooks**: See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for webhook security patterns * **Error Handling**: See [Error Handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling) for authentication errors * **Orchestrator Design**: See [Orchestrator Design](/dist/docs/3.1.20/building/operating/orchestrator-design) for multi-tenant security # Webhook Verifier Tuning Guide Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L1/webhook-verifier-tuning Non-normative tuning recipes for webhook verifier thresholds — starting values, baselining methodology, and attack-scenario walkthroughs. This document is non-normative. It provides **starting values** and a tuning methodology for the webhook verifier thresholds whose **structural shape** is specified in [Webhook Security](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-security). The normative spec specifies only the category (short-window ratio, medium-window ratio, long-window ratio, proportional ceiling) and the requirement that thresholds be operator-configurable. This guide tells you where to start and how to tune. **First-30-days oracle risk.** The starting values below are published, therefore attacker-known. A verifier running the shipped defaults is running against an oracle until operators tune the thresholds to their own traffic. **Operators MUST tune each threshold within 30 days of first deployment**; verifiers running published starting values past 30 days are running against a known attacker tuning target. Implementations SHOULD randomize each starting threshold on first deployment, drawing from a log-uniform distribution over \[0.5×, 2×] the starting value (equivalently: ratio-uniform jitter with a 4× spread between the narrowest and widest defaults across a fleet). Narrower distributions (e.g., ±30%, giving only a 1.86× spread) let a disciplined attacker tune to 0.7× the published value and stay under every jittered deployment in the fleet; log-uniform over \[0.5×, 2×] forces the attacker to cover a 4× range, which starts to cost meaningfully in attack volume. **Implementations SHOULD log or alarm a `threshold_tuning_overdue` event** when any threshold remains at its shipped starting value more than 30 days past the verifier's first admission — this gives the 30-day tuning rule a testable, auditable hook (without it, the rule is operator-diligence-only and silently fails when diligence lapses). **Why this guide is separate from the spec.** Publishing concrete threshold values as normative defaults hands attackers an oracle — a disciplined attacker reads the spec and tunes their attack to stay just under the published values. The normative spec deliberately says *what shape the rule has*; this guide says *what numbers to start with*. Operators MUST treat these as starting values, observe their own traffic, and adjust. ## The rule you're tuning Verifiers MUST track new-keyid admission pressure and SHOULD alert when the rate exceeds **any** of four thresholds (whichever triggers first). The normative spec names these four thresholds by category; this guide gives starting values for each category. ## Starting values | # | Category | Starting formula | What it catches | | ----- | -------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **a** | Short-window ratio | `3× the 24-hour moving average` of new-keyid admission rate | Sudden spikes against a stable baseline — the classic "abnormal traffic volume" signal. | | **b** | Medium-window ratio | `2× the 30-day P95` | Multi-week ramp-up attacks. The 30-day P95 is dominated by the baseline-traffic tail, so a 2–3 week ramp cannot drift the reference into the attack. | | **c** | Long-window ratio | `1.5× the 90-day P99` | Multi-month ramp-up attacks. A 60–90 day staged compromise that drifts the 30-day P95 still trips the 90-day P99 because the P99 tail moves much more slowly. | | **d** | Proportional ceiling | `max(20 distinct new keyids, 10% × 30-day unique-keyid count) per 5-minute window` | Sparse-traffic verifiers whose moving averages and P95/P99 values are near zero (small operators), AND auto-scaling for operators of any size. | **These are starting values, not normative defaults.** A fresh deployment can use them day one. As traffic baselines stabilize, tighten or loosen based on the observed false-positive and false-negative rates. ## Baselining methodology Before tuning the thresholds, establish the baseline shape of your verifier's traffic: 1. **Collect 30 days of new-keyid admissions** without alarming. Instrument the rate but do not page operators. 2. **Compute your deployment's P50, P95, P99** of new-keyid admissions per 5-minute window. 3. **Track the unique-keyid count per 30-day sliding window.** This is the denominator for clause (d). 4. **Document your median and peak legitimate onboarding batches.** If you routinely onboard 50 new signers per day (batched into a 10-minute window twice a week), clause (d)'s fixed floor of 20/5-min is too tight; raise it to match your largest legitimate batch. Once the baseline is known, each clause (a)/(b)/(c)/(d) becomes a concrete threshold in your deployment. The spec's OR-of-four shape means any one clause tripping is enough for an alert — so the thresholds do not need to agree on shape, they need to each close a different attacker pattern. ## Attack-scenario walkthroughs ### Scenario 1: Sudden mass-compromise An attacker compromises 100 signer keys over a weekend and begins sending webhooks from all 100 simultaneously starting Monday morning. * **What trips**: clause (a). The 24-hour moving average of new-keyid admissions is \~0 (on a stable verifier); 100 new keyids in one 5-min window is orders of magnitude above `3×` that. * **Alarm detail the operator needs**: which clause (a), so the triage team knows to look for a mass-compromise pattern rather than a single-key spike. ### Scenario 2: Patient multi-week ramp An attacker compromises 5 keys in week 1, 10 in week 2, 20 in week 3, 40 in week 4 — doubling weekly, staying under any "3× yesterday" rule because today's rate is never more than 2× yesterday. * **What trips**: clause (b). The 30-day P95 is dominated by the first three weeks of baseline traffic, so `2×` that is roughly the normal peak; by week 4, 40 keyids/day is 8× the weekly baseline, well over the P95 anchor. * **Miss if you only had clause (a)**: yes. 2× daily ramping stays under 3× short-window MA permanently. ### Scenario 3: Multi-quarter staged compromise An attacker compromises 1 key per day for 90 days — never triggering any daily-or-weekly ratio because today's rate is roughly equal to yesterday's. * **What trips**: clause (c). The 90-day P99 is anchored by baseline traffic much older than the attack; even the last 2 weeks of the ramp (days 76–90) register as above `1.5× baseline` P99. * **Miss if you only had clauses (a) and (b)**: yes. Monotonic slow ramps drift both the 24-hour MA and the 30-day P95 with them. ### Scenario 4: Sparse-traffic verifier, burst attack A verifier with 20 total active signers and near-zero new-keyid traffic suddenly sees 15 new keyids in a 5-minute window. * **What trips**: nothing. The ratio rules (a)/(b)/(c) compare against near-zero baselines (`3× 0.01 = 0.03`) and would trip on any positive admission including legitimate single-seller onboarding — so they produce too much noise to alarm on at sparse-traffic verifiers. Clause (d)'s `max(20, 10%×20) = max(20, 2) = 20` fixed floor requires more than 20 new keyids per 5-min window before firing. 15 is under the floor. * **What the operator sees**: nothing. 15 new keyids at a sparse-traffic verifier is within normal bounds; operators running sparse-traffic verifiers SHOULD raise the fixed floor if routine onboarding regularly exceeds it, OR leave the floor at 20 if routine onboarding stays under (the attacker's ceiling becomes ≤20/window, which sharply limits aggregate pressure over reasonable windows). ### Scenario 5: Large-verifier ceiling scaling A verifier with 10,000 active signers sees 500 new keyids in a 5-minute window. * **What trips**: nothing from clause (d). 10% × 10,000 = 1,000; 500 does not exceed the proportional floor. Depending on the verifier's baseline, clauses (a) or (b) might trip if 500/5-min is materially above the 24-hour moving average or the 30-day P95. * **What changes with scale**: at a small verifier (100 signers), 500 new keyids is 5× the entire signer base — obviously attack. Clause (d)'s `max(20, 10%×100) = 20` floor means 500 is 25× over, firing immediately. The proportional shape auto-scales. ### Scenario 6: Onboarding-burst false positive A verifier onboarding 200 new sellers in a planned Tuesday batch trips clause (a) or (d) during the batch. * **What the operator does**: raises the fixed floor in clause (d) temporarily (documented in change-control), OR silences the alert for the known onboarding window. After the batch, floor returns to baseline. Document the raise so it can be audited and floored-back. Raised-floor windows SHOULD be kept as short and internally-scoped as possible — publicly-announced onboarding windows are an attacker planning signal (see Scenario 10). * **Why automatic revocation is wrong here**: the spec's `Alarms SHOULD route to incident response, not automatic revocation` rule exists specifically for this case. Machine-derivable "attack vs onboarding" is unreliable; operator context is the distinguishing signal. ### Scenario 7: Legitimate key-rotation storm A peer seller's root CA is revoked and all 500 of their signing agents rotate to fresh `keyid`s within a 10-minute window. Your verifier sees 500 new keyids in one 5-min window and 0 in the next. * **What trips**: clauses (a) and likely (d). Shape is indistinguishable from Scenario 1 (sudden mass-compromise) at the rate-only level. * **What the operator does**: triage the alarm, recognize the event shape from the peer seller's notification (CA-compromise incidents are typically pre-announced to peers), mark as legitimate in the incident record, do NOT auto-revoke. If the peer did NOT pre-announce, treat exactly as Scenario 1 until peer contact confirms. **Do not silence the alarm preemptively based on peer announcements alone** — a compromised peer pre-announcement channel is itself an attacker tactic; the alarm firing and being triaged is the detection-in-depth layer. ### Scenario 8: Thin-history window attack (days 1–90 post-deployment) A verifier deployed yesterday has no 30-day P95 data and no 90-day P99 data. Clauses (b) and (c) degrade gracefully to the clause (d) floor until the percentile windows mature. An attacker who knows the verifier is new stages a ramp that stays under clause (d)'s `max(20, 10%×count)` floor for the first 90 days, during which only clause (a) provides meaningful coverage. * **What trips**: clause (a) only — and only on sufficiently large short-window spikes. Clauses (b), (c), (d) all degrade to the floor-dominated case. * **What the operator does**: for new verifiers, SHOULD tighten clause (d)'s absolute floor below the published starting value (e.g., 10 instead of 20) for the first 90 days while P95/P99 mature. Treat this as a documented first-deployment posture, not permanent tuning — relax back to the mature-verifier floor once the percentile windows have real data. * **Why clauses (b)/(c)/(d) are not independent during warmup**: clause (c) explicitly degrades to `1.5× max(observed_P99, clause_d_floor)`, so during days 1–90 clauses (c) and (d) are redundant. This is a known limitation of the rule shape; the tightened-floor posture is the mitigation. ### Scenario 9: Intermittent low-volume attack (rule-shape limitation) An attacker compromises 500 keys and emits 1 new keyid every 30 minutes across the fleet — roughly 48/day. Against a clause (d) floor of `max(20, 10% × 200-signer-count) = 20`/5-min, each 5-min window sees 0 or at most 1–2 new keyids. Over 30 days the attack admits 1,440 new keyids — which BECOMES part of the 30-day unique-keyid count clause (b) compares against. The attack is pre-baked into the baseline. * **What trips**: nothing. * **What the operator sees**: elevated unique-keyid count over 30 days, but no single-window alarm fires. * **Why this is a known limitation**: the admission-pressure rule closes volume-spike attacks, not low-rate long-duration attacks smoothed across long windows. **The per-keyid cap (step 9a) and the aggregate cache cap do NOT close this gap** — they bound cache size, not key-population growth, and 1,440 new keyids/month is \~0.014% of a 10M aggregate cap. At the rate-window level, every clause (a/b/c/d) trips at zero and the aggregate-cap alarm never fires. Operators with slow-drip key-population growth in their threat model **MUST layer application-level detection** (signer-reputation scoring, per-seller traffic-anomaly detection over business-meaningful windows like "signals delivered per billing period", new-keyid admission tracked against a declared-fleet-size expectation). Relying only on the admission-pressure rule plus the caps ships a verifier that has the attack class acknowledged in its spec but no actual detection for it. ### Scenario 10: Onboarding-window-timed attack An attacker monitors the verifier operator's public announcements (product launches, fiscal-year boundaries, platform partnerships). The operator raises clause (d)'s floor to `200` for a scheduled Tuesday onboarding window per Scenario 6. The attacker times their mass-compromise to that Tuesday, riding the temporarily-raised floor. * **What trips**: nothing during the raised-floor window. * **What the operator does**: during raised-floor windows, alarms on clauses (a)/(b)/(c) SHOULD escalate to **mandatory human review, not auto-suppress**, even though clause (d) is intentionally loose. Keep raised-floor windows as short as possible and internally-scoped — avoid publicly announcing that "new-seller onboarding will happen on date X" in a form that attackers can schedule against. Where public announcements are unavoidable (regulatory disclosures, customer-facing launches), SHOULD increase out-of-band detection during the window (traffic-pattern analysis, seller-claim cross-validation, request-body sampling). ### Scenario 11: Baseline reset at a mature verifier (failover, cache rebuild, config change) A mature verifier with 90 days of stable P95/P99 data fails over to a standby pool whose baseline-computation cache is empty. Clauses (b)/(c) degrade to the clause (d) floor-dominated case for the duration of the rebuild — mirroring Scenario 8 (thin-history window) but at a verifier that was supposed to be mature. An attacker who knows failover events happen (public status-page incidents, scheduled maintenance windows, observable response-time changes) can time an attack to land during the rebuild window. * **What trips**: clause (a) only (same as Scenario 8). Clauses (b)/(c) have no baseline data. * **What the operator does**: treat as a *temporary* thin-history posture. Persist baseline-statistic state across failover (Redis / shared dedup service) rather than rebuilding from the empty cache — the same infrastructure choice the spec already requires for the replay cache under cross-endpoint scoping also fixes this. If persistence is not possible, tighten clause (d)'s absolute floor during the rebuild window and escalate (a)/(b)/(c) alarms to human review per Scenario 10. * **Why this is spec-distinct from Scenario 8**: Scenario 8 is a first-deployment posture expected to stabilize in 90 days. Scenario 11 is a mature-verifier operational-event posture that can recur indefinitely if operators don't persist baselines across failover. Spec cannot mandate the persistence choice (deployment-internal); the tuning guide can call it out as a known attack-timing opportunity that operators are responsible for mitigating. ## Tuning adjustments to consider | Observation | Adjustment | | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Too many false positives from clause (a) during legitimate bursts | Raise the clause (a) ratio from `3×` to `4×` or `5×`. Do NOT lower the threshold on clauses (b)/(c)/(d) to compensate — they catch different attacker shapes. | | Clause (d) fires on routine onboarding | Raise the fixed floor component of clause (d) to match the largest legitimate batch size. Keep the `10%×30d-unique-count` proportional part unchanged. | | Clause (c) never fires during red-team exercises that run for \< 60 days | Expected — clause (c) is the multi-month anchor. Red-team exercises SHOULD include a 60-day slow-ramp scenario to validate clause (c) is correctly wired to the 90-day P99. | | Alarm shows clauses (a) and (d) both fired for the same event | Report the first clause that tripped in the alarm payload (per spec). Both clauses surfacing is informational, not a bug. | | Verifier is too small to have meaningful P99 data | Clause (c) degrades gracefully to `1.5× max(observed_P99, clause_d_floor)` — never lower than the proportional ceiling. Track for 90 days, then the P99 becomes meaningful. | ## What NOT to do * **Do NOT publish your tuned threshold values externally.** Thresholds are deployment-internal operational parameters. This rule distinguishes three audiences: * **Public disclosure** (blog posts, marketing copy, public config repositories, open-source defaults, conference talks): **prohibited**. This is the attacker oracle this guide exists to close. * **Attested disclosure under NDA** to qualified security auditors, regulators, or contracted red teams: **permitted**. Detection-posture assessment is itself a defense-in-depth practice and SOC 2 / ISO 27001 audits may require it. The NDA scope SHOULD limit redistribution and mandate deletion at engagement close. * **Internal operator runbooks, incident-response runbooks, version-controlled operator config**: **required**. The detecting team needs the values to triage effectively, and post-incident forensics require knowing what the thresholds were at the time of the event. * **Do NOT tune all four thresholds to the same value.** Each clause catches a different attacker pattern. Collapsing them loses detection coverage. * **Do NOT auto-revoke on alarm.** The alarm is a signal for incident response, not a remediation action. Automatic revocation of signer keys on admission-pressure alarm creates a denial-of-service vector: any party driving legitimate new-signer onboarding can trip the alarm and cause mass revocation. * **Do NOT hardcode the starting values in your deployment config.** Make each threshold a tunable parameter (e.g., environment variable, config file) so operators can adjust without code changes. Hardcoded starting values become de facto operator-visible defaults, which re-introduces the attacker oracle. ## Related * [Webhook Security → Webhook replay dedup sizing](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-replay-dedup-sizing) — normative spec for the rule this guide tunes. Scroll to the §Webhook replay dedup sizing heading directly beneath the 15-check verifier flow; the "New-keyid admission pressure" bullet is the rule whose four categories the tuning guide populates with starting values. * [Webhook verifier checklist](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-callbacks) — the full 15-check flow. Step 14b (logging discipline) is a sub-step under step 14 (body well-formedness); its sanitization rules (non-printable classification, 32-byte UTF-8 codepoint-safe truncation, count cap at 4) apply to the diagnostic information this guide assumes alarms carry. # Account state Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L2/account-state AdCP account state model: how accounts hold catalogs, creatives, audiences, event sources, and campaigns. Sync tasks, upsert semantics, and async approval workflows. # Account state AdCP accounts are stateful containers. Before a buyer can run a campaign on a seller's platform, they build up state on the account: product catalogs, creative assets, audience lists, conversion tracking. Each piece of state has its own sync task, its own approval workflow, and its own lifecycle. This is different from earlier versions of AdCP where accounts were billing references and most operations were stateless. In AdCP 3.0, the account is the central object that ties everything together. ## State domains An account holds six categories of state, each managed by a dedicated task: | Domain | Sync task | What it manages | Lifecycle | | ----------------- | -------------------- | ----------------------------------------------------------- | ------------------------------------------------------------ | | **Identity** | `sync_accounts` | Who the buyer is, which brand, billing terms | Setup once, update rarely | | **Catalogs** | `sync_catalogs` | Product feeds, inventory, stores, promotions, offerings | Continuous — feeds update hourly/daily | | **Creatives** | `sync_creatives` | Creative assets with format-specific manifests | Per-campaign, updated as needed | | **Audiences** | `sync_audiences` | First-party CRM audience lists | Incremental — add/remove members over time | | **Event sources** | `sync_event_sources` | Conversion tracking configuration (pixels, S2S, app events) | Setup once per source, rarely changes | | **Governance** | `sync_governance` | Governance agent configuration for this account | Setup once per account, update when governance agents change | | **Campaigns** | `create_media_buy` | Active campaigns with packages and targeting | Created when ready, updated throughout flight | Each sync task follows the same pattern: * **Upsert semantics** — items are matched by ID, created if new, updated if existing * **Discovery mode** — omit the items array to see what's already on the account * **Async approval** — platforms may review items before activating them * **Per-item status** — individual items can succeed or fail independently ## Setup sequence A typical buying workflow builds account state in dependency order. Each step requires the previous steps to be complete: ```mermaid theme={null} flowchart LR A[sync_accounts] --> B[sync_catalogs] A --> C[sync_event_sources] B --> D[sync_creatives] C --> D A --> E[sync_audiences] A --> G[sync_governance] D --> F[create_media_buy] E --> F G --> F ``` ### 1. Establish the account `sync_accounts` declares who the buyer is and how they pay. The seller acknowledges the relationship and returns status and billing terms. ```json theme={null} { "accounts": [{ "brand": { "domain": "acme-corp.com" }, "operator": "pinnacle-media.com", "billing": "operator" }] } ``` ### 2. Sync catalogs `sync_catalogs` makes product data available on the account. Formats declare what catalog types they need via `catalog` asset types in their `assets` array, so the buyer syncs the right feeds before submitting creatives. ```json theme={null} { "account": { "account_id": "acct_001" }, "catalogs": [ { "catalog_id": "product-feed", "type": "product", "url": "https://feeds.acme.com/products.xml", "feed_format": "google_merchant_center", "update_frequency": "daily" }, { "catalog_id": "store-locations", "type": "store", "url": "https://feeds.acme.com/stores.json", "feed_format": "custom", "update_frequency": "weekly" } ] } ``` The platform fetches and validates each feed. Items may be approved, rejected, or flagged — similar to Google Merchant Center reviewing product listings. ### 3. Configure event sources `sync_event_sources` sets up conversion tracking so the platform can attribute outcomes to ad exposure. ```json theme={null} { "account": { "account_id": "acct_001" }, "event_sources": [{ "event_source_id": "web-pixel", "name": "Website Conversions", "type": "pixel", "events": ["purchase", "add_to_cart", "lead"] }] } ``` ### 4. Configure governance [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance) registers governance agents on the account. Once configured, sellers with governance support will call [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) before confirming media buys. ```json theme={null} { "account": { "account_id": "acct_001" }, "governance_agents": [{ "agent_url": "https://governance.acme-corp.com/adcp", "domains": ["campaign", "creative", "content_standards"] }] } ``` Changing governance agents on a live account affects all active campaigns. If a governance agent is removed, sellers will stop calling `check_governance` for that domain. If a new agent is added, existing campaigns are not retroactively validated — only new transactions go through the updated governance configuration. ### 5. Sync creatives `sync_creatives` submits creative assets that reference the catalogs synced in step 2. For catalog-driven formats, the creative's `catalogs` field references synced catalogs by `catalog_id` instead of embedding items inline. ```json theme={null} { "account": { "account_id": "acct_001" }, "creatives": [{ "creative_id": "product-carousel", "format_id": { "agent_url": "https://creative.retailer.com/adcp", "id": "product_carousel_with_inventory" }, "catalogs": [{ "catalog_id": "product-feed", "type": "product", "tags": ["summer"] }], "assets": { "banner_image": { "url": "https://cdn.acmecorp.com/carousel-hero.jpg", "width": 1200, "height": 628 } } }] } ``` ### 6. Upload audiences `sync_audiences` uploads first-party audience lists for targeting. Members are hashed before sending. ```json theme={null} { "account": { "account_id": "acct_001" }, "audiences": [{ "audience_id": "high-value-customers", "name": "High Value Customers", "add": [ { "hashed_email": "a1b2c3..." }, { "hashed_email": "d4e5f6..." } ] }] } ``` ### 7. Create the campaign With all state in place, `create_media_buy` activates a campaign that references the synced state: ```json theme={null} { "account": { "account_id": "acct_001" }, "name": "Summer Product Launch", "packages": [{ "product_id": "sponsored-products", "creative_ids": ["product-carousel"], "targeting_overlay": { "audiences": { "include": ["high-value-customers"] } } }] } ``` ## Discovery Every sync task supports **discovery mode**: call the task without an items array to see what state already exists on the account. This is how a buying agent learns what a seller already knows about the brand. ```json theme={null} // What catalogs does this account have? { "account": { "account_id": "acct_001" } } // Response: catalogs already on the account { "catalogs": [ { "catalog_id": "product-feed", "action": "unchanged", "item_count": 1250 }, { "catalog_id": "store-locations", "action": "unchanged", "item_count": 45 } ] } ``` This matters because sellers may already have brand data from other sources — a retailer might have the brand's product catalog from their commerce platform, or a publisher might have creatives from a previous campaign. Discovery lets the buyer build on existing state rather than re-uploading everything. ## Approval workflows Sync tasks are often asynchronous. The platform may need to review items before they're active: * **Catalogs**: Product listings go through content policy checks. Items can be approved, rejected, or flagged with warnings. * **Creatives**: Generative creatives require human approval. Traditional creatives may need policy review. * **Audiences**: Platforms need time to match hashed identifiers against their user base. * **Event sources**: Conversion tracking may require pixel verification. All sync tasks support `push_notification_config` for webhook callbacks when processing completes. For long-running operations, the platform returns async status updates (working, input-required, submitted) that the buyer polls or receives via webhook. ## State dependencies Some state depends on other state. The platform enforces these dependencies: * **Creatives reference catalogs** — a creative that uses `catalog_id: "product-feed"` requires that catalog to be synced first * **Campaigns reference creatives and audiences** — `create_media_buy` requires the referenced `creative_ids` and audience IDs to exist on the account * **Event sources enable optimization** — optimization goals on packages reference event sources for attribution If a dependency is missing, the platform returns an error explaining what needs to be synced first. ## Stateless vs stateful operations Not everything requires account state. Some tasks are stateless queries: | Stateless (no account needed) | Stateful (account required) | | ------------------------------------------- | ------------------------------------ | | `get_products` — discover inventory | `create_media_buy` — buy inventory | | `list_creative_formats` — discover formats | `sync_creatives` — upload creatives | | `get_signals` — discover signals | `activate_signal` — activate signals | | `get_adcp_capabilities` — discover features | `sync_catalogs` — upload catalogs | The pattern: **discovery is stateless, execution is stateful**. You can browse a seller's inventory without an account. You need an account to buy. ## Related documentation * **[Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents)** — Account identity, billing models, and `sync_accounts` details * **[Async operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations)** — How async approval workflows work * **[Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks)** — Receiving notifications when async operations complete * **[Catalogs](/dist/docs/3.1.20/creative/catalogs)** — Typed data feeds that provide the items publishers render in ads # Accounts and agents Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents AdCP accounts and agents: the four entities in every transaction (brand, account, operator, agent), account-id namespaces, buyer-declared accounts, and billing configuration. AdCP distinguishes four entities in every billable operation: | Entity | Question | How identified | | ------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------- | | **Brand** | Whose products are advertised? | Brand reference: `domain` + optional `brand_id` ([brand.json](/dist/docs/3.1.20/brand-protocol/brand-json)) | | **Account** | Who gets billed? What rates apply? | [Account reference](#account-references) | | **Operator** | Who operates on the brand's behalf? | Domain (e.g., `pinnacle-media.com`) | | **Agent** | What software is placing the buy? | Authenticated session | **Brand** — The advertiser whose products or services are promoted. Identified by a `brand` reference (`domain` + optional `brand_id`), resolved via `/.well-known/brand.json`. Single-brand houses use the domain alone (no `brand_id`). **Account** — A billing relationship between a buyer and seller. Determines rate card, payment terms, credit limit, and who receives invoices. Every billable operation requires an account reference — a seller-assigned `account_id` when the seller or upstream platform owns the canonical account namespace, or a natural key (`brand`, `operator`) when that tuple is the durable protocol key for buyer-declared accounts. Sandbox accounts follow the same model — account-id namespaces use pre-existing sandbox IDs from `list_accounts` or out-of-band setup, while buyer-declared sandboxes use the natural key with `sandbox: true`. **Operator** — The entity driving buys — an agency trading desk, the brand's internal team, or another entity acting on behalf of the advertiser. Identified by domain and verifiable via [authorized operators](#authorized-operators) in `brand.json`. **Agent** — The software placing buys and managing campaigns. Authenticates with the seller and may operate on behalf of multiple operators and brands. See [Accounts Protocol overview](/dist/docs/3.1.20/accounts/overview) for the full commercial model and [sync\_accounts](/dist/docs/3.1.20/accounts/tasks/sync_accounts) for the task reference. ## What sellers declare Sellers configure the `account` section of [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities#account): **1. Which billing models do you support?** (`supported_billing`) The buyer must pass one of these values as `billing` in every `sync_accounts` entry. The seller either accepts or rejects. | Billing | Who is invoiced | Use case | | ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------- | | `operator` | Operator (agency or brand buying direct) | Operator buying on their own terms | | `agent` | Agent | Agent consolidates billing across brands | | `advertiser` | Advertiser directly | Operator places orders but advertiser pays (common on social platforms and in DACH B2B workflows) | **2. Do you require operator-level auth?** (`require_operator_auth`) This field determines the authentication model and the account reference shape: When `false` (default) — **buyer-declared accounts**: the seller trusts the agent. The agent authenticates once and declares accounts via `sync_accounts`. On subsequent requests, the buyer passes the natural key (`brand` + `operator`) and the seller resolves internally. When `true` — **account-id namespaces**: each operator must authenticate with the seller directly. The agent obtains a credential per operator — via OAuth using the seller's `authorization_endpoint`, or via API key out-of-band. Subsequent requests pass a seller-assigned `account_id`. If a credential may access more than one account, the seller MUST expose `list_accounts` and the buyer MUST resolve an explicit account before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose `list_accounts` returning that singleton; a seller MAY omit `list_accounts` only when it supplies the same explicit account ID through another declared path or out-of-band onboarding. For sandbox, the path follows the account namespace: account-id namespaces use pre-existing test accounts from `list_accounts` or out-of-band setup; buyer-declared accounts use `sync_accounts` with `sandbox: true` and reference by natural key. Sellers can also declare `account_financials: true` to expose account-level financial data (spend, credit, invoices) via [`get_account_financials`](/dist/docs/3.1.20/accounts/tasks/get_account_financials). This only applies to operator-billed accounts. **Example capabilities:** ```json theme={null} { "account": { "require_operator_auth": false, "supported_billing": ["operator", "agent"] } } ``` Sellers that support `advertiser` billing declare it explicitly: ```json theme={null} { "account": { "require_operator_auth": false, "supported_billing": ["operator", "agent", "advertiser"] } } ``` These fields combine into common patterns. ## Seller patterns Which kind of platform are you buying from? That determines the account setup pattern. | Platform type | Account pattern | `require_operator_auth` | `supported_billing` | | ------------------------------------------ | ------------------------------------- | ----------------------- | ----------------------------------------- | | [Social / walled garden](#social-platform) | Upstream-managed account-id namespace | `true` | `["operator"]` | | [Direct publisher](#direct-publisher) | Buyer-declared accounts | `false` | `["operator"]` or `["operator", "agent"]` | | [DSP / programmatic](#dsp--programmatic) | Buyer-declared accounts | `false` | `["agent"]` | ### Social platform The operator already has an account on the platform — an ad account, a business manager, a self-serve dashboard. The upstream platform owns the canonical account namespace accessible to that credential. The agent obtains the operator's credentials (via OAuth or API key), opens a per-operator session, resolves an explicit account through `list_accounts`, and uses the returned `account_id` values. The platform bills the operator directly. **Capabilities:** ```json theme={null} { "account": { "require_operator_auth": true, "supported_billing": ["operator"], "authorization_endpoint": "https://seller.example.com/oauth/authorize" } } ``` **Buyer workflow:** 1. Call `get_adcp_capabilities` — see `require_operator_auth: true` and `authorization_endpoint` 2. For each operator: a. Obtain operator's credential (OAuth via `authorization_endpoint`, or API key out-of-band) b. Open a new session with the operator's credential c. Call `list_accounts` to discover accounts visible to that credential 3. Human or policy selects the correct account from the list 4. Call `get_products` / `create_media_buy` with the operator's session and `{ "account_id": "..." }` **list\_accounts response:** ```json theme={null} { "accounts": [{ "account_id": "acc_spark_social_001", "name": "Spark paid social", "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "operator": "pinnacle-media.com", "status": "active", "billing": "operator", "account_scope": "operator_brand" }] } ``` Subsequent calls use the discovered ID: ```json theme={null} { "account": { "account_id": "acc_spark_social_001" } } ``` **Key point:** The operator's credential — not the agent's — authorizes all calls in that session. The buyer does not declare or create accounts through AdCP in the 3.0.x account-id model; `list_accounts` mirrors the upstream namespace. If the seller exposes `sync_accounts`, it is only for settings updates against an existing `account_id`, not natural-key provisioning, unless a future explicit capability declares account-id provisioning. ### Direct publisher The publisher trusts the agent but bills the operator directly. The agent sets up accounts via `sync_accounts` — no per-operator login needed. Accounts may require human approval (credit checks, legal agreements) before becoming active. Many publishers also accept agent billing (`supported_billing: ["operator", "agent"]`). The buyer chooses per account — operators with a direct relationship use `billing: "operator"`, everything else uses `billing: "agent"`. If the seller doesn't support the requested billing for a particular account, it rejects the request and the agent re-submits with a different model. **Capabilities:** ```json theme={null} { "account": { "supported_billing": ["operator", "agent"] } } ``` **Buyer workflow:** 1. Call `get_adcp_capabilities` — see `require_operator_auth` absent (defaults to `false`) 2. Call `sync_accounts` for each brand/operator pair 3. Wait for account status `active` — may require human to complete credit/legal at `setup.url` 4. Call `get_products` with `account` reference 5. Call `create_media_buy` with `account` reference **sync\_accounts request — brand buying direct:** ```json theme={null} { "accounts": [{ "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "billing": "operator" }] } ``` Seller acknowledges the request but requires setup before provisioning: ```json theme={null} { "accounts": [{ "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "action": "created", "status": "pending_approval", "billing": "operator", "account_scope": "brand", "setup": { "url": "https://seller.example.com/advertiser-onboard", "message": "Complete advertiser registration and credit application" } }] } ``` The seller has acknowledged the relationship `(brand: "acme-corp.com", operator: "acme-corp.com", billing: "operator")`, but the account is pending review before it becomes active. A human at Acme Corp completes the setup at the URL. To check progress, the agent either: * Re-calls `sync_accounts` with the same natural key — the seller returns the updated status * Receives a webhook notification if `push_notification_config` was provided in the request **Key point:** `pending_approval` is the normal path. Every buyer needs a direct relationship with the seller. **Billing rejection — operator billing not available:** The seller supports operator billing in general, but may not support it for every operator. Here, the agent requests operator billing for an operator without a direct relationship: ```json theme={null} { "accounts": [{ "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "billing": "operator" }] } ``` Seller rejects the request because this operator has no direct billing relationship: ```json theme={null} { "accounts": [{ "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "action": "failed", "status": "rejected", "errors": [{ "code": "BILLING_NOT_SUPPORTED", "message": "Operator billing is not available for this account. Re-submit with billing: \"agent\"." }] }] } ``` The agent re-submits with `billing: "agent"` or informs the buyer that operator billing is not available with this seller. Billing is never silently remapped. ### DSP / programmatic All billing flows through the agent. The agent has a standing relationship with the platform and consolidates billing across all brands and operators. Accounts are created instantly — no human approval needed. **Capabilities:** ```json theme={null} { "account": { "supported_billing": ["agent"] } } ``` **Buyer workflow:** 1. Call `get_adcp_capabilities` — see `supported_billing: ["agent"]` 2. Call `sync_accounts` for each brand/operator pair with `billing: "agent"` 3. Accounts are active immediately — no human approval needed 4. Call `get_products` / `create_media_buy` with `account` reference **sync\_accounts request:** ```json theme={null} { "accounts": [{ "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "operator": "pinnacle-media.com", "billing": "agent" }] } ``` Account active immediately: ```json theme={null} { "accounts": [{ "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "operator": "pinnacle-media.com", "action": "created", "status": "active", "billing": "agent", "account_scope": "operator_brand" }] } ``` **Key point:** The agent receives a single consolidated invoice. Per-brand accounts give reporting granularity but billing is centralized. ## Authorized operators Brands declare who can represent them in `/.well-known/brand.json` via the `authorized_operators` field. Sellers SHOULD verify operators against this when processing `sync_accounts`. ```json theme={null} { "house": { "domain": "nova-brands.com", "name": "Nova Brands" }, "brands": [ { "id": "spark", "names": [{"en": "Spark"}] }, { "id": "glow", "names": [{"en": "Glow"}] } ], "authorized_operators": [ { "domain": "pinnacle-media.com", "brands": ["spark", "glow"], "countries": ["US", "GB", "DE"] }, { "domain": "summit-agency.jp", "brands": ["spark"], "countries": ["JP"] }, { "domain": "nova-brands.com", "brands": ["*"] } ] } ``` | Field | Required | Description | | ----------- | -------- | ---------------------------------------------------------------- | | `domain` | Yes | Operator's domain | | `brands` | Yes | Brand IDs this operator can represent. `["*"]` means all brands. | | `countries` | No | ISO 3166-1 alpha-2 country codes. Omit for global authorization. | ### Verification flow 1. Resolve `{brand.domain}/.well-known/brand.json` 2. Check `authorized_operators` for matching `domain` with the brand in `brands` 3. If found → proceed (account may still need credit/legal approval) 4. If not found → reject the account (`action: "failed"`) or return `pending_approval` for manual review Verification is a trust signal, not a gate. Finding the operator in `brand.json` lets the seller fast-track provisioning. If the operator isn't listed, the seller can still approve through its own review process. **Self-authorization is implicit.** When the `operator` domain matches the brand's domain, the brand is operating directly — no listing in `authorized_operators` is needed. `authorized_operators` models the interface between the brand and whoever operates on its behalf. It does not model internal agency hierarchies. ## Buyer-agent identity `authorized_operators` tells the seller whether an operator is allowed to represent a brand. It doesn't tell the seller who the *agent* placing the call is, or what commercial relationship is on file with that agent. Those are different questions, and the seller checks both before provisioning. Two layers run on every `sync_accounts` request: | Layer | Question | Where it lives | | -------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Agent identity** | Which buyer agent issued this request? | The seller's onboarding record, looked up by signed-request `agent_url` (when [request signing](/dist/docs/3.1.20/building/by-layer/L1/security#signed-requests-transport-layer) is in use) or by the bearer / API-key / OAuth credential the agent presented. Signature or credential establishes identity; authorization is a separate check. | | **Brand-operator authorization** | Is the operator named in the request authorized to act for the brand? | `brand.json` `authorized_operators` (above). Verified whether the request is signed or not. | Both layers MUST pass. A signed request from an onboarded agent for an unauthorized operator gets rejected on the brand-operator check; a request from an unrecognized agent for an authorized operator gets rejected on the identity check. Sellers that advertise [`request_signing.required_for`](/dist/docs/3.1.20/building/by-layer/L1/security#transport-scope) on `sync_accounts` reject unsigned traffic at the identity layer; sellers that don't advertise it MAY still require an established credential mapping before agent-billable values are accepted. The brand-operator check runs against the seller's cached `brand.json` per [Operator revocation and caching](#operator-revocation-and-caching) — revocation is eventual. Sellers performing high-value or first-time-on-this-brand provisioning SHOULD bypass the cache to close the TOCTOU window. **SDK naming for the brand-operator authorization Protocol.** SDKs that surface a typed Protocol for the brand-operator check (for adopters to plug their own resolver into) SHOULD name it after the file consulted: `BrandAuthorizationResolver` (or equivalent in idiomatic casing). The file is `brand.json/authorized_operators` — the brand-side declaration of who may represent the brand. SDKs SHOULD NOT name this Protocol after `adagents.json`, which is publisher-side / data-provider-side and models a different relationship (which sales agents may sell that publisher's inventory). Naming the buyer-side resolver `AdagentsResolver` confuses the two surfaces and locks adopters into the wrong mental model. This is a spec-side recommendation; SDK conventions track upstream. **The agent's commercial state is offline.** Whether a buyer agent is *passthrough-only* (no payments relationship — only the operator can be invoiced) or *agent-billable* (the agent can be invoiced directly) is recorded in the seller's onboarding system, the same way operator account creation is. Provisioning that record — contract, KYC, payment terms, billing entity capture — is out of scope for AdCP. What's in scope is two on-wire consequences: 1. **Runtime billing gate.** A passthrough-only agent that submits `billing: "agent"` or `billing: "advertiser"` is rejected with `BILLING_NOT_PERMITTED_FOR_AGENT` and an `error.details.suggested_billing` of `operator`. See [Billing and Account Setup](/dist/docs/3.1.20/building/by-layer/L3/error-handling#billing-and-account-setup) for the recovery contract. 2. **Per-agent defaults.** Sellers MAY pre-fill `payment_terms`, `billing_entity`, rate-card linkage, and credit limit from the buyer agent's onboarding record when provisioning new accounts under that agent. Per-account values on the `sync_accounts` request always take precedence over per-agent defaults — the buyer can override per row. The per-agent layer is a recommended implementation pattern (it mirrors how SSPs maintain `buyer_id` / `seat_id` rows for OpenRTB DSPs); smaller publishers MAY collapse to seller-wide defaults until they have receivables ops that distinguish per-agent terms. ## Account references Every account-scoped operation accepts an `account` object instead of a flat `account_id` string. The seller's `require_operator_auth` capability determines the auth model and reference shape; tool exposure distinguishes upstream-managed `account_id` namespaces from seller-defined IDs supplied out-of-band. Sellers that support caller-scope introspection attach an optional `authorization` object to each per-account entry in [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) responses — listing the tasks and request fields this caller is allowed to use on the account, plus any standard named scope (e.g., `attestation_verifier`). See [Caller authorization](/dist/docs/3.1.20/accounts/overview#caller-authorization) for the full shape and semantics. ### Account-id namespaces (`require_operator_auth: true`) Accounts are managed outside of AdCP. The advertiser creates an account on the seller's platform, grants the operator permission to manage it, and the buyer passes a seller-assigned `account_id`. The agent is not involved in account creation or billing setup — those are handled between the advertiser, operator, and seller directly. **Typical sellers:** Social platforms, self-serve ad platforms — anywhere the advertiser already has an account. **Upstream-managed workflow:** 1. Advertiser creates an account on the seller's platform (out-of-band) 2. Advertiser grants the operator permission to manage the account (out-of-band) 3. Agent calls `list_accounts` to discover available accounts 4. Human selects the correct account from the list 5. Agent passes `{ "account_id": "acc_acme_001" }` on every request (`get_products`, `create_media_buy`, etc.) `list_accounts` is mandatory when the authenticated credential may access more than one account because the upstream owns the namespace. When the credential is bound to exactly one account, sellers SHOULD still expose `list_accounts` returning that singleton so SDKs can auto-select it and send explicit `{ "account_id": "..." }` on required-account calls. `sync_accounts` provisioning is out of scope for account-id namespaces in 3.0.x unless a future explicit capability declares it; if `sync_accounts` is exposed today, it is settings-update mode for an existing `account_id`. **Seller-defined workflow:** Some sellers use `account_id` without exposing an account discovery surface. In that pattern, the seller gives the buyer an account ID during onboarding or configuration, and the buyer passes that ID on account-scoped calls. Absence of `list_accounts` means there is no protocol namespace to discover; it does not mean the buyer should try to provision by natural key. ### Buyer-declared accounts (`require_operator_auth: false`) The agent manages the buying relationship. It calls `sync_accounts` to tell the seller who's advertising, who's operating on the brand's behalf, and who's paying. The seller provisions accounts and responds with status — the account IDs are a byproduct of the declaration, not something the buyer needs to know upfront. Buyer-declared-account sellers SHOULD also expose `list_accounts` as the recovery read for stateless buyers. Replaying `sync_accounts` is not a cold-start recovery mechanism when the buyer has lost the `(brand, operator, sandbox)` natural keys required to compose that replay. `list_accounts` returns the relationships the seller already acknowledged; it does not change the natural-key contract or make the seller-issued `account_id` mandatory on later calls. **Typical sellers:** Traditional publishers, retail media networks, DSPs — anywhere the buying relationship is established programmatically. `sync_accounts` is the declaration tool. Each entry is a set of flags that tells the seller what the buyer needs: | Flag | What it tells the seller | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `brand` (`domain` + optional `brand_id`) | Which brand is advertising | | `operator` | Who operates on the brand's behalf (agency, trading desk, or the brand itself) | | `billing` | Who gets the invoice — `operator`, `agent`, or `advertiser` | | `billing_entity` | Structured business entity details for the party responsible for payment — legal name, VAT ID, tax ID, address, contacts, and bank details. Used for formal B2B invoicing. Bank details are write-only (never echoed in responses). | | `payment_terms` | Payment terms for this account (`net_15`, `net_30`, `net_45`, `net_60`, `net_90`, `prepay`). The seller must accept these terms or reject the account — terms are never silently remapped. | | `sandbox` | Whether this is a sandbox (test) account — no real spend. Only used with buyer-declared accounts; account-id namespace sandboxes are pre-existing and discovered through `list_accounts` or supplied out-of-band. | Every combination of flags that might require the seller to do something different — bill a different entity, set up a different rate card, create a sandbox — is a distinct declaration. ### Billing entity and invoice recipient For markets that require structured invoicing data (e.g., EU B2B transactions requiring VAT IDs), the `billing_entity` on an account provides the default business entity details for whoever `billing` points to. This includes legal name, tax identifiers, postal address, billing contact, and bank details. On individual media buys, an `invoice_recipient` can override the account default — useful when a specific campaign should be billed to a different party. When `invoice_recipient` differs from the account default and the account has `governance_agents`, the seller MUST include it in the `check_governance` request so the governance agent can approve or reject the billing redirect. **Workflow:** 1. Agent calls `sync_accounts` with one or more declarations 2. Seller provisions or links accounts for each, responds with status: * `active` — ready to use * `pending_approval` — seller reviewing (human may need to visit `setup.url`) * `rejected` — seller declined the request 3. For subsequent requests, pass the account reference: * **Buyer-declared accounts** (`require_operator_auth: false`): pass the natural key `{ "brand": { "domain": "acme-corp.com" }, "operator": "pinnacle-media.com" }` * **Account-id namespaces** (`require_operator_auth: true`): pass `{ "account_id": "acc_acme_001" }` (discover via `list_accounts` for upstream-managed namespaces, or receive out-of-band for seller-defined namespaces) * **Sandbox (buyer-declared)**: pass the natural key with `sandbox: true` (declared via `sync_accounts`) * **Sandbox (account-id namespace)**: pass `{ "account_id": "test_acc_001" }` (pre-existing test account, discovered via `list_accounts` or supplied out-of-band) 4. When anything changes (billing model, new brand, new operator), call `sync_accounts` again 5. After a cold start, call `list_accounts` to recover previously acknowledged relationships when the seller exposes the recommended read surface The agent may be directly responsible for billing when `billing` is `"agent"`. When `billing` is `"operator"` or `"advertiser"`, the agent facilitates but is not the invoiced party. The seller may require human approval before activating accounts. ### Natural key semantics The tuple `(brand, operator, sandbox)` uniquely identifies an account relationship. The `brand` is a nested object with `domain` and optional `brand_id`. `operator` is always required — when the brand operates directly, set `operator` to the brand's domain. `sandbox` defaults to `false` when omitted. For example, `{brand: {domain: "acme-corp.com"}, operator: "acme-corp.com"}` (brand buying direct) is a different account from `{brand: {domain: "acme-corp.com"}, operator: "pinnacle-media.com"}` (brand via agency). Adding `sandbox: true` references the sandbox account for the same pair. See [sync\_accounts task reference](/dist/docs/3.1.20/accounts/tasks/sync_accounts) for the full request/response schema. ### Account status | Status | Meaning | Next step | | ------------------ | --------------------------- | ---------------------------------------------------------------------- | | `active` | Ready to use | Place buys on this account | | `pending_approval` | Seller reviewing | Human may need to visit `setup.url`. Poll `list_accounts` for updates. | | `rejected` | Seller declined the request | Review rejection reason, adjust and re-sync, or contact seller | | `payment_required` | Credit limit reached | Add funds or route spend to other accounts | | `suspended` | Was active, now paused | Contact seller | | `closed` | Was active, now terminated | — | ### Account scope The agent requests accounts by natural key — `(brand, operator)`. The seller decides what granularity to assign. The `account_scope` field in the response tells the agent how the seller resolved the request: | Scope | Meaning | Example | | ---------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `operator` | One account for all brands under this operator | Agent sends (Pinnacle Media, Acme) and (Pinnacle Media, Nova) — seller maps both to the Pinnacle Media account | | `brand` | One account for this brand regardless of operator | Agent sends (Acme, Pinnacle Media) and (Acme, Summit Agency) — seller maps both to the Acme account | | `operator_brand` | Dedicated account for this operator+brand pair | Agent sends (Pinnacle Media, Acme) — seller creates a specific Acme-via-Pinnacle account | | `agent` | Agent-scoped account with no brand- or operator-specific split | Agent sends any brand — seller maps the request to the standing agent-scoped account | The agent does not choose the scope — the seller assigns it based on its own account policy. An agent requesting `(brand: {domain: "acme-corp.com"}, operator: "pinnacle-media.com")` might receive an operator-scoped account, a brand-scoped account, or a dedicated operator\_brand account depending on the seller. When multiple natural keys resolve to the same scope, the `account_scope` explains why. For buyer-declared accounts (`require_operator_auth: false`), use natural keys (`brand` + `operator`) on subsequent requests — adding `sandbox: true` for sandbox accounts. A seller may also return an `account_id` from `sync_accounts` as its internal handle, but the seller MUST continue accepting the natural-key `AccountRef` for every account provisioned this way. For account-id namespaces (`require_operator_auth: true`), discover account IDs via `list_accounts` for upstream-managed namespaces, or receive them out-of-band for seller-defined namespaces — including sandbox test accounts. ## Error codes | Code | When returned | Resolution | | ------------------------ | ------------------------------------------------ | ----------------------------------------------- | | `ACCOUNT_REQUIRED` | Multiple accounts; seller can't determine which | Pass `account_id` in the account reference | | `ACCOUNT_NOT_FOUND` | `account_id` doesn't exist or agent lacks access | Check account reference, re-run `sync_accounts` | | `ACCOUNT_SETUP_REQUIRED` | Natural key resolved but account needs setup | Check `details.setup` for URL/message | | `ACCOUNT_AMBIGUOUS` | Natural key resolves to multiple accounts | Pass `account_id` or more specific natural key | | `PAYMENT_REQUIRED` | Credit limit reached or funds depleted | Add funds, route to another account | | `ACCOUNT_SUSPENDED` | Account not in good standing | Contact seller | | `BRAND_REQUIRED` | Billable operation without brand reference | Include `brand` in request | When the seller returns `ACCOUNT_REQUIRED`, it includes the available accounts: ```json theme={null} { "errors": [{ "code": "ACCOUNT_REQUIRED", "message": "Multiple accounts available. Please specify account_id in the account reference.", "details": { "available_accounts": [ { "account_id": "acc_acme_001", "name": "Acme Corp" }, { "account_id": "acc_pinnacle", "name": "Pinnacle Media" } ] } }] } ``` ## Design notes ### sync\_accounts and seller record systems When an agent declares `(brand: {domain: "acme-corp.com"}, operator: "pinnacle-media.com")`, the seller looks up or creates records in its own system — CRM, OMS, ad server, or billing platform. `sync_accounts` is the buyer-side interface to the seller's record system. The seller may: * Map the natural key to an existing account and return `status: "active"` * Create a new record and return it immediately (`status: "active"`) * Create a placeholder pending human review (`status: "pending_approval"`) * Decline the request entirely (`status: "rejected"`) `list_accounts` returns all records the seller has mapped for this agent — including pending and rejected entries. The agent uses `list_accounts` to see the full state of its portfolio with this seller, not just active accounts. ### Accounts and insertion orders An account represents a standing relationship — who gets billed, what rates apply, what credit is available. It is not a campaign or an insertion order. Insertion orders and campaign flights are modeled as media buys via `create_media_buy`. The account determines *billing terms*; the media buy determines *what runs and when*. A single account can have many media buys over its lifetime. ### Operator revocation and caching If a brand removes an operator from `authorized_operators`, existing active accounts are not automatically deactivated. Revocation is eventual, not immediate — similar to how `ads.txt` changes propagate on the supply side. Sellers SHOULD respect standard HTTP caching headers on `brand.json` and re-validate periodically. A reasonable cache TTL is 24 hours. ### Brand identity for SMBs Domain-based identity via `/.well-known/brand.json` works for organizations of any size — it's a static JSON file that can be hosted on any web server. For organizations that cannot host files on their domain, the `authoritative_location` field in `brand.json` allows the house domain to redirect to a hosted location: ```json theme={null} { "house": { "domain": "local-bakery.com" }, "authoritative_location": "https://registry.agenticadvertising.org/brands/local-bakery.com" } ``` # Authentication Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L2/authentication AdCP authentication guide: public vs authenticated operations, static credential implementation, and credential management for buyer and seller agents. AdCP uses a tiered authentication model where some operations are publicly accessible while others require authentication. ## When Authentication is Required ### Public Operations (No Authentication Required) These operations work without credentials to enable discovery and evaluation: * **`get_adcp_capabilities`** - Discover agent capabilities, portfolio, and supported features * **`list_creative_formats`** - Browse available creative formats * **`get_products`** - Discover inventory (returns limited results without auth) **Rationale**: Publishers want potential buyers to discover their capabilities before establishing a business relationship. **Important**: Unauthenticated `get_products` may return: * Partial catalog (standard products only) * No pricing information or CPM details * No custom product offerings * Generic format support only ### Authenticated Operations (Credentials Required) These operations require valid credentials: * **`get_products`** (full access) - Complete catalog with pricing and custom products * **`create_media_buy`** - Create advertising campaigns * **`update_media_buy`** - Modify existing campaigns * **`sync_creatives`** - Upload creative assets * **`list_creatives`** - View your creative library * **`get_media_buy_delivery`** - Monitor campaign performance and metrics * **`provide_performance_feedback`** - Submit optimization signals **Rationale**: These operations involve financial commitments, access to proprietary data, or modifications to active campaigns. ## Authentication Method AdCP supports three authentication mechanisms for authenticated operations. The choice depends on the operation's risk class and the AdCP version in use: | Mechanism | 3.0 (current) | 3.1+ | Notes | | ------------------------------------------------------ | -------------------------------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **RFC 9421 request signing** | RECOMMENDED for all authenticated operations | **REQUIRED** for mutating / financial operations | Asymmetric, body-bound, replay-resistant. See [RFC 9421 request signing](/dist/docs/3.1.20/building/by-layer/L1/security#request-signing). | | **Mutual TLS (mTLS)** | Permitted for any operation | Permitted as an alternative to 9421 | Transport-layer identity; recommended when the deployment already terminates mTLS at the edge. | | **Static Authorization credentials (Bearer or Basic)** | Permitted; effective baseline for 3.0 | **PROHIBITED for mutating / financial operations**; permitted for read / discovery only | Shared-secret transport. Bearer is the canonical example; HTTP Basic is also acceptable when sent over TLS and validated on every protected request. Documented sunset for mutating ops — see the [known limitation](/dist/docs/3.1.20/reference/known-limitations). | **3.0 mutating-operation floor.** Until 3.1 lands, static Authorization-header credentials over TLS are the effective floor for mutating operations. Operators handling spend commitments SHOULD ship RFC 9421 request signing before the 3.1 deprecation date to avoid a forced cutover. ### Static Authorization credentials (3.0 baseline) ``` Authorization: Bearer Authorization: Basic ``` Bearer tokens may be: * **Opaque tokens**: Server-validated strings mapped to agents * **JWT tokens**: Self-contained tokens with embedded claims HTTP Basic credentials are also a static shared-secret mechanism. They MUST be sent in the `Authorization` header over TLS, and servers MUST validate the credential on every protected request. Basic credentials are not stronger than Bearer tokens; they are accepted as the same conformance class because both carry a pre-provisioned shared secret on the transport. Implementations MUST enforce TLS 1.2+ on all static-credential-authenticated endpoints. See the [implementation security reference](/dist/docs/3.1.20/building/by-layer/L1/security) for transport requirements. The credential MUST be carried in the `Authorization` request header. Sellers MUST NOT require non-canonical aliases (e.g. `x-adcp-auth`, which appeared in some early MCP-only deployments) and MUST NOT advertise them as the supported header in agent cards, capability responses, or documentation. A seller MAY accept such an alias as a transitional input while it phases out an existing adopter's integration, but `Authorization: Bearer` or `Authorization: Basic` MUST also be accepted on the same endpoint. Buyer agents and SDKs SHOULD emit `Authorization: Bearer` unless a seller explicitly provisions Basic credentials; SDK examples and docstrings MUST NOT show alias headers as the canonical form. ### RFC 9421 request signing (recommended; required for mutating ops in 3.1+) Signed requests bind `@method`, `@target-uri`, `@authority`, `content-type`, and `content-digest` under an `Ed25519`, `ecdsa-p256-sha256`, or `rsa-pss-sha512` signature with a ±60 s timestamp window and ≥128-bit nonce. The full verifier checklist, key-discovery rules (`brand.json` → `agents[]` → `jwks_uri`), and rotation semantics are defined in the [implementation security reference](/dist/docs/3.1.20/building/by-layer/L1/security#request-signing). Capability discovery via `get_adcp_capabilities.request_signing.supported` lets clients detect whether a seller enforces signing before sending a mutating call. ### mTLS Operators terminating mTLS at the edge MAY use the peer certificate as the primary identity mechanism for AdCP operations. When mTLS is used, operators MUST pin identity to the certificate subject / SAN rather than any header field. ### JWT Token Claims When using JWT tokens, include these standard claims: ```json theme={null} { "sub": "agent_123", "exp": 1706745600, "iat": 1706742000 } ``` Sales agents may require additional claims for authorization. ## Agents and Accounts AdCP distinguishes between the **agent** (who is making requests) and the **account** (who gets billed): * **Agent**: The authenticated entity making API calls (identified by the token) * **Account**: The billing relationship determining rates and invoicing An agent may have access to multiple accounts (e.g., an agency managing several clients). See [Accounts and Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) for details on account selection and billing attribution. For schema definitions, see [`account.json`](https://adcontextprotocol.org/schemas/3.1.20/core/account.json). ## Tenant resolution AdCP resolves tenant from the authenticated principal, not from request payloads. Seller agents map the authenticated identity (bearer token, Basic credential, mTLS client cert, or RFC 9421 key) to the originating buyer's account via their own authorization context. Task payloads never carry tenant identity as a substitute for authentication — when a schema requires a globally-unique resource ID (`plan_id`, `rights_id`, `standards_id`, `event_source_id`, `list_id`) rather than an `account` envelope, the seller resolves ID → tenant via the same authorization context. The authenticated principal must hold access to the referenced resource, and the resource itself carries the brand it was provisioned for; envelope identity on those calls would be redundant and, if it disagreed with the authenticated principal, a spoofing vector. Compliance storyboards in the training agent inject envelope identity on these calls as a sandbox routing convention, because the training agent has no authenticated-principal layer of its own — see [Storyboard authoring](/dist/docs/3.1.20/contributing/storyboard-authoring). Production sellers do not require it. ## Credential placement Credentials authenticating the **buyer principal** MUST arrive on the transport's authentication channel and MUST NOT be placed in the task payload — top-level, inside `context`, inside `ext`, or in any other nested location. The transport channel is: * **Static credentials over HTTP** — `Authorization: Bearer ` per [RFC 6750 §2](https://www.rfc-editor.org/rfc/rfc6750#section-2), or `Authorization: Basic ` when the seller provisions HTTP Basic credentials. * **RFC 9421 signed requests** — the `Signature` and `Signature-Input` headers per [RFC 9421 §2](https://www.rfc-editor.org/rfc/rfc9421#section-2). The signature itself is the credential; nothing in the payload authenticates the signer. * **MCP and A2A authentication framing** — the transport's authentication descriptor (e.g., MCP's `authInfo`, A2A's `authentication.schemes`). Discovery of the authentication requirement follows [RFC 9728 §3](https://www.rfc-editor.org/rfc/rfc9728#section-3) protected-resource metadata where applicable. * **Mutual TLS** — the peer certificate, per the mTLS row in the table above. The rule is transport-agnostic: it applies regardless of which mechanism the seller accepts. There is no AdCP version, capability, or seller policy under which a buyer principal authenticates via a payload field. The same placement rule applies to credentials or caller-supplied trust material a request tries to pass for a downstream evaluator call: those belong on the agent-to-evaluator transport or in account provisioning, not task payloads. A seller that detects a credential- or trust-material key in the payload (e.g., `_access_token`, `api_key`, `client_secret`, `bearer`, `authorization`, `jwk`, `jwks`, or `jwks_uri` at any nesting depth) SHOULD reject the request with [`CREDENTIAL_IN_ARGS`](/dist/docs/3.1.20/building/by-layer/L3/error-handling#authentication-and-access) under AdCP 3.1; the requirement upgrades to MUST 90 days after the 3.1 publication date. The code's recovery classification is `terminal` — agents MUST NOT auto-retry, since auto-retry re-logs the credential on each attempt and is itself the prompt-injection exfiltration surface this rule closes (see [Threats specific to agentic advertising](/dist/docs/3.1.20/building/concepts/security-model#threats-specific-to-agentic-advertising)). ### Carve-outs The following credential surfaces are **not** buyer-principal credentials and the rule above does not apply to them: * **`push_notification_config.authentication.credentials`** ([schema](https://adcontextprotocol.org/schemas/3.1.20/core/push-notification-config.json)). This is the legacy Bearer / HMAC-SHA256 credential that the **seller** uses when calling **back** to the buyer's webhook endpoint. It authenticates the seller-as-caller against the buyer-as-receiver — orthogonal to the buyer-principal credential that authenticates the inbound AdCP request. The default 9421 webhook profile uses keys discovered via `brand.json` and crosses no shared secrets; the legacy block is a deprecated compatibility scheme removed in AdCP 4.0. * **Onboarding-time secrets exchanged out-of-band** — initial token issuance, OAuth dynamic registration responses, dashboard-issued API keys. These travel through the AAO authorization server or the seller's onboarding flow, not as AdCP task payloads. ### Relay agents The agency / A2A relay topology (brand → relay → seller) authenticates **under the relay's own principal**. The relay either preserves the brand agent's RFC 9421 signature verbatim (pass-through model) or re-signs under its own key (re-signing model) — both options are described in [#2324](https://github.com/adcontextprotocol/adcp/issues/2324). Neither model permits forwarding the brand's transport credential as a relay-side payload field. Brand-agent identity, when the relay is the principal of record, MUST be carried in the request body as identity context (e.g., a buyer-side identity assertion verifiable by the seller against `adagents.json` / `authorized_operator[]`) — never as a forwarded transport credential. Relays MUST NOT echo or reattach buyer credentials in any args field on outbound seller-bound requests. ## Protocol Configuration Most MCP and A2A integrations use `Authorization: Bearer ` ([RFC 6750 §2](https://www.rfc-editor.org/rfc/rfc6750#section-2)) as the authentication header. Configure your client with: ```json theme={null} { "auth": { "type": "bearer", "token": "" } } ``` The client library handles adding the `Authorization: Bearer ` header to requests. If a seller explicitly provisions HTTP Basic credentials, the same transport rule applies: send `Authorization: Basic ` on every authenticated request over HTTPS. **Header alias policy by leg.** The two protocol legs differ in what aliases they accept beyond the standard `Authorization` header: * **A2A** — `Authorization` only. For Bearer credentials, send `Authorization: Bearer ` and declare a `bearerAuth` `HTTPAuthSecurityScheme` in the seller's agent card (see the [A2A guide — Agent Cards](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide#agent-cards)). For Basic credentials, send `Authorization: Basic ` and declare HTTP Basic. The `x-adcp-auth` custom header is not recognized on the A2A surface. * **MCP** — `Authorization` is primary for Bearer and Basic credentials. `x-adcp-auth` is accepted as a back-compat Bearer alias for integrations predating adcp 4.5.0. New implementations should use the standard header on both legs. **Sellers migrating to adcp 4.5.0.** If you previously configured `x-adcp-auth` as the A2A leg header via the `a2a_header_name` knob, verify that knob is set to the RFC 6750 default before updating your agent card to declare `bearerAuth` — buyers that have not yet migrated off the legacy header will otherwise receive HTTP 401. ## MCP Client Configuration When using the MCP protocol, authentication is handled by the transport layer, not by adding HTTP headers manually. ### Using MCP Client Libraries The recommended approach is to use an MCP client library: ```typescript TypeScript theme={null} import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; const transport = new StreamableHTTPClientTransport( new URL('https://test-agent.adcontextprotocol.org/sales/mcp'), { requestInit: { headers: { 'Authorization': 'Bearer YOUR_TOKEN_HERE' } } } ); const client = new Client({ name: 'my-client', version: '1.0.0' }); await client.connect(transport); ``` ```python Python theme={null} from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client async with streamablehttp_client( "https://test-agent.adcontextprotocol.org/sales/mcp", headers={"Authorization": "Bearer YOUR_TOKEN_HERE"} ) as (read, write): async with ClientSession(read, write) as session: await session.initialize() ``` ### Common Mistake: Raw HTTP Headers A common mistake is trying to add authentication headers to raw HTTP requests: ```http theme={null} # This won't work for MCP endpoints GET /mcp HTTP/1.1 Authorization: Bearer YOUR_TOKEN ``` MCP uses a streaming protocol over HTTP. The authentication must be configured in the MCP client transport layer, which handles the protocol negotiation and message framing. ### Troubleshooting Authentication If you're getting "authentication required" errors: 1. **Verify you're using an MCP client library** - not making raw HTTP calls 2. **Check the token format** - should be passed to the transport configuration 3. **Test with the public test agent** - verify your setup works before testing custom agents 4. **Check protocol version** - ensure client and server protocol versions are compatible For OAuth handshake failures and RFC 9421 signing issues, use the [CLI auth graders](/dist/docs/3.1.20/building/verification/grading) — `diagnose-auth` probes RFC 9728 + RFC 8414 discovery and ranks hypotheses; `grade request-signing` runs every signing vector with per-vector diagnostics. ## Obtaining Credentials ### Account Setup Process To access authenticated operations, you must establish an account with each sales agent: 1. **Identify Sales Agents**: Discover sales agents via publisher `adagents.json` files 2. **Contact Sales Team**: Reach out to the agent's sales or partnerships team 3. **Complete Onboarding**: Provide business information, sign agreements, configure billing 4. **Receive Credentials**: Get API keys or OAuth client credentials **Note**: Each sales agent manages their own accounts independently. You need separate credentials for each agent you work with. ### Dynamic Registration (Optional) Some sales agents support OAuth 2.0 dynamic client registration: ```http theme={null} POST /oauth/register Content-Type: application/json { "client_name": "Your Company Name", "redirect_uris": ["https://yourapp.com/callback"], "grant_types": ["authorization_code", "refresh_token"], "scope": "adcp:products adcp:media_buys adcp:creatives" } ``` Check the sales agent's documentation or `adagents.json` for dynamic registration support. ### Aggregation Platforms Consider using aggregation platforms (like Scope3) that manage credentials and relationships with multiple sales agents on your behalf. This simplifies: * Credential management * Financial relationships * Legal agreements * Compliance monitoring ## Authenticating to AAO Platform Services The mechanisms above govern **agent-to-agent** auth (buyer ↔ sales agent). Authenticating to **AAO-hosted services** — the registry write API, the AAO MCP endpoint, the member dashboard — is a separate surface. AAO runs an OAuth 2.1 + OIDC authorization server. Clients discover it via standard well-knowns: * **Authorization server metadata (RFC 8414):** `https://agenticadvertising.org/.well-known/oauth-authorization-server` * **Protected-resource metadata (RFC 9728):** `/.well-known/oauth-protected-resource/api` (REST API) and `/.well-known/oauth-protected-resource/mcp` (MCP). Both list `https://agenticadvertising.org` as the authorization server. * **Flow:** authorization code with PKCE (S256). User identity is via WorkOS AuthKit; tokens are signed JWTs. * **Dynamic client registration (RFC 7591):** `POST /register`. * **Server-to-server:** there is no `client_credentials` grant. Backend services should use a WorkOS organization API key from the [AAO dashboard](https://agenticadvertising.org/dashboard/api-keys), not the OAuth `/token` endpoint. All AAO endpoints are HTTPS-only; reject any discovery document served over plain HTTP. A user JWT obtained from AAO is **not** an AdCP credential. Calls to a sales agent still use that agent's bearer / 9421 / mTLS credentials per the table above. Full reference: [AAO registry — Authentication](/dist/docs/3.1.20/registry#authentication). **If you discover an `authorization_endpoint` on a sales agent's RFC 9728 protected-resource metadata** (e.g., for an operator-account OAuth flow), pin the discovered `authorization_servers` issuer against what `adagents.json` — or out-of-band onboarding — authorized for that seller. Do not blindly trust an AS URL the resource itself returned, otherwise a malicious or compromised seller can route operator credentials to an attacker-controlled endpoint. ## Error Responses ### Unauthenticated Request to Protected Operation ```json theme={null} { "error": { "code": "AUTH_REQUIRED", "message": "Authentication required for this operation" } } ``` ### Invalid or Expired Credentials ```json theme={null} { "error": { "code": "AUTH_INVALID", "message": "Invalid or expired credentials" } } ``` ### Insufficient Permissions ```json theme={null} { "error": { "code": "INSUFFICIENT_PERMISSIONS", "message": "Agent does not have required permissions for this operation" } } ``` ## Best Practices 1. **Secure Storage**: Store credentials securely (environment variables, secret managers) 2. **Rotation**: Implement credential rotation policies 3. **Scope Limitation**: Request minimum required permissions 4. **Token Refresh**: Implement automatic token refresh for JWT tokens 5. **Error Handling**: Handle authentication errors gracefully with retry logic ## Testing Authentication The public test agent accepts a shared token — no signup required: ```bash theme={null} export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" export AGENT_URL="https://test-agent.adcontextprotocol.org/sales/mcp" ``` Configure your client with this token: ```json theme={null} { "agent_uri": "https://test-agent.adcontextprotocol.org/sales/mcp", "protocol": "mcp", "auth": { "type": "bearer", "token": "1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" } } ``` For org-scoped usage tracking, replace the public token with your own API key from the [AAO dashboard](https://agenticadvertising.org/dashboard/api-keys). See [Sandbox Mode](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox) for testing capabilities including sandbox mode for risk-free development. # Context & Sessions Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L2/context-sessions AdCP context_id vs task_id explained. How to manage conversation state, session continuity, and extension fields across MCP and A2A protocol requests. AdCP uses identifiers and data fields to maintain state across requests. Understanding these is essential for building effective integrations. ## Key Identifiers AdCP uses two distinct identifiers for different purposes: ### context\_id vs task\_id | Identifier | Purpose | Lifespan | Scope | | --------------- | ------------------------------- | -------------------------------- | -------------------------- | | **context\_id** | Conversation/session continuity | \~1 hour | Across multiple task calls | | **task\_id** | Tracking specific operations | Until completion (hours to days) | Single operation | **context\_id**: * Comes from the protocol layer (built into A2A, manual in MCP) * Provides conversation history and session continuity * Used for maintaining state across multiple task calls * Expires after conversation timeout (typically 1 hour) **task\_id**: * Specific to individual requests that could be asynchronous * Lives beyond the conversation * Used for tracking operation progress over time * Persists until the task completes (may be days for complex media buys) * Can be referenced across different conversations or sessions ### Usage Example ```javascript theme={null} // First call - establishes context and creates task const result = await call('create_media_buy', { brief: "Launch summer campaign" }); const contextId = result.context_id; // For conversation continuity const taskId = result.task_id; // For tracking this specific media buy // Later in same conversation - uses context_id const update1 = await call('update_media_buy', { context_id: contextId, // Maintains conversation state task_id: taskId, // References the specific media buy updates: {...} }); // Days later in new conversation - only task_id needed const delivery = await call('get_media_buy_delivery', { task_id: taskId // No context_id - this is a new conversation }); ``` ## Protocol Differences * **A2A**: Context is handled automatically by the protocol * **MCP**: Requires manual context\_id management ### A2A Context (Automatic) A2A handles sessions natively - you don't need to manage context: ```javascript theme={null} // A2A maintains context automatically const task = await a2a.send({ message: {...} }); // contextId is managed by A2A protocol // Follow-ups automatically use the same context const followUp = await a2a.send({ contextId: task.contextId, // Optional - A2A tracks this message: {...} }); ``` ### MCP Context (Manual) MCP requires explicit context management to maintain state: ```javascript theme={null} // First call - no context const result1 = await mcp.call('get_products', { brief: "Video ads" }); const contextId = result1.context_id; // Save this! // Follow-up - must include context_id const result2 = await mcp.call('get_products', { context_id: contextId, // Required for continuity brief: "Focus on premium inventory" }); ``` ### MCP Context Management Pattern ```javascript theme={null} class MCPSession { constructor(mcp) { this.mcp = mcp; this.contextId = null; } async call(method, params) { const result = await this.mcp.call(method, { ...params, context_id: this.contextId }); this.contextId = result.context_id; // Update for next call return result; } } ``` ### MCP Agent-Side: Session ID Fallback Many MCP clients (ChatGPT, Claude) don't pass `context_id`. Agents should use the transport's session ID as a fallback to enable automatic session persistence: ```typescript theme={null} server.tool('get_products', schema, async (args, extra) => { // Use explicit context_id if provided, fall back to MCP sessionId const contextId = args.context_id ?? extra?.sessionId; const products = await generateProducts(args.brief, contextId); await productStore.save(contextId, products); return products; }); ``` This allows simple clients to get automatic session persistence while preserving explicit control for advanced buyers who need resumable sessions. For a working implementation, see the [Snap AdCP Agent](https://github.com/scope3data/snap-adcp). ## What Context Maintains The `context_id` maintains conversation state, regardless of protocol: * Current media buy and products being discussed * Search results and applied filters * Conversation history and user intent * User preferences expressed in the session * Workflow state and temporary decisions Note: Long-term task state (like media buy status, creative assets, performance data) is tracked via `task_id`, not `context_id`. ## Extension Fields (`ext`) Extension fields enable platform-specific functionality while maintaining protocol compatibility. ### Schema Pattern Extensions appear consistently across requests, responses, and domain objects: ```json theme={null} { "product_id": "ctv_premium", "name": "Connected TV Premium Inventory", "ext": { "gam": { "order_id": "1234567890", "dashboard_url": "https://..." }, "roku": { "content_genres": ["comedy", "drama"] } } } ``` The `ext` object: * Is always **optional** (never required) * Accepts any valid JSON structure * Must be preserved by implementations (even unknown fields) * Is not validated by AdCP schemas (implementation-specific validation allowed) ### Namespacing (Critical) Extensions MUST use vendor/platform namespacing: ```json theme={null} // ✅ Correct - Namespaced { "ext": { "gam": { "test_mode": true }, "roku": { "app_ids": ["123"] } } } // ❌ Incorrect - Not namespaced { "ext": { "test_mode": true, // Missing namespace! "app_ids": ["123"] // Which platform? } } ``` ## Application Context (`context`) Context provides opaque correlation data that is echoed unchanged in responses and webhooks. ### Key Properties * Agents NEVER parse or use context to affect behavior * Exists solely for the initiator's internal tracking needs * Echoed unchanged in responses and webhook payloads ### Normative echo contract Agents MUST obey the following rules. The compliance runner asserts on these literally, and buyers rely on them for correlation. 1. **Echo on success.** When the caller includes a top-level `context` object on a request, the agent MUST include the same object, byte-for-byte equivalent, in the response. This applies whether the response status is `completed`, `submitted`, `working`, `input-required`, or any other terminal or intermediate state. 2. **Echo on error.** Failure responses MUST also echo `context` verbatim. Dropping context on the error path breaks correlation exactly when the buyer needs it most. Agents that return `adcp_error`, `errors[]`, or any other error envelope MUST still carry through the caller's `context`. 3. **Echo on async updates.** Push notifications, webhook payloads, and any subsequent messages the agent emits for the same operation MUST carry the original `context`. The agent MUST NOT drop context between the initial response and a later status update — a buyer that correlated by `context.trace_id` expects every message for that operation to surface the same trace. 4. **No synthesis.** When the caller does NOT provide a `context` object, the agent MUST NOT fabricate one. Responses to context-less requests MUST omit the `context` field (or emit it as null / absent per the transport's normal serialization). Synthetic context from the agent side is a conformance failure — the whole point of context is that it is owned by the caller. 5. **No mutation.** Agents MUST NOT add, remove, rename, reorder, or retype fields in the echoed context. JSON equivalence applies: `{"a":1,"b":2}` and `{"b":2,"a":1}` may serialize differently but are considered equivalent for the echo rule provided key set and values match. Verifiers that rely on byte-literal equality (e.g., MCP clients that hash the raw JSON) SHOULD serialize with stable key ordering on the agent side. 6. **No action.** Agents MUST NOT parse, validate, log fields from, or branch on any value inside `context`. Context is opaque to the agent — a value that looks like a structured identifier is not an invitation to interpret it. ### Schema Pattern ```json theme={null} { "tool": "create_media_buy", "arguments": { "packages": [...], "context": { "ui_session_id": "sess_abc123", "trace_id": "trace_xyz789", "internal_campaign_id": "camp_456" } } } ``` Response echoes the context: ```json theme={null} { "status": "input-required", "message": "Media buy requires manual approval before activation.", "context_id": "ctx_ghi789", "context": { "ui_session_id": "sess_abc123", "trace_id": "trace_xyz789", "internal_campaign_id": "camp_456" } } ``` ### Common Context Uses 1. **UI/Session tracking** - Maintaining state across async operations 2. **Request correlation** - Tracing requests through distributed systems 3. **Internal identifiers** - Mapping to your internal data structures 4. **Organization context** - Multi-tenant tracking ## When to Use What | Field | Purpose | Agent Reads? | Agent Modifies? | | ------------ | ------------------------ | ------------ | --------------------- | | `context_id` | Session continuity | Yes | Yes (creates/updates) | | `task_id` | Operation tracking | Yes | Yes (creates) | | `ext` | Platform-specific config | MAY | MAY add response data | | `context` | Opaque correlation | NEVER | NEVER | ### Use `ext` when: * Platform needs to parse the data * Data MAY affect operational behavior * Data represents platform-specific configuration * Data should persist across operations ### Use `context` when: * Data is only for caller's internal use * Data should never affect agent behavior * Data is for correlation/tracking only * Data needs to be echoed unchanged ## Best Practices ### For A2A * Let the protocol handle context * Use contextId for explicit conversation threading * Trust the session management ### For MCP * Always preserve context\_id between calls * Implement a session wrapper (see pattern above) * Handle context expiration (1 hour timeout) * Start fresh context for new workflows * **Agents**: Use transport session ID as fallback when `context_id` is not provided (see [Session ID Fallback](#mcp-agent-side-session-id-fallback)) ### For Extensions * Always namespace under vendor keys * Document your extensions extensively * Consider proposing standardization for common patterns ### For Application Context * Keep it opaque - don't structure for agents to parse * Avoid large payloads - context is echoed in every response * Use for correlation only - never for operational data # L2 — Auth & registry Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L2/index Auth-and-registry layer of the AdCP stack. Turns a verified identity into a scoped principal — which buyer, which brand, which advertiser account, which sandbox-vs-live tier. L2 turns a verified identity into a scoped principal. On the agent side: multi-tenant principal resolution, sandbox/live boundary, brand resolution, permission scoping. On the caller side: publish own identity, look up the agent it's calling — much smaller surface. ## What an SDK at L2 must provide If you're picking an SDK or porting one to a new language, this is the L2 build target: * **An account-store abstraction** that resolves an authenticated principal to a scoped account, with hooks for multi-tenant routing. * **Authentication primitives** for at least API-key and bearer-token shapes, plus a way to compose them. * **Brand-resolution / agent-registry lookup** — or a documented extension point if the SDK doesn't ship it natively. * **The sandbox-vs-live account flag**, enforced at the SDK boundary so the conformance-test surface refuses to dispatch on production accounts. For the cumulative cross-layer story (what L0+L1+L2 buys you), see the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#l2--auth--registry). ## Pages in this layer * **[Authentication](/dist/docs/3.1.20/building/by-layer/L2/authentication)** — credentials and permissions. * **[Account state](/dist/docs/3.1.20/building/by-layer/L2/account-state)** — multi-tenant account resolution. * **[Accounts and agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents)** — relationship between account scoping and agent identity. * **[Context & sessions](/dist/docs/3.1.20/building/by-layer/L2/context-sessions)** — managing principal state across requests. # Async Operations Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L3/async-operations AdCP async operations guide: handling synchronous, asynchronous, and interactive (input-required) task types with polling, SSE streaming, and timeout strategies. AdCP operations can take seconds, hours, or days. The server decides how to respond based on how long the operation will take and what's blocking it. ## The 30-second rule Any AdCP task can return one of these statuses. The server chooses based on what it knows about the work involved: | Expected duration | Status | What the caller does | | ------------------------------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | Under 30 seconds | `completed` / `failed` | Result is inline — done | | Over 30 seconds, server actively processing | `working` | Out-of-band progress signal. Connection stays open, result arrives when ready. Caller just waits | | Blocked on external dependency | `submitted` | Truly async — poll by `task_id`; `push_notification_config` can also deliver notifications when configured. Result may take hours or days | | Blocked on human input | `input-required` | Caller provides the requested input to continue | **`working` is not async.** It's a progress signal the server sends out-of-band (via MCP status notifications or SSE) while it continues processing. The caller holds the connection and receives the result when it's ready — no polling, no webhooks. Think of it as "this is taking a moment, but I'm on it." **`submitted` is async.** The operation is blocked on something outside the server's control — publisher approval, human review, third-party processing. The caller can always poll the AdCP task status surface with `task_id`. A configured webhook is an additional notification channel for background workflows, not a replacement for polling. :::tip Webhooks for `submitted` operations **Webhooks** are recommended for background `submitted` operations — they work with any transport (MCP, A2A, REST) and handle operations that outlive a single session. For MCP/REST, the webhook channel is requested with `push_notification_config` on the task request. For A2A, it remains transport configuration at `configuration.pushNotificationConfig`, not a snake\_case skill parameter. When a request includes a webhook channel and the server accepts the task by returning `submitted`, the server MUST deliver at least the terminal completion or failure notification to that channel. Intermediate progress notifications are optional unless another operation-specific contract requires them. If the server cannot honor the requested webhook channel, it MUST reject the request with a structured error instead of silently downgrading delivery. See [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks). **Polling** via the AdCP task polling surface is always valid for `submitted` tasks. In 3.x, that surface is legacy `tasks/get`, with optional `get_task_status` when the seller advertises the alias. Both names accept the same payload shape; multi-account callers SHOULD include `account` so sellers can scope task visibility to the authenticated account + principal pair. See the [polling pattern](#polling-for-submitted-operations) below. **Transport-native tasks are not the AdCP lifecycle.** MCP Tasks or A2A task updates may carry or stream an AdCP response, but the durable task state is the AdCP payload: `task_id`, `status`, webhook payloads, and AdCP polling/reconciliation. See [MCP Guide](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide#mcp-tasks-as-a-transport-wrapper). ::: ## Operation examples ### Synchronous (instant) | Operation | Description | | ------------------------------------ | ----------------------------------- | | `get_adcp_capabilities` | Agent capability discovery | | `list_creative_formats` | Format catalog | | `build_creative` (library retrieval) | Resolving an existing `creative_id` | ### May need human input | Operation | Description | | ----------------------------- | ---------------------------------------------------- | | `get_products` | When brief is vague or needs clarification | | `create_media_buy` | When approval is required | | `build_creative` (generation) | When creative direction or asset selection is needed | ### May go async (`submitted`) | Operation | Description | | ----------------------------------- | -------------------------------------------------------------------------- | | `create_media_buy` | Publisher approval workflows | | `update_media_buy` | Manual seller review for budget, targeting, or creative changes | | `get_products` (`brief` / `refine`) | Bespoke curation that depends on upstream inventory queries or HITL review | | `get_signals` (`brief`) | Semantic signal discovery that depends on slow provider queries or review | | `sync_creatives` | Asset review and transcoding pipelines | | `build_creative` (with review) | Human creative review before finalizing | | `sync_catalogs` | Large feeds or feeds requiring content policy review | | `activate_signal` | Platform deployment pipelines | These operations integrate with external systems or require human approval. Wholesale feed reads are the exception: `get_products buying_mode: "wholesale"` and `get_signals discovery_mode: "wholesale"` stay synchronous repair/reconciliation reads and report partial completion with `incomplete[]`, not `submitted`. ## Timeout Configuration Set reasonable timeouts based on status: ```javascript theme={null} const TIMEOUTS = { sync: 30_000, // 30 seconds — most operations complete here working: 300_000, // 5 minutes — server is actively processing interactive: 300_000, // 5 minutes for human input submitted: 86_400_000 // 24 hours for external dependencies }; function getTimeout(status) { if (status === 'submitted') return TIMEOUTS.submitted; if (status === 'working') return TIMEOUTS.working; if (status === 'input-required') return TIMEOUTS.interactive; return TIMEOUTS.sync; } ``` `working` uses a connection timeout (how long to hold open), not a poll interval. The server sends progress out-of-band and delivers the result on the same connection. `submitted` uses a polling or webhook delivery window; a 30-second poll interval is a reasonable default even when webhooks are configured as the primary notification path. ## Human-in-the-Loop Workflows ### Design Principles 1. **Optional by default** - Approvals are configured per implementation 2. **Clear messaging** - Users understand what they're approving 3. **Timeout gracefully** - Don't block forever on human input 4. **Audit trail** - Track who approved what when The human-in-the-loop patterns in async operations embody the [Embedded Human Judgment](/dist/docs/3.1.20/governance/embedded-human-judgment) framework — human judgment is embedded in system design, not bolted on afterward. ### Approval Patterns ```javascript theme={null} async function handleApprovalWorkflow(response) { if (response.status === 'input-required' && needsApproval(response)) { // Show approval UI with context const approval = await showApprovalUI({ title: "Campaign Approval Required", message: response.message, details: response, // Task fields are at top level approver: getCurrentUser() }); // Send approval decision const decision = { approved: approval.approved, notes: approval.notes, approver_id: approval.approver_id, timestamp: new Date().toISOString() }; return sendFollowUp(response.context_id, decision); } } ``` ### Common Approval Triggers * **Budget thresholds**: Campaigns over \$100K * **New advertisers**: First-time buyers * **Policy-sensitive content**: Certain industries or topics * **Manual inventory**: Premium placements requiring publisher approval ## Progress Tracking ### Progress Updates Long-running operations may provide progress information: ```json theme={null} { "status": "working", "message": "Processing creative assets...", "task_id": "task-456", "progress": 45, "step": "transcoding_video", "steps_completed": ["upload", "validation"], "steps_remaining": ["transcoding_video", "thumbnail_generation", "cdn_distribution"] } ``` ### Displaying Progress ```javascript theme={null} function displayProgress(response) { if (response.progress !== undefined) { updateProgressBar(response.progress); } if (response.step) { updateStatusText(`Step: ${response.step}`); } if (response.steps_completed) { updateStepsList(response.steps_completed, response.steps_remaining); } // Always show the message updateMessage(response.message); } ``` ## Protocol-Agnostic Patterns These patterns work with both MCP and A2A. ### Product Discovery with Clarification ```javascript theme={null} async function discoverProducts(brief) { let response = await adcp.send({ task: 'get_products', brief: brief }); // Handle clarification loop while (response.status === 'input-required') { const moreInfo = await promptUser(response.message); response = await adcp.send({ context_id: response.context_id, additional_info: moreInfo }); } if (response.status === 'completed') { return response.products; // Task fields are at top level } else if (response.status === 'failed') { throw new Error(response.message); } } ``` ### Campaign Creation with Approval ```javascript theme={null} async function createCampaign(packages, budget) { let response = await adcp.send({ task: 'create_media_buy', packages: packages, total_budget: budget }); // Handle approval if needed if (response.status === 'input-required') { const approved = await getApproval(response.message); if (!approved) { throw new Error('Campaign creation not approved'); } response = await adcp.send({ context_id: response.context_id, approved: true }); } // 'working' means the server is actively processing — result will arrive // 'submitted' means blocked on external dependency — poll by task_id; // a webhook may also deliver completion if configured. if (response.status === 'submitted') { response = await pollForResult(response.task_id); } if (response.status === 'completed') { return response.media_buy_id; // Task fields are at top level } else { throw new Error(response.message); } } ``` ### Polling for `submitted` Operations Polling is always valid for `submitted` operations. A webhook may also deliver completion when configured, but callers can still reconcile through the task polling surface. Don't poll for `working` — the server delivers the result on the open connection. ```javascript theme={null} async function pollForResult(taskId, options = {}) { const { maxWait = 86_400_000, pollInterval = 30_000 } = options; const startTime = Date.now(); while (true) { if (Date.now() - startTime > maxWait) { throw new Error('Operation timed out'); } await sleep(pollInterval); const response = await adcp.call('get_task_status', { task_id: taskId, include_result: true }); if (['completed', 'failed', 'canceled'].includes(response.status)) { return response; } } } ``` ## Asynchronous-First Design ### Store State Persistently Don't rely on in-memory state for async operations: ```javascript theme={null} class AsyncOperationTracker { constructor(db) { this.db = db; } async startOperation(taskId, operationType, request) { await this.db.operations.insert({ task_id: taskId, type: operationType, status: 'submitted', request: request, created_at: new Date(), updated_at: new Date() }); } async updateStatus(taskId, status, result = null) { await this.db.operations.update( { task_id: taskId }, { status: status, result: result, updated_at: new Date() } ); } async getPendingOperations() { return this.db.operations.find({ status: { $in: ['submitted', 'working', 'input-required'] } }); } } ``` ### Handle Restarts Gracefully Resume tracking after orchestrator restarts: ```javascript theme={null} async function onStartup() { const tracker = new AsyncOperationTracker(db); const pending = await tracker.getPendingOperations(); for (const operation of pending) { // Check current status on server const response = await adcp.call('get_task_status', { task_id: operation.task_id, include_result: true }); // Update local state await tracker.updateStatus(operation.task_id, response.status, response); // Resume polling if still pending if (['submitted', 'working'].includes(response.status)) { startPolling(operation.task_id); } } } ``` ## Best Practices 1. **Design async first** - Assume any operation could take time 2. **Persist state** - Don't rely on in-memory tracking 3. **Handle restarts** - Resume tracking on startup 4. **Implement timeouts** - Don't wait forever 5. **Show progress** - Keep users informed 6. **Support cancellation** - Let users cancel long operations 7. **Audit trail** - Log all status transitions ## Next Steps * **Webhooks**: See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for push notifications instead of polling * **Task Lifecycle**: See [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for status handling details * **Orchestrator Design**: See [Orchestrator Design](/dist/docs/3.1.20/building/operating/orchestrator-design) for production patterns # Compliance test controller Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller Optional sandbox tool that lets the storyboard runner walk full lifecycle state machines by triggering seller-side transitions deterministically. # Compliance test controller **The compliance test controller is a dev/staging-only affordance, not a production-time concept.** AgenticAdvertising.org Verified (Sandbox) grading does not require or use it. The compliance heartbeat drives storyboards against the seller's registered production URL with `account.sandbox: true` on every request, and the seller's production stack is responsible for honoring the flag — no controller endpoint needed. Specialism certification under Verified (Spec) is different: any selected storyboard with `controller_seeding: true` requires a registered dev or staging endpoint that exposes the seed scenarios used by that storyboard. Sellers need only implement the scenarios required by the specialisms they certify; they do not need the full controller surface. Sellers MAY implement the controller in their dev or staging environment to support their own integration testing — walking lifecycle state machines deterministically, seeding fixtures, forcing transitions that would otherwise require waiting for real time. That's its purpose. It MUST NOT be exposed on production deployments (see [Sandbox gating](#sandbox-gating) below). Confused about how the controller relates to AgenticAdvertising.org Verified (Sandbox)? See [#4379](https://github.com/adcontextprotocol/adcp/issues/4379) for the framing decision: (Sandbox) attests "real production endpoint correctly handles sandbox-flagged traffic across the full storyboard suite." The controller is the developer-side affordance for deterministic testing and Verified (Spec) specialism fixtures, not the production grading mechanism. AdCP defines lifecycle state machines for accounts, creatives, media buys, SI sessions, and delivery reporting. Many transitions in these state machines are seller-initiated — creative approval, account suspension, budget depletion, delivery accrual. A storyboard runner can only exercise buyer-initiated flows, leaving seller-initiated transitions untested. The **compliance test controller** is an optional tool sellers expose in their dev/staging environment to support deterministic local testing. It allows a runner to trigger seller-side state transitions on demand, enabling end-to-end lifecycle verification during development. ## Motivation Without a test controller, compliance testing is observational: fire an action, read back whatever state exists, move on. This catches schema violations but not behavioral ones. | Track | Observational (today) | Deterministic (with controller) | | --------------- | ------------------------------------------------ | -------------------------------------------------------------------------------- | | **Creative** | Sync → observe initial status | Walk `processing` → `approved` → `archived`; force `rejected` with reason | | **Account** | Read existing statuses | Force `suspended` → verify operation gates → reactivate | | **SI sessions** | Initiate → message → terminate | Force `terminated` with timeout reason → verify `SESSION_NOT_FOUND` on next call | | **Reporting** | Call `get_media_buy_delivery` → hope data exists | Simulate delivery → verify rollups | | **Budgeting** | Create buy with budget → read back | Simulate spend to threshold → verify alerts and `payment_required` | | **Media buy** | Create → pause → resume | Force seller-initiated `rejected` → verify terminal state | ## Sandbox gating Sellers MUST NOT expose `comply_test_controller` on production deployments — to anyone, on any surface. The tool MUST be absent from `tools/list` (MCP) and from the agent card's `skills[]` (A2A); the `compliance_testing` block MUST be absent from `get_adcp_capabilities`; dispatch MUST return the transport's standard unknown-tool error (e.g., JSON-RPC `-32601 Method not found` for MCP, the unknown-skill rejection for A2A) — indistinguishable from the same-transport response of a seller that does not implement the tool. A production deployment that exposes the tool on any of these surfaces is non-conformant regardless of whether dispatch is gated. The canonical pattern is two deployments: one production (no controller wired), one sandbox/staging (controller wired for all comers). Sellers expose `comply_test_controller` only on sandbox/staging deployments; any principal that can authenticate to such a deployment can call it. Sellers MAY instead run a single deployment with mixed sandbox/live principals and project the tool per-principal, gating on the resolved account's mode. This is an implementation pattern, not the canonical model. Sellers picking this pattern MUST gate all three surfaces consistently: `tools/list` (or `skills[]`), the `compliance_testing` capability block, and dispatch. Partial projection — e.g., gating `tools/list` but leaving the `compliance_testing` block visible to live principals, or returning `FORBIDDEN` (rather than unknown-tool) to a live principal who probes by name — is non-conformant; it reopens the discovery side channel that deployment-scoping closes. `FORBIDDEN` is reserved for the in-sandbox case where the caller is authorized to call the controller but `params` reference a non-sandbox account. Sandbox gating is enforced per-request on the account reference, not just at tool registration time. The mechanism for provisioning sandbox credentials and for separating production from sandbox/staging deployments is seller-specific and out of scope for this spec. Sellers MUST document their sandbox access mechanism so storyboard runners can connect appropriately. The storyboard runner MUST treat the presence of `comply_test_controller` in `tools/list` (or `skills[]`) or the presence of the `compliance_testing` block in `get_adcp_capabilities` on a connection it believes is production as a hard conformance failure. ## Tool definition **Schemas**: [`comply-test-controller-request.json`](https://adcontextprotocol.org/schemas/3.1.20/compliance/comply-test-controller-request.json) | [`comply-test-controller-response.json`](https://adcontextprotocol.org/schemas/3.1.20/compliance/comply-test-controller-response.json) Sellers that implement compliance test controller MUST: * Only expose the tool in sandbox mode (see sandbox gating above) * Enforce the same state transition rules as production — invalid transitions MUST return errors * Reflect forced state changes in subsequent reads (`list_creatives`, `get_media_buys`, etc.) ```json theme={null} { "name": "comply_test_controller", "description": "Triggers seller-side state transitions for compliance testing. Sandbox only.", "inputSchema": { "type": "object", "properties": { "scenario": { "type": "string", "enum": [ "list_scenarios", "force_creative_status", "force_creative_purge", "force_account_status", "force_media_buy_status", "force_create_media_buy_arm", "force_get_products_arm", "force_get_signals_arm", "force_task_completion", "force_session_status", "simulate_delivery", "simulate_budget_spend", "seed_account", "seed_product", "seed_pricing_option", "seed_creative", "seed_plan", "seed_media_buy", "seed_creative_format", "seed_measurement_catalog", "query_upstream_traffic", "query_provenance_audit_observations", "force_upstream_unavailable" ], "description": "The seller-side transition or fixture-seed to trigger." }, "params": { "type": "object", "description": "Scenario-specific parameters. Omit for list_scenarios. force_creative_status: {creative_id, status, rejection_reason?}. force_creative_purge: {creative_id, purge_kind?, reason_code?, reason_detail?}. force_account_status: {account_id, status}. force_media_buy_status: {media_buy_id, status, rejection_reason?}. force_create_media_buy_arm: {arm, task_id?, message?} - task_id required when arm = submitted. force_get_products_arm: {arm, task_id?, message?} - task_id required when arm = submitted. force_get_signals_arm: {arm, task_id?, message?} - task_id required when arm = submitted. force_task_completion: {task_id, result}. force_session_status: {session_id, status, termination_reason?}. simulate_delivery: {media_buy_id, impressions?, clicks?, reported_spend?, conversions?, reach?, frequency?, reach_window?, viewability?}. simulate_budget_spend: {account_id|media_buy_id, spend_percentage}. seed_account: {account_id, fixture?}. seed_product: {product_id, fixture?}. seed_pricing_option: {product_id, pricing_option_id, fixture?}. seed_creative: {creative_id, fixture?}. seed_plan: {plan_id, fixture?}. seed_media_buy: {media_buy_id, fixture?}. seed_creative_format: {format_id, fixture?}. seed_measurement_catalog: {vendor, metrics[]}. query_upstream_traffic: {since_timestamp?, endpoint_pattern?, limit?, attestation_mode?, identifier_value_digests?}. query_provenance_audit_observations: {creative_id}. force_upstream_unavailable: {tool, upstream_name?}." } }, "required": ["scenario"] } } ``` The `params` description inlines param shapes for each scenario because MCP clients (including LLMs) read descriptions, not conditional schema branches. For formal validation schemas suitable for SDK code generation, see the per-scenario definitions below. ## Scenarios ### `force_creative_status` Transitions a creative to the specified status. The seller MUST enforce valid transitions per the [creative lifecycle state machine](/dist/docs/3.1.20/creative/specification#creative-status-lifecycle). **Params:** | Field | Type | Required | Description | | ------------------ | ----------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------- | | `creative_id` | string | Yes | Creative to transition | | `status` | `processing` \| `pending_review` \| `approved` \| `suspended` \| `rejected` \| `archived` | Yes | Target status | | `rejection_reason` | string | When `status` = `rejected` | Reason for rejection | | `reason_code` | CreativeEventReasonCode | No | Reason code for the lifecycle transition | | `reason_detail` | string | No | Human-readable detail for the lifecycle transition | **Example:** ```json theme={null} { "scenario": "force_creative_status", "params": { "creative_id": "cr-123", "status": "rejected", "reason_code": "policy_revocation", "rejection_reason": "Brand safety policy violation" } } ``` ### `force_account_status` Transitions an account to the specified status. The seller MUST enforce the [account lifecycle rules](/dist/docs/3.1.20/accounts/overview#account-status-lifecycle) — terminal states (`rejected`, `closed`) cannot be exited. **Params:** | Field | Type | Required | Description | | ------------ | --------------------------------------------------------------------------------------------- | -------- | --------------------- | | `account_id` | string | Yes | Account to transition | | `status` | `active` \| `pending_approval` \| `rejected` \| `payment_required` \| `suspended` \| `closed` | Yes | Target status | **Example:** ```json theme={null} { "scenario": "force_account_status", "params": { "account_id": "acct-456", "status": "payment_required" } } ``` ### `force_media_buy_status` Transitions a media buy to the specified status. The seller MUST enforce the media buy lifecycle — `rejected` is only valid from `pending_creatives` or `pending_start`. **Params:** | Field | Type | Required | Description | | ------------------ | --------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------- | | `media_buy_id` | string | Yes | Media buy to transition | | `status` | `pending_creatives` \| `pending_start` \| `active` \| `paused` \| `completed` \| `rejected` \| `canceled` | Yes | Target status | | `rejection_reason` | string | When `status` = `rejected` | Reason for rejection | **Example:** ```json theme={null} { "scenario": "force_media_buy_status", "params": { "media_buy_id": "mb-789", "status": "rejected", "rejection_reason": "Policy violation" } } ``` ### `force_create_media_buy_arm` Shapes the next [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) call from the caller's authenticated sandbox account into a specific response arm. v1 supports two arms: `submitted` (the async task envelope, no `media_buy_id` yet) and `input-required` (the errors-branch). Unlike `force_media_buy_status`, no entity transitions — there is no media buy yet — so the response carries `forced.arm` rather than `previous_state`/`current_state`. The submitted-arm wire shape is otherwise implementation-dependent: most sellers route most buys synchronously and no buyer-side request shape reliably triggers async. This scenario lets storyboards pin the arm so a regressed seller (e.g., emitting `media_buy_id` under `status: submitted`) cannot pass conformance silently. **Params:** | Field | Type | Required | Description | | --------- | ------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `arm` | `submitted` \| `input-required` | Yes | Target response arm for the next `create_media_buy` call | | `task_id` | string | When `arm` = `submitted` | Deterministic task handle (max 128 chars) the seller MUST emit verbatim on the submitted envelope and MUST accept on subsequent `tasks/get` polls. Sandbox task\_ids are caller-opaque strings; production task-id format rules do not apply. | | `message` | string | No | Human-readable explanation surfaced verbatim on the seller's `create_media_buy` response. Plain text, max 2000 characters. Buyers consuming the resulting response MUST apply the prompt-injection sanitization documented for [`message` on the submitted envelope](https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-response.json) — this scenario is the natural place for a runner to inject adversarial strings to test that buyer-side sanitization. | **Example:** ```json theme={null} { "scenario": "force_create_media_buy_arm", "params": { "arm": "submitted", "task_id": "task_async_signed_io_q2", "message": "Awaiting IO signature from sales team; typical turnaround 2–4 hours" } } ``` **Response.** A `ForcedDirectiveSuccess` shape carrying the registered directive: ```json theme={null} { "success": true, "forced": { "arm": "submitted", "task_id": "task_async_signed_io_q2" }, "message": "Next create_media_buy call will return the submitted arm with task_id task_async_signed_io_q2" } ``` `forced.task_id` is present only when `arm: submitted`. **Consumption and idempotency.** The directive is keyed to the caller's authenticated sandbox account (account + principal pair) and is consumed by the next `create_media_buy` call from that account. Subsequent calls without a fresh directive return the seller's default arm. Buyer-side `idempotency_key` semantics are unchanged: if the caller replays a `create_media_buy` request that already consumed a directive, the seller MUST replay the cached response (the request idempotency cache wins) and MUST NOT re-evaluate against the now-empty directive slot. Sellers MUST NOT match a directive against a `create_media_buy` call from a different account or principal, even within the same transport connection. A second `force_create_media_buy_arm` call before the directive is consumed overwrites the prior one. ### `force_get_products_arm` / `force_get_signals_arm` Shape the next curated discovery call from the caller's authenticated sandbox account (account + principal pair) into a submitted task envelope. `force_get_products_arm` applies only to `get_products` with `buying_mode: "brief"` or `"refine"`. `force_get_signals_arm` applies only to `get_signals` with `discovery_mode: "brief"` (or omitted, where brief is the default). Wholesale feed reads are synchronous feed access and MUST NOT consume these directives or return the Submitted arm solely because a directive exists. The directive exists for the same reason as `force_create_media_buy_arm`: buyers cannot reliably trigger async discovery from request shape alone, but conformance needs a deterministic way to prove clients and sellers honor the task-result path. The submitted envelope carries only `status` and `task_id` (plus optional advisory fields such as `message`); terminal `products[]`, `proposals[]`, or `signals[]` land on task completion through `get_task_status` (legacy `tasks/get`) and any registered push notification. **Params:** | Field | Type | Required | Description | | --------- | ----------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `arm` | `submitted` | Yes | Target response arm for the next matching discovery call. | | `task_id` | string | When `arm` = `submitted` | Deterministic task handle (max 128 chars) the seller MUST emit verbatim on the submitted envelope and MUST accept on subsequent polling. | | `message` | string | No | Human-readable explanation surfaced verbatim on the submitted discovery response. Plain text, max 2000 characters. | **Examples:** ```json theme={null} { "scenario": "force_get_products_arm", "account": { "brand": { "domain": "acmeoutdoor.example" }, "operator": "pinnacle-agency.example", "sandbox": true }, "params": { "arm": "submitted", "task_id": "task_async_products_acme_q3", "message": "Custom product curation queued; typical turnaround 10 minutes" } } ``` ```json theme={null} { "scenario": "force_get_signals_arm", "account": { "brand": { "domain": "novamotors.example" }, "operator": "pinnacle-agency.example", "sandbox": true }, "params": { "arm": "submitted", "task_id": "task_async_signals_nova_ev", "message": "Signal discovery queued; typical turnaround 10 minutes" } } ``` **Response.** Both scenarios return the same `ForcedDirectiveSuccess` shape as `force_create_media_buy_arm`, carrying `forced.arm` and `forced.task_id`. **Consumption and idempotency.** The directive is keyed to the caller's authenticated sandbox account (account + principal pair) and consumed by the next matching discovery call from that same account. The seller MUST NOT match a product directive against `get_signals`, a signal directive against `get_products`, a brief/refine directive against wholesale mode, or any directive against a different account or principal. A second directive for the same operation before consumption overwrites the prior directive. Request idempotency replay semantics are unchanged: if the discovery request that consumed the directive is replayed, the seller returns the cached submitted envelope and does not consume a fresh directive. ### `force_task_completion` Resolves a previously-submitted async task to `completed` with a buyer-supplied result payload. The companion to the `force_*_arm` scenarios: those scenarios drive the seller into a submitted envelope; this one closes the loop by transitioning the task store entry to `completed` and stamping the registered result. The buyer observes completion via the seller's push notification to `push_notification_config.url` and via subsequent `get_task_status` calls reporting `status: "completed"`. When the caller requests `include_result: true`, `get_task_status` returns the typed terminal result payload that matches the original async operation. The submitted → completed lifecycle is otherwise non-deterministic — real task completions ride on out-of-band signals (IO countersignature, batch processor cron, governance human review). Storyboards cannot wait. This scenario lets a runner pin the completion deterministically immediately after registering the directive, so the buyer-side polling assertion fires on the same wire shape buyers will observe in production. **Params:** | Field | Type | Required | Description | | --------- | --------------------------------------------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `task_id` | string | Yes | Task to resolve. MUST resolve within the caller's authenticated sandbox account; sellers MUST return `NOT_FOUND` (not `FORBIDDEN`, per the multi-tenant convention above) for `task_id`s belonging to other accounts. Typically captured from the prior `create_media_buy` submitted-envelope response (or registered via `force_create_media_buy_arm`). | | `result` | [`async-response-data`](https://adcontextprotocol.org/schemas/3.1.20/core/async-response-data.json) | Yes | Completion payload to record. Validates against the same `anyOf` union the push-notification webhook and `tasks/get` polling responses use. For `create_media_buy`, this is a `CreateMediaBuyResponse` with `media_buy_id` and `packages`. Sellers MUST emit `INVALID_PARAMS` if `result` does not validate against the response branch for the task's original method. Sellers MAY reject `result` payloads exceeding 256 KB with `INVALID_PARAMS`; storyboards MUST stay below this. | **Example:** ```json theme={null} { "scenario": "force_task_completion", "params": { "task_id": "task_async_signed_io_q2", "result": { "media_buy_id": "mb_async_signed_io_q2", "status": "active", "packages": [ { "package_id": "pkg-0", "product_id": "async_signed_io_q2", "budget": 30000 } ] } } } ``` **Response.** Returns a state-transition success shape: ```json theme={null} { "success": true, "previous_state": "submitted", "current_state": "completed", "message": "Task task_async_signed_io_q2 transitioned from submitted to completed" } ``` Source state MUST be `submitted`, `working`, or `input-required`; any other source returns `INVALID_TRANSITION`. Sellers MUST emit `NOT_FOUND` if `task_id` is unknown to the caller's account, and `INVALID_TRANSITION` if the task is already terminal (`completed` / `failed` / `canceled`). Forcing a task to `failed` is out of scope for this scenario; the input-required arm of `force_create_media_buy_arm` covers the buyer-input-needed failure path. **Replay semantics.** Replays with identical params before the task is terminal are idempotent no-ops. Replays with diverging params before the task is terminal MUST overwrite the registered result (last-write-wins) — same precedent as `force_create_media_buy_arm`'s "second call overwrites." After the task is terminal, every replay returns `INVALID_TRANSITION` regardless of params. **Cross-protocol obligations.** * **Push notifications.** If the buyer registered `push_notification_config.url` on the original `create_media_buy`, forcing completion MUST fire the webhook with the registered `result` payload (the canonical 3.0 delivery path for completion data). Otherwise the storyboard can only test polling for terminal status, not push delivery of the result. * **`simulate_delivery` / `simulate_budget_spend`.** Once forced to completed with a valid `CreateMediaBuyResponse` carrying `media_buy_id`, the resulting media buy MUST be addressable by those scenarios. Round-tripping through `force_task_completion` is the supported path for storyboards that need a media buy without going through the synchronous flow. **Buyer-side observation.** After this scenario runs, the registered `result` is delivered to the buyer's `push_notification_config.url` (3.0 canonical path) with all caller-supplied fields preserved. Sellers MAY augment with seller-controlled fields (e.g., `created_at`, `dsp_*` IDs, normalized currency casing) but MUST NOT overwrite caller-supplied values. A subsequent `tasks/get(task_id)` MUST return `status: "completed"`. The `result` payload is buyer-controlled in sandbox and round-trips through the seller's store — buyers receiving it via webhook MUST treat the payload as untrusted seller output (per AdCP convention) regardless of the fact that they originated the bytes. This makes `force_task_completion` the natural place for a runner to inject adversarial payloads when testing buyer-side sanitization on the webhook delivery path. ### `force_session_status` Transitions an SI session to a terminal status. Enables testing timeout and termination scenarios that would otherwise require waiting for real timeouts. The `termination_reason` param simulates the cause so the storyboard runner can verify sellers report the correct reason in subsequent responses. **Params:** | Field | Type | Required | Description | | -------------------- | -------------------------- | ---------------------------- | --------------------------------------------------------------------------------------- | | `session_id` | string | Yes | Session to transition | | `status` | `complete` \| `terminated` | Yes | Target terminal status | | `termination_reason` | string | When `status` = `terminated` | Reason for termination (e.g., `session_timeout`, `host_terminated`, `policy_violation`) | **Example:** ```json theme={null} { "scenario": "force_session_status", "params": { "session_id": "sess-abc", "status": "terminated", "termination_reason": "session_timeout" } } ``` ### `simulate_delivery` Injects synthetic delivery data for a media buy. Subsequent calls to `get_media_buy_delivery` MUST reflect this data. Delivery simulation is additive — each call adds to existing delivery totals. **Delivery and budget are independent systems.** `simulate_delivery` records what the ad server would report. `simulate_budget_spend` records what the billing system would track. A seller's production system may or may not couple these — the test controller does not assume coupling. **Params:** | Field | Type | Required | Description | | ---------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy_id` | string | Yes | Media buy to add delivery to | | `impressions` | integer | No | Impressions to simulate | | `clicks` | integer | No | Clicks to simulate | | `reported_spend` | object | No | `{ amount: number, currency: string }` — spend as reported in delivery data, does not affect budget | | `conversions` | integer | No | Conversions to simulate | | `reach` | number | No | Unique reach count to surface at `totals.reach` | | `frequency` | number | No | Average frequency per reach unit to surface at `totals.frequency` | | `reach_window` | object | No | Measurement window for simulated reach/frequency. Shape: `{ kind: "cumulative" }`, `{ kind: "period", period: Duration }`, or `{ kind: "rolling", period: Duration }` | | `viewability` | object | No | Viewability block to surface at `totals.viewability`, including `measurable_impressions`, `viewable_impressions`, `viewable_rate`, `viewed_seconds`, and `standard`. `standard` SHOULD be supplied whenever measured viewability values are present | **Example:** ```json theme={null} { "scenario": "simulate_delivery", "params": { "media_buy_id": "mb-789", "impressions": 10000, "clicks": 150, "reach": 4000, "frequency": 2.5, "reach_window": { "kind": "rolling", "period": { "interval": 7, "unit": "days" } }, "viewability": { "measurable_impressions": 9000, "viewable_impressions": 7200, "viewable_rate": 0.8, "viewed_seconds": 4.3, "standard": "mrc" }, "reported_spend": { "amount": 150.00, "currency": "USD" } } } ``` ### `simulate_budget_spend` Simulates budget consumption to a specified percentage. Enables testing budget threshold alerts and `payment_required` transitions without waiting for real spend. This is the only scenario that affects account-level financial state. After calling `simulate_budget_spend`, the seller MUST reflect the simulated consumption in `get_account_financials`. Specifically: * `total_spend` (or equivalent) MUST reflect the simulated amount * `remaining_budget` (or equivalent) MUST be reduced accordingly * Budget utilization percentages MUST match `spend_percentage` **Params:** | Field | Type | Required | Description | | ------------------ | ------ | -------- | ---------------------------------- | | `account_id` | string | No | Account (for account-level budget) | | `media_buy_id` | string | No | Media buy (for buy-level budget) | | `spend_percentage` | number | Yes | Spend to this % of budget (0–100) | At least one of `account_id` or `media_buy_id` is required. The target entity MUST have a non-zero budget configured — the controller SHOULD return `INVALID_PARAMS` if it does not. **Example:** ```json theme={null} { "scenario": "simulate_budget_spend", "params": { "media_buy_id": "mb-789", "spend_percentage": 95 } } ``` ### `seed_product` Creates (or upserts) a product fixture with a caller-supplied `product_id` so subsequent storyboard steps can reference the product by stable ID. The controller MUST make the seeded product discoverable via `get_products` under the authenticated account unless the fixture explicitly marks it hidden. **Why this scenario exists.** Storyboards hardcode fixture IDs like `"test-product"` and expect the seller to have a matching product. Without a seed scenario, every implementer rediscovers which IDs the conformance suite expects and has to alias them by hand. `seed_product` replaces that discovery with an explicit, storyboard-authored contract. **Params:** | Field | Type | Required | Description | | ------------ | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `product_id` | string | Yes | Stable identifier the storyboard will reference | | `fixture` | object | No | Product shape. Minimum useful fields: `delivery_type`, `channels`, `pricing_options[]`, `format_ids[]`. Sellers MAY fill in defaults for omitted fields. | For vendor-metric precondition tests, prefer `seed_measurement_catalog` for the external vendor catalog. A product fixture may also carry `measurement_catalogs[]` entries shaped as `{ vendor, metrics[] }` as a compatibility fallback for local harnesses that need the product contract and referenced measurement snapshot in one fixture. If both are supplied for the same vendor, the explicit `seed_measurement_catalog` snapshot takes precedence for that compliance session. **Example:** ```json theme={null} { "scenario": "seed_product", "params": { "product_id": "test-product", "fixture": { "delivery_type": "non_guaranteed", "channels": ["display"], "pricing_options": [ { "pricing_option_id": "test-pricing", "pricing_model": "cpm", "currency": "USD", "floor_price": 1.0 } ], "format_ids": [{ "id": "display_300x250" }] } } } ``` ### `seed_pricing_option` Adds (or upserts) a pricing option on an existing seeded product. Use this when a storyboard needs a specific pricing option that wasn't included in the initial `seed_product` call, or when the option's attributes need to diverge from the seller's default. **Params:** | Field | Type | Required | Description | | ------------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `product_id` | string | Yes | Parent product (must already exist — seed it first) | | `pricing_option_id` | string | Yes | Stable identifier for the pricing option | | `fixture` | object | No | Pricing option shape per the [`PricingOption`](https://adcontextprotocol.org/schemas/3.1.20/core/pricing-option.json) schema (`pricing_model`, `currency`, `floor_price` for auction-based, `fixed_price` for fixed, etc.) | **Example:** ```json theme={null} { "scenario": "seed_pricing_option", "params": { "product_id": "test-product", "pricing_option_id": "default", "fixture": { "pricing_model": "cpm", "floor_price": 5.0, "currency": "USD" } } } ``` ### `seed_creative` Creates a creative fixture at a specific lifecycle status. Lets governance and delivery storyboards reference a pre-approved creative without round-tripping `sync_creatives` first. **Params:** | Field | Type | Required | Description | | ------------- | ------ | -------- | ------------------------------------------------------------------------------------- | | `creative_id` | string | Yes | Stable identifier | | `fixture` | object | No | Creative shape. Typical fields: `status`, `format_id`, `assets`, `click_through_url`. | **Example:** ```json theme={null} { "scenario": "seed_creative", "params": { "creative_id": "campaign_hero_video", "fixture": { "status": "approved", "format_id": { "id": "video_30s" }, "assets": [{ "type": "video", "url": "https://example.com/hero.mp4" }] } } } ``` ### `seed_plan` Creates a media plan fixture. Used by governance storyboards that assert against a specific plan without running the full briefing + proposal flow first. **Params:** | Field | Type | Required | Description | | --------- | ------ | -------- | ------------------------------------------------------------------------ | | `plan_id` | string | Yes | Stable identifier | | `fixture` | object | No | Plan shape. Typical fields: `budget`, `brand`, `flight`, `line_items[]`. | **Example:** ```json theme={null} { "scenario": "seed_plan", "params": { "plan_id": "gov_acme_q2_2027", "fixture": { "budget": { "total": 30000, "currency": "USD" }, "brand": { "domain": "acmeoutdoor.example" }, "flight": { "start": "2027-04-01", "end": "2027-06-30" } } } } ``` ### `seed_media_buy` Creates a media buy fixture at a specified lifecycle state, bypassing the `create_media_buy` flow. Used by storyboards that need to assert governance or delivery behavior against a pre-existing buy. **Params:** | Field | Type | Required | Description | | -------------- | ------ | -------- | ---------------------------------------------------------------------------- | | `media_buy_id` | string | Yes | Stable identifier | | `fixture` | object | No | Media buy shape. Typical fields: `status`, `packages[]`, `budget`, `flight`. | **Example:** ```json theme={null} { "scenario": "seed_media_buy", "params": { "media_buy_id": "mb_acme_q2_2026_auction", "fixture": { "status": "active", "packages": [{ "package_id": "pkg_001", "product_id": "test-product" }] } } } ``` ### `seed_measurement_catalog` Seeds a measurement vendor's `get_adcp_capabilities.measurement.metrics[]` snapshot for the compliance session, when the seller's controller advertises this scenario. Media-buy storyboards use an explicit `comply_test_controller` step with this scenario to distinguish a product-level vendor metric capability from the external vendor-catalog discovery precondition. Storyboards may also carry this snapshot in `seed_product.fixture.measurement_catalogs[]` as a compatibility fallback for controllers whose SDK adapter set has not adopted the scenario yet. When both sources are present for the same vendor, the explicit `seed_measurement_catalog` seed is authoritative. **Params:** | Field | Type | Required | Description | | --------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------- | | `vendor` | BrandRef | Yes | Measurement vendor whose catalog is being seeded | | `metrics` | object\[] | Yes | Catalog entries; each entry must include `metric_id` and may include the optional fields from `measurement.metrics[]` | **Example:** ```json theme={null} { "scenario": "seed_measurement_catalog", "params": { "vendor": { "domain": "attentionvendor.example" }, "metrics": [ { "metric_id": "attention_catalog_baseline", "unit": "score", "description": "Baseline attention metric present in this vendor catalog." } ] } } ``` ### Seeding semantics and ordering * **Fixture shape.** `fixture` is kept permissive (`additionalProperties: true`) so storyboard authors can declare the minimum shape each test needs. Fixtures SHOULD conform to the corresponding domain schema (`core/product.json` for `seed_product`, `core/pricing-option.json` for `seed_pricing_option`, `media-buy/sync-creatives-request.json` creative-item shape for `seed_creative`, `core/media-buy.json` for `seed_media_buy`, the plan schema for `seed_plan`). `seed_measurement_catalog.metrics[]` mirrors `get_adcp_capabilities.measurement.metrics[]`. Sellers MAY reject clearly malformed fixtures with `INVALID_PARAMS`. * **Idempotency on re-seed.** A second call with the same primary ID and a `fixture` equivalent to the first SHOULD succeed and return `success: true` with `previous_state: "existing"`. A second call with a **diverging** fixture MUST return `INVALID_PARAMS` with `error_detail` explaining which fields diverged — sellers MUST NOT merge or update silently. Storyboards that need to change fixture state mid-run MUST use `force_*` scenarios, not a re-seed. This keeps the same storyboard deterministic across sellers. * **Foreign-key ordering.** The runner seeds fixtures in dependency order so sellers receive referenced parents before their children. The dependency DAG: ``` product ──┬─→ pricing_option ├─→ plan └─→ media_buy creative ────→ media_buy plan ────────→ media_buy ``` Concretely: `seed_product` before `seed_pricing_option`; `seed_product`, `seed_creative`, and `seed_plan` all before `seed_media_buy` when the fixture references them. Storyboards that declare a `fixtures:` block MUST list entries in an order the runner can topologically sort — sellers that receive a `seed_pricing_option` for a product that does not exist, or a `seed_media_buy` referencing a creative/product/plan that was not seeded first, MUST return `INVALID_PARAMS` rather than auto-create the parent. * **Sandbox scope.** Seeded fixtures exist only for the authenticated sandbox account. `NOT_FOUND` applies the same way as for `force_*` — a seller that cannot see the parent product for the caller's account MUST return `NOT_FOUND`, not silently fall back to another tenant. * **Capability advertisement.** Sellers that do not implement a given seed scenario MUST return `UNKNOWN_SCENARIO` for that scenario name. The runner treats `UNKNOWN_SCENARIO` on a `seed_*` as a coverage gap for storyboards whose `prerequisites.controller_seeding` requires the scenario — those storyboards are graded `not_applicable`, not failed. This applies to **unfamiliar** `seed_*` names as well: a runner may emit a scenario the seller has never seen because the enum is open-for-extension (see below). Sellers and runners MUST respond with `UNKNOWN_SCENARIO` rather than schema-reject an unrecognized scenario value. * **Open-for-extension enum.** The `scenario` enum adds new values over time (new seed scenarios land as specialisms demand them). Runners and sellers MUST accept scenario strings they do not recognize and respond with `UNKNOWN_SCENARIO` rather than hard-fail schema validation — otherwise every new enum value becomes a breaking change for stale implementations. ## Response shape ### State transition responses (`force_*`) **Success:** ```json theme={null} { "success": true, "previous_state": "processing", "current_state": "approved", "message": "Creative cr-123 transitioned from processing to approved" } ``` **Failure (invalid transition):** ```json theme={null} { "success": false, "error": "INVALID_TRANSITION", "error_detail": "Cannot transition from archived to processing — archived is terminal", "current_state": "archived" } ``` **Failure (unknown entity):** ```json theme={null} { "success": false, "error": "NOT_FOUND", "error_detail": "Creative cr-unknown not found", "current_state": null } ``` ### Simulation responses (`simulate_*`) **`simulate_delivery` response:** ```json theme={null} { "success": true, "simulated": { "impressions": 10000, "clicks": 150, "reach": 4000, "frequency": 2.5, "reach_window": { "kind": "rolling", "period": { "interval": 7, "unit": "days" } }, "viewability": { "measurable_impressions": 9000, "viewable_impressions": 7200, "viewable_rate": 0.8, "viewed_seconds": 4.3, "standard": "mrc" }, "reported_spend": { "amount": 150.00, "currency": "USD" } }, "cumulative": { "impressions": 25000, "clicks": 380, "reach": 4000, "frequency": 2.5, "reach_window": { "kind": "rolling", "period": { "interval": 7, "unit": "days" } }, "viewability": { "measurable_impressions": 9000, "viewable_impressions": 7200, "viewable_rate": 0.8, "viewed_seconds": 4.3, "standard": "mrc" }, "reported_spend": { "amount": 375.00, "currency": "USD" } }, "message": "Delivery simulated for mb-789: 10000 impressions, 150 clicks, $150.00 spend" } ``` The `simulated` field echoes back the values injected by this call. The `cumulative` field returns running totals across additive counters and spend for this media buy, plus the latest non-additive reach-window and viewability state, so callers can verify expected state before checking `get_media_buy_delivery`. **`simulate_budget_spend` response:** ```json theme={null} { "success": true, "simulated": { "spend_percentage": 95, "computed_spend": { "amount": 950.00, "currency": "USD" }, "budget": { "amount": 1000.00, "currency": "USD" } }, "message": "Budget for mb-789 set to 95% consumed ($950.00 of $1000.00)" } ``` ### Error codes Controllers MUST use structured error codes so the storyboard runner can assert on specific failure modes: | Error code | When | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `INVALID_TRANSITION` | Requested state-machine transition is not valid (e.g., `archived → processing`, `canceled → paused`) | | `INVALID_STATE` | Operation is not permitted for the resource's current status (e.g., re-seeding a fixture that already exists with a diverging shape) | | `NOT_FOUND` | Entity does not exist or caller does not have access (multi-tenant sandboxes SHOULD treat "not yours" as "not found") | | `UNKNOWN_SCENARIO` | Scenario not implemented by this seller | | `INVALID_PARAMS` | Missing or malformed params, or precondition not met (e.g., `simulate_budget_spend` on an entity with no budget configured) | | `FORBIDDEN` | Production account referenced from a sandbox connection | | `JCS_NON_FINITE_NUMBER` | Digest-mode `query_upstream_traffic` cannot canonicalize a parsed JSON-like value tree containing `NaN`, `+Infinity`, or `-Infinity`; controllers must not coerce these values, and runners grade the affected validation `not_applicable` | | `INTERNAL_ERROR` | Transient seller-side failure (e.g., sandbox database unavailable). The runner SHOULD retry once before treating as a failure. | **Controller-specific enum.** The `error` field on controller responses uses a controller-specific vocabulary defined in [`comply-test-controller-response.json`](https://adcontextprotocol.org/schemas/3.1.20/compliance/comply-test-controller-response.json), distinct from the canonical seller-response [`error-code.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/error-code.json) enum that governs task-level errors. `INVALID_TRANSITION` is controller-specific (state-machine primitives expose the transition-vs-state distinction that seller-level error codes collapse into `INVALID_STATE`). Storyboard assertions on controller responses use `path: "error"` or direct `field_value` checks, not `check: error_code` — the shape-agnostic `error_code` check is for task-response errors (`adcp_error` / payload `errors[]`), not the controller's own response schema. ### Idempotency State transition scenarios (`force_*`) are idempotent: forcing a status that matches the current state returns success with `previous_state` equal to `current_state`. This avoids flaky tests when the runner retries after transient failures. Simulation scenarios (`simulate_*`) are NOT idempotent — `simulate_delivery` adds to existing totals, while `simulate_budget_spend` replaces the current spend level. ## Test surfaces Where a seller's state-of-record lives determines how the storyboard test loop closes. State-local sellers (typically SSPs, creative agents) write to the seller's DB via the `seed_*` scenarios above; the seller's read handlers consume the same store, and the seed→read loop closes naturally. Upstream-proxy sellers (DSPs proxying to platforms, retail-media networks reading retailer catalogs, signals brokers) cannot close the loop that way because their read handlers reach a system the seller does not control; the TypeScript SDK ships a `TestControllerBridge` that runs the real adapter call first, then merges seeded fixtures into the response. Either path earns the wire-format pass that `AAO Verified (Spec)` attests. Neither path is what `(Sandbox)` attests — that's a separate axis covering whether the seller's production stack honors `account.sandbox: true` without real-world side effects. The cross-page framing for both implementations of this pattern, the SDK's `_bridge` advisory marker, and the runtime-signals disambiguation table all live in the Conformance Specification → [Test surfaces and the storyboard loop](/dist/docs/3.1.20/building/verification/conformance#test-surfaces-and-the-storyboard-loop). ## Compliance testing modes The presence of `comply_test_controller` in a seller's tool list determines which mode a compliance tester uses: ### Capability discovery A seller may implement the test controller without supporting every scenario. The storyboard runner SHOULD call `comply_test_controller` with `scenario: "list_scenarios"` as the first interaction. Sellers that support this return the list of implemented scenarios: ```json theme={null} { "success": true, "scenarios": [ "force_creative_status", "force_account_status", "force_media_buy_status" ] } ``` Sellers that implement `list_scenarios` MUST respond with scenario names that appear verbatim in the `scenario` enum of [`comply-test-controller-request.json`](https://adcontextprotocol.org/schemas/3.1.20/compliance/comply-test-controller-request.json). Custom seller-specific scenario names are not part of the compliance contract; storyboard runners will not dispatch to scenarios outside the canonical enum, so listing them serves no purpose. A seller that supports `seed_product` MUST respond with the string `"seed_product"` — not `"create_test_product"` or any other variant. Sellers that do not implement `list_scenarios` SHOULD return an error with `UNKNOWN_SCENARIO`. When this happens, the runner tries each scenario individually and treats `UNKNOWN_SCENARIO` responses as coverage gaps (not failures). This means early implementers who skip `list_scenarios` are not penalized — the runner discovers supported scenarios through trial. ### Observational mode (default) When `comply_test_controller` is not available: * The runner executes buyer-initiated flows and validates response schemas * State machine transitions that require seller action are skipped * Advisory observations note what could not be tested ### Deterministic mode When `comply_test_controller` is available: * The runner walks every reachable state in each lifecycle * Forces edge cases: terminal states, invalid transitions, error codes * Validates that forced state changes are reflected in subsequent reads * Tests operation gates (e.g., `create_media_buy` blocked when account is `suspended`) The runner distinguishes three outcome categories in deterministic mode: * **Scenario not supported** — returned by `list_scenarios` or `UNKNOWN_SCENARIO` error. Reported as a coverage gap, not a failure. * **Transition correctly rejected** — controller returned `INVALID_TRANSITION` for an invalid state change. This is a pass. * **Unexpected failure** — controller returned an error for a transition that should be valid, or succeeded on a transition that should fail. This is a compliance failure. ### Example: creative lifecycle in deterministic mode ``` 1. sync_creatives(creative) 2. list_creatives() → verify status = "processing" 3. force_creative_status(creative_id, "pending_review") 4. force_creative_status(creative_id, "approved") 5. list_creatives() → verify status = "approved" 6. force_creative_status(creative_id, "archived") 7. list_creatives() → verify status = "archived" 8. sync_creatives(same creative) → verify unarchive (→ approved or pending_review) 9. force_creative_status(creative_id, "rejected", reason) 10. list_creatives() → verify rejection_reason persisted 11. sync_creatives(same creative) → verify resubmission (rejected → processing) 12. force_creative_status(creative_id, "approved") → expect INVALID_TRANSITION (must go through pending_review) ``` ### Example: account operation gates in deterministic mode ``` 1. sync_accounts(account) → active 2. force_account_status(account_id, "suspended") 3. create_media_buy() → expect ACCOUNT_SUSPENDED 4. get_media_buys() → expect existing buys still readable 5. force_account_status(account_id, "active") 6. create_media_buy() → expect success 7. force_account_status(account_id, "payment_required") 8. update_media_buy(add packages) → expect ACCOUNT_PAYMENT_REQUIRED 9. get_media_buys() → existing buys still readable ``` ### Example: media buy lifecycle in deterministic mode ``` 1. create_media_buy() → status = "pending_creatives" 2. force_media_buy_status(media_buy_id, "rejected", reason) → expect success 3. get_media_buys() → verify status = "rejected", rejection_reason persisted 4. force_media_buy_status(media_buy_id, "active") → expect INVALID_TRANSITION (rejected is terminal) 5. create_media_buy() → new buy, status = "pending_creatives" 6. force_media_buy_status(media_buy_id, "pending_start") 7. force_media_buy_status(media_buy_id, "active") 8. force_media_buy_status(media_buy_id, "rejected") → expect INVALID_TRANSITION (rejected only valid from pending_creatives or pending_start) ``` ### Example: delivery and budget verification ``` 1. create_media_buy(budget: $1000) 2. simulate_delivery(impressions: 10000, reported_spend: $500) 3. get_media_buy_delivery() → verify delivery reflects simulated data (reported_spend is delivery-only; does not affect account budget) 4. simulate_budget_spend(spend_percentage: 95) 5. get_account_financials() → verify total_spend reflects 95% ($950, not $500 from delivery) 6. simulate_budget_spend(spend_percentage: 100) 7. force_account_status("payment_required") 8. create_media_buy() → expect ACCOUNT_PAYMENT_REQUIRED ``` ## Certification tiers | Tier | Requirement | What it proves | | ------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- | | **Functional compliance** | Pass all storyboards in observational mode | Tools exist, respond correctly, and complete buyer-initiated flows | | **Stateful compliance** | Pass all storyboards in deterministic mode | State machines enforce correct transitions, error codes match spec, operation gates block correctly | **Specialism-scoped seed requirements.** Stateful compliance also requires that sellers implement the `seed_*` scenarios covering the specialisms they certify against. The `UNKNOWN_SCENARIO` → `not_applicable` grading is for honest coverage reporting on missing surface area, not a blanket opt-out from conformance — a seller certifying `sales-non-guaranteed` MUST implement at least `seed_product` and `seed_pricing_option`; a seller certifying `creative-ad-server` MUST implement `seed_creative`; a seller certifying `governance-delivery-monitor` MUST implement `seed_plan` (and `seed_media_buy` where the storyboard requires it). The storyboard authors in `static/compliance/source/specialisms/` declare the fixtures their storyboards need; sellers match that list to the specialisms on their cert. ## Implementation guidance ### For sellers 1. Gate `comply_test_controller` at the deployment level — it MUST NOT appear in `tools/list` (or A2A `skills[]`), MUST NOT be advertised via the `compliance_testing` capability block, and MUST dispatch to unknown-tool on production deployments. See [Sandbox gating](#sandbox-gating) for the full rule. 2. Reuse your production state machine logic — the controller should call the same internal transition functions, not bypass them 3. Enforce transition rules — if `rejected` is terminal in production, `force_media_buy_status(rejected → active)` must fail via the controller too 4. Reflect changes immediately — after a forced transition, the next `list_*` or `get_*` call must return the updated state ### For compliance testers 1. Detect the tool during profile discovery via `tools/list` 2. Call `list_scenarios` to discover which scenarios are supported 3. Run observational mode as the baseline — it works everywhere 4. Layer deterministic scenarios on top when the controller is available 5. Report which mode was used and distinguish coverage gaps from failures 6. Test the controller's transition validation itself — invalid transitions should return `INVALID_TRANSITION`, not silently succeed ## Design decisions 1. **Sellers validate transition ordering.** The controller enforces the same state machine rules as production. Calling `force_creative_status(approved)` on a creative that was never `processing` is an error — the controller rejects it just as production would. The lifecycle state machines referenced here are defined in the respective protocol specifications (see [creative lifecycle](/dist/docs/3.1.20/creative/specification#creative-status-lifecycle), [account lifecycle](/dist/docs/3.1.20/accounts/overview#account-status-lifecycle), [media buy lifecycle](/dist/docs/3.1.20/media-buy/specification), [SI session lifecycle](/dist/docs/3.1.20/sponsored-intelligence/specification#session-states)). 2. **Tests are self-contained.** Each test SHOULD create dedicated entities (media buys, creatives, accounts) rather than reusing existing ones. This ensures additive simulation calls (`simulate_delivery`) start from known-zero state without needing a reset mechanism. No `reset` scenario is needed. Compliance testers SHOULD use unique identifiers (e.g., UUIDs) for test entities to avoid collisions when multiple storyboard runner instances run against the same sandbox concurrently. Sandbox entity cleanup (e.g., TTL-based expiration) is the seller's responsibility. 3. **Delivery simulation uses a synthetic marker.** `simulate_delivery` records MAY include a `synthetic: true` field that sellers can use internally for bookkeeping. The runner ignores this marker — it validates `get_media_buy_delivery` responses against the same schema regardless. This lowers the implementation bar for sellers without affecting test correctness. 4. **One tool, many scenarios.** The single-tool design keeps context window cost to \~500 tokens vs \~1,400 for seven separate tools. Sellers implement one sandbox gate. The runner detects one tool. The `list_scenarios` introspection handles partial implementations without requiring per-tool presence detection. # Error Handling Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L3/error-handling AdCP error handling: protocol errors, task failures, and validation errors with standard error codes, recovery strategies, and exponential backoff retry logic. AdCP uses a consistent error handling approach across all operations. Understanding error categories and implementing proper recovery strategies is essential for building robust integrations. ## Compliance Levels Sellers can adopt error handling incrementally. Each level builds on the previous: | Level | What to implement | What agents can do | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | **Level 1** | Return `code` + `message` on every error | Agents match on error code to classify failures | | **Level 2** | Add `recovery`, `retry_after`, `field`, and `suggestion` | Agents auto-retry transient errors and self-correct correctable ones | | **Level 3** | Use [transport bindings](/dist/docs/3.1.20/building/operating/transport-errors) to put errors in `structuredContent` (MCP) or artifact `DataPart` (A2A) | Programmatic clients get typed errors without parsing text | **Level 1** is the minimum for a conformant implementation. **Level 2** is where agent-driven recovery becomes possible — without `recovery`, agents must guess from the error code. **Level 3** is where client libraries like `@adcp/sdk` can provide fully typed error objects. ## Error Categories ### 1. Protocol Errors Transport/connection issues not related to AdCP business logic: * Network timeouts * Connection refused * TLS/SSL errors * JSON parsing errors **Handling:** Retry with exponential backoff. ### 2. Task Errors Business logic failures returned as `status: "failed"`: * Insufficient inventory * Invalid targeting * Budget validation failures * Resource not found **Handling:** Check the `recovery` field to determine whether to retry, fix the request, or escalate. ### 3. Validation Errors Malformed requests that fail schema validation: * Missing required fields * Invalid field types * Out-of-range values **Handling:** Fix request format and retry. Usually development-time issues. ## Error Response Format Failed operations return status `failed` with error details. The error object follows the [`error.json`](https://adcontextprotocol.org/schemas/3.1.20/core/error.json) schema: ```json theme={null} { "status": "failed", "message": "Budget is below the seller's minimum for this product", "errors": [ { "code": "BUDGET_TOO_LOW", "message": "Budget is below the seller's minimum for this product", "recovery": "correctable", "field": "budget.total", "suggestion": "Increase budget to at least 500 USD", "details": { "minimum_budget": 500, "currency": "USD" } } ] } ``` ### Envelope vs. payload errors — the two-layer model AdCP exposes errors in two distinct places, and implementers need to populate the right layer for the right situation. This is the single most common source of error-shape drift between agents and storyboards. | Layer | Key | When to populate | Shape | | ---------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | **Task payload** | `payload.errors[]` (or top-level `errors[]`, depending on transport) | The task ran; the payload reports one or more issues (fatal or non-fatal) | Array of error objects per [`error.json`](https://adcontextprotocol.org/schemas/3.1.20/core/error.json) | | **Transport envelope** | `adcp_error` | The task failed and the transport needs a typed, extractable signal | Single error object per [`error.json`](https://adcontextprotocol.org/schemas/3.1.20/core/error.json) | **A fatal task failure SHOULD populate both layers.** The payload carries the structured `errors[]` array that any protocol can read verbatim, and the transport envelope carries `adcp_error` so MCP/A2A clients can extract a typed error without re-parsing the payload. Populating only one of the two is the source-of-truth for most interop bugs — a runner that reads the transport envelope sees no error, and a runner that reads the payload sees no error signal on the transport: ```json theme={null} // MCP — structuredContent AND payload both carry the error { "content": [{"type": "text", "text": "{\"adcp_error\":{\"code\":\"BUDGET_TOO_LOW\", ...}}"}], "isError": true, "structuredContent": { "adcp_error": { "code": "BUDGET_TOO_LOW", "message": "...", "recovery": "correctable" }, "payload": { "errors": [ { "code": "BUDGET_TOO_LOW", "message": "...", "recovery": "correctable", "field": "budget.total" } ] } } } ``` ```json theme={null} // A2A — artifact DataPart carries adcp_error; if the agent also surfaces payload via a sibling DataPart, errors[] lives there { "status": { "state": "failed" }, "artifacts": [{ "artifactId": "error-result", "parts": [ { "kind": "data", "data": { "adcp_error": { "code": "BUDGET_TOO_LOW", ... } } }, { "kind": "data", "data": { "errors": [{ "code": "BUDGET_TOO_LOW", "field": "budget.total", ... }] } } ] }] } ``` **Non-fatal errors populate only the payload.** A `status: "submitted"` or `status: "input-required"` task reporting a warning (e.g., "this media buy requires manual approval — \[warning details]") populates `errors[]` in the payload with `severity: "warning"` but MUST NOT populate `adcp_error`. The transport envelope signals "the task failed" — it is not a warning channel. **Storyboard validators.** Prefer `check: error_code` over `check: field_present, path: "errors"` when asserting on a failed task's error code. `error_code` is shape-agnostic — the runner resolves it from either `adcp_error.code` (transport) or `errors[0].code` (payload). Direct `path: "errors"` checks pin the assertion to the payload shape and fail against agents that surface errors only via the transport envelope, even when the agent is conformant. See [Storyboard authoring — Asserting on errors](/dist/docs/3.1.20/contributing/storyboard-authoring#asserting-on-errors). **Discriminated rejection arms.** When the task response defines a structured rejection arm (e.g., `AcquireRightsRejected`, `CreativeRejected` — see the wire-placement guidance on [`GOVERNANCE_DENIED`](https://adcontextprotocol.org/schemas/3.1.20/enums/error-code.json) for the rule), the spec-correct denial response carries no error code on the wire — the rejection arm enforces `not: { required: [errors] }` at the schema layer. Asserting `check: error_code` will fail against a conformant agent. Assert on the discriminator instead: `check: field_value, path: "status", value: "rejected"`. This is the pattern for governance denial on `acquire_rights` and policy denial on `creative_approval`; assertions that mix the two paths (`error_code` for tasks with rejection arms, `field_value` for tasks without) bake non-spec opinions into the storyboard. ### Error Object Fields These fields are defined by the [`error.json`](https://adcontextprotocol.org/schemas/3.1.20/core/error.json) schema: | Field | Type | Required | Description | | ------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `code` | string | Yes | Machine-readable error code from the [standard vocabulary](#standard-error-codes) or a seller-specific code | | `message` | string | Yes | Human-readable error description | | `recovery` | string | No | Agent recovery classification: `transient`, `correctable`, or `terminal` | | `retry_after` | number | No | Seconds to wait before retrying (transient errors) | | `field` | string | No | Field path in JSONPath-lite format (e.g., `packages[0].targeting`). When `issues` is present, sellers MUST set this to `issues[0].pointer` translated from RFC 6901 to JSONPath-lite (e.g., `/packages/0/targeting` → `packages[0].targeting`). Will be deprecated in a future major version. | | `issues` | array | No | Structured list of validation failures. Each entry carries `pointer` (RFC 6901), `message`, `keyword` (JSON Schema keyword that rejected — `required` / `type` / `format` / etc.), and optionally `schema_id`, `schemaPath`, `discriminator`. See [Validator-internals fields](#validator-internals-fields-on-issues) for the `schema_id` / `schemaPath` / `discriminator` semantics, the production-emit rules, and the resolution path for `schema_id`. | | `suggestion` | string | No | Suggested fix for the error | | `details` | object | No | Additional context-specific information. Sellers MAY mirror `issues[]` here as `details.issues` for backward compatibility with pre-3.1 consumers; new consumers SHOULD prefer the top-level `issues` field. | ### Validator-internals fields on `issues` Three optional fields on each `issues[]` entry name the schema element that rejected the payload, so agents can recover from validation errors in one iteration instead of probing variants: | Field | Shape | Purpose | | --------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `schema_id` | string — a published `$id` (e.g. `/schemas/3.1.0/core/activation-key.json`) | Canonical name of the rejecting (sub-)schema. 3.1+ consumers' primary handle. | | `schemaPath` | string — a JSON Schema tree path (e.g. `#/properties/packages/items/oneOf/1`) | Validator-internal traversal. Retained for 3.0.x backward compatibility; 3.1+ consumers SHOULD prefer `schema_id`. (Renamed to `schema_path` in a future major.) | | `discriminator` | array of `{property_name, value}` | Variant the validator selected for const-discriminated `oneOf` / `anyOf`, sourced from values present in the payload. Aligns with OpenAPI 3.x `discriminator.propertyName`. | **Resolving `schema_id`.** The string is the schema's `$id`. To load the schema: * **HTTPS canonical:** prepend `https://adcontextprotocol.org` (e.g. `https://adcontextprotocol.org/schemas/3.1.0/core/activation-key.json`). Cacheable; immutable per version. * **SDK-bundled:** `@adcp/sdk` and `adcp-client-python` ship the schema bundle for offline resolution — look up by `$id` against the bundled tree. * **Bundled-tree caveat.** Tools served from the pre-resolved bundled tree (`/schemas/{version}/bundled/...`) inline sub-schemas with their `$id` preserved (3.1+, see #3868) — but only on the **first occurrence** of each sub-schema within a bundle. Same source schema referenced from multiple co-locations gets `$id` only on the first inline; subsequent occurrences fall back to the nearest `$id`-bearing ancestor (typically the response root) when SDK error reporting walks up the schema tree. Sub-schemas whose subtrees contain hoisted `$defs` references also have `$id` stripped at bundle time, because preserving `$id` there would break local-fragment resolution. Consumers reading bundles produced before #3868 see only the response-root `$id`. Detect the pre-#3868 case by checking whether the `$id` ends in `/bundled/-response.json` — if so, fall back to walking the bundled schema by `pointer`. **Discriminator semantics.** Sellers populate `discriminator` only when (a) the rejecting schema is a const-discriminated `oneOf` / `anyOf`, and (b) the discriminator property is present in the payload. The wire field reports the value the caller sent — not a validator inference on partial-match heuristics — so the field is deterministic across Ajv, Python `jsonschema`, and `gojsonschema`. When zero variants survive validation, sellers MUST omit `discriminator` (omission is the signal to the agent that "the validator could not localize a target variant"). For compound discriminators (e.g. `audience-selector`'s `(type, value_type)`), entries are ordered by declaration in the rejecting schema's `properties` block. When the discriminator property is *missing* from the payload (the validator can't even start branch selection), sellers omit `discriminator` — the recovery signal comes from a sibling `issues[]` entry with `keyword: "required"` whose `pointer` names the absent discriminator property. Agents reading "missing required discriminator field" + "no `discriminator` on this issue" recover by populating the named property; agents seeing a `discriminator` array with a value that didn't match any branch recover by switching to a different value. **Production-emit rules (the public-spec stance).** All three fields are emit-on-production safe **when the rejecting element is in the published spec at the version the seller advertises via `get_adcp_capabilities`**. The rationale is replay-locally: schemas are published at adcontextprotocol.org and bundled with every SDK, so an adversary running the same validator against the same payload derives the same branch selection — the wire field carries no information they can't compute. The replay-locally argument has carve-outs sellers MUST honor: * **Private extensions.** Sellers running schemas with custom `oneOf` branches, server-only sub-schemas, or enum subsets layered via `additionalProperties: true` MUST NOT emit `schema_id`, `schemaPath`, or `discriminator` when the rejecting element is not present in the published spec. Emission would leak seller-internal validation state the adversary cannot replay. *Implementation:* sellers running a mixed public + private validation tree typically (a) compile public-only and private-only validators separately and emit `schema_id` only from the public run, or (b) instrument compiled schemas with a side-table mapping each `$id` to its source bundle. * **Version skew.** Sellers validating against a pre-release or post-release schema MUST NOT emit a `schema_id` whose `$id` is not present in the published bundle for the version named in `get_adcp_capabilities`. * **Server-narrowed public elements.** When the seller server-side filters a public enum, pattern, or numeric range to a tenant-specific subset (e.g. accepts `["a","b","c"]` per the public schema, rejects everything except `["a"]` for this caller), sellers MUST NOT return `VALIDATION_ERROR` with `keyword: "enum"` (or `pattern` / `minimum` / `maximum`) against the public `schema_id`. The public-spec replay would accept the value; the seller rejects on private state. Use `POLICY_VIOLATION` or `UNSUPPORTED_FEATURE` instead so the rejection isn't mis-attributed to a public schema element. The structural delta between "public-replay accepts, seller rejects" is itself a fingerprint of the seller's private subset. * **Custom keywords.** `keyword` MUST be drawn from the JSON Schema Draft 7 / 2020-12 vocabulary — sellers using validator-specific custom keywords (Ajv `addKeyword`, `instanceof`) MUST NOT emit them on the wire. * **Probe terseness.** Sellers MAY scope these three fields to dev/sandbox responses on rate-limited endpoints to keep production envelopes terse, even when the carve-outs above don't apply. Field omission is always conformant. ## Standard Error Codes Standard error codes are defined in [`error-code.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/error-code.json). The vocabulary is **open**: `error.code` is wire-typed as `string`, the standard codes are documentary, and senders MAY emit codes outside the standard set. ### Forward-compatible decoding (normative) **The error-code vocabulary is open.** `error.code` is typed as `string` in [`core/error.json`](https://adcontextprotocol.org/schemas/3.1.20/core/error.json) — not as a closed enum — so a strict JSON Schema validator MUST accept any string value. The standard vocabulary in `error-code.json` is documentary; it constrains neither sender nor receiver at the wire level. **Receivers MUST decode unknown codes.** A receiver pinned to AdCP version X that decodes a response carrying an `error.code` introduced in version X+1 (or a platform-specific code outside the standard vocabulary) MUST: 1. Treat the response as well-formed — MUST NOT reject the envelope, throw a deserialization exception, or downgrade to a generic protocol error. 2. Recover the recovery classification from `error.recovery` (top-level field on the error envelope) when present. `error.recovery` is the normative carrier; the `enumMetadata.recovery` in `error-code.json` is the documentary mirror. 3. When `error.recovery` is absent (legacy senders), apply a conservative default. `transient` is the safe default for unknown codes — retry-with-backoff cannot do worse than terminal classification, and the manifest's [`error_code_policy.default_unknown_recovery`](https://adcontextprotocol.org/schemas/3.1.20/manifest.schema.json) documents this as the canonical fallback. The `transient` default is **bounded** by the retry rules in [§ Retry Logic](#retry-logic) — receivers MUST apply `maxRetries` and the jittered exponential-backoff schedule, and MUST NOT loop indefinitely on a `transient` default. A hostile or buggy sender cannot induce unbounded retries against a conformant client; the open-enum decoding rule does not exempt receivers from the retry budget. **Senders MAY emit codes outside the receiver's pinned vocabulary.** A sender emitting a 3.1-era code (e.g., `PROPOSAL_NOT_FOUND`) to a 3.0-pinned receiver does not violate the spec — the receiver is required by the rule above to handle it. When a sender knows the receiver's pinned version (via `adcp_version` envelope echo or capability discovery), senders SHOULD prefer a code from that version's vocabulary when an equivalent exists; senders MAY emit newer or platform-specific codes when no equivalent is available. **Senders MUST populate `error.recovery` on every error.** This is the normative carrier of recovery semantics across version skew — receivers cannot reliably classify a code they don't know, but they can always read `error.recovery`. Omitting it defeats the forward-compat rule. **Why this matters.** Forward-compatible decoding is the wire-level invariant that lets future maintenance lines (3.1.x, 4.0.x, …) ship new error codes additively without breaking older receivers. Without it, every new code is a wire change held to the next minor — the current drift-lint policy on 3.0.x. With it in 3.1+, new codes can register during a maintenance line's lifetime, which matters as adopters' real-world rejection paths surface new codes. **3.0.x policy unchanged.** 3.0.x receivers predate this normative rule, so 3.0.x stays wire-stable for the remainder of its support window — new codes still land at the next minor. This rule sets the receiver contract from 3.1 onward. **Not-found precedence.** When a referenced identifier does not resolve, sellers SHOULD return the resource-specific code when the resolved type is known from the request: `PRODUCT_NOT_FOUND` for `product_id`, `PACKAGE_NOT_FOUND` for `package_id`, `MEDIA_BUY_NOT_FOUND` for `media_buy_id`, `CREATIVE_NOT_FOUND` for `creative_id`, `SIGNAL_NOT_FOUND` for `signal_id`, `SESSION_NOT_FOUND` for SI `session_id`, `ACCOUNT_NOT_FOUND` for `account_id`, `PLAN_NOT_FOUND` for governance `plan_id`. Fall back to `REFERENCE_NOT_FOUND` for resource types without a dedicated code (e.g., property lists, content standards, rights grants, SI offerings, proposals, catalogs, event sources, collection lists, brands, individual properties). Typed parameters that lack a dedicated standard code MUST use `REFERENCE_NOT_FOUND` rather than minting a custom `*_NOT_FOUND` code — the vocabulary grows by upstream spec change, not by per-seller inflation. Clients SHOULD switch on `error.code` first; the resource-specific codes let clients dispatch without parsing `error.field`. **Polymorphic parameters.** When the unresolved identifier was supplied via a polymorphic or untyped parameter (a field that accepts multiple resource types), sellers MUST use `REFERENCE_NOT_FOUND` even if a resource-specific code exists for the resolved type. Using the type-specific code on a polymorphic parameter leaks the resolved type to an unauthorized caller. Polymorphism is evaluated against the parameter's declared shape in the tool schema — **before any lookup** — so a generic `reference_id` parameter dispatches to `REFERENCE_NOT_FOUND` regardless of what the id resolves to. Evaluating on the resolved type after dispatch reintroduces the leak. A tool's declared parameter shape MUST be identical across all callers for a given tool version; dispatch rules MUST NOT be conditioned on caller identity. A schema that exposes `property_list_id` to tenant B but only `reference_id` to tenant A turns capability discovery itself into an enumeration oracle (read the schema under two identities, diff). **Uniform response for inaccessible references.** The uniform-response requirement applies to **every** not-found code in this vocabulary (`REFERENCE_NOT_FOUND`, `SIGNAL_NOT_FOUND`, `CREATIVE_NOT_FOUND`, `MEDIA_BUY_NOT_FOUND`, `PACKAGE_NOT_FOUND`, `SESSION_NOT_FOUND`, `ACCOUNT_NOT_FOUND`, `PLAN_NOT_FOUND`): sellers MUST return the same response for "exists but the caller lacks access" as for "does not exist". Never distinguish the two — this is how cross-tenant enumeration lands. The MUST covers every observable channel, not just `error.code`: * **Error object.** `error.code`, `error.message`, `error.field`, `error.details` MUST be byte-equivalent between the two cases. On typed parameters that fall back to `REFERENCE_NOT_FOUND`, `error.field` MUST be identical across true-miss and resolve-then-deny — either omitted on both or replaced with a type-neutral name on both. `error.field` MAY name the input parameter when the parameter name is type-neutral (e.g., `reference_id`); when the original parameter name is type-revealing (e.g., `property_list_id`), `error.field` MUST be omitted or replaced with a neutral name. `error.message` MUST be generic (no resource-qualified text like `"Property list not found"`). For `REFERENCE_NOT_FOUND` specifically, sellers MUST NOT leak the resolved resource type via `error.field`, `error.details`, or a resource-qualified `error.message`. When the parameter is an array (e.g., `catalog_ids`, `format_ids`), `error.field` MUST name the array parameter itself. Sellers MAY enumerate specific unresolvable elements in `error.details` — but only when the elements were supplied verbatim by the caller. Sellers MUST NOT distinguish "supplied element resolved but caller unauthorized" from "supplied element does not exist" at the element level; that reintroduces the enumeration oracle at the array-entry granularity. * **Transport status.** HTTP status code, A2A `task.status.state`, and MCP `isError` MUST be identical between the two cases. * **Response headers.** `ETag`, `Cache-Control`, per-resource-type rate-limit buckets, CDN tags, and any header whose value or presence differs by resource type MUST be identical. * **Side effects.** Webhook dispatch and audit-log writes MUST be identical — a resolve-then-deny path MUST NOT write tenant audit rows, enqueue background work (search-indexer updates, cache warmers, access-log aggregators), increment per-resource-type quota/rate-limit counters, or fire webhooks to any subscriber (including the resource owner) in ways a true-miss would not. If the resolve-then-deny path touches a per-tenant DB shard or cache, the true-miss path MUST touch the same shape of storage (e.g., route both through a tenant-agnostic resolver) so that co-tenant observers cannot distinguish via storage-layer metrics. * **Observability.** Downstream logs, APM spans, and third-party error-reporting telemetry (Sentry, Datadog, Rollbar, and equivalents) MUST NOT be tagged with the resolved resource type when the caller lacks access; the trace a true-miss emits MUST be structurally indistinguishable from the trace a resolve-then-deny emits. To make latency parity a consequence rather than a separate requirement, sellers MUST perform the same resolution-and-authorization work on both paths — **resolve-then-authorize**, never short-circuit on "unknown id." On a true-miss, sellers MUST still execute an authorization decision of equivalent shape (e.g., against an empty principal set, or against the caller's own tenant as a decoy) so that authorizer latency — which varies with the size and shape of the ACL graph — is not a side channel. Pre-lookup input validation (UUID format, length, regex) is permitted iff it is deterministic in request content only (same input → same verdict, regardless of caller or existence). Non-normative implementation note: a single-query pattern like `SELECT ... WHERE id = ? AND tenant = ?` *looks* uniform but differs in execution plan, buffer-pool touches, and authorizer invocation depending on whether the row exists in another tenant. Prefer the two-step pattern — resolve by id, then authorize against the loaded row (or against an empty row on true-miss) — as the only pattern that naturally produces observational uniformity. **Cache warmth** is a distinct oracle: a warm cache on tenant B's id indicates someone accessed it recently. Sellers MUST NOT gate cache population on authorization — true-miss ids MUST be cached-as-miss with the same TTL as resolve-then-deny, or cache reads MUST be bypassed for not-found responses. **Verifying this yourself.** The paired-probe `adcp fuzz` invariant checks uniform-response compliance by comparing two responses per tool. See [Validate Your Agent — Preparing to test uniform error responses](/dist/docs/3.1.20/building/verification/validate-your-agent#preparing-to-test-uniform-error-responses) for tenant-setup requirements and CLI invocation. Full-strength testing requires two isolated tenants; single-tenant runs cover only the "does not exist" leg. ### Authentication and Access | Code | Recovery | Description | Resolution | | -------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AUTH_REQUIRED` | correctable\* | Authentication is required, or presented credentials were rejected | Provide credentials when missing; escalate to operator when rejected — see the warning below | | `CREDENTIAL_IN_ARGS` | terminal | Authentication material or caller-supplied trust material was placed in request args (top-level, `context`, `ext`, or any nested location) instead of the relevant transport authentication or trust channel. This includes inbound buyer-principal credentials and evaluator-call credentials or JWK/JWKS/JWKS-URI material smuggled into evaluator payload fields. | Do NOT auto-retry — auto-retry re-logs the credential. Move the credential onto the relevant transport authentication/trust channel ([Credential placement](/dist/docs/3.1.20/building/by-layer/L2/authentication#credential-placement)), rotate any leaked credential, then resubmit | | `ACCOUNT_NOT_FOUND` | terminal | Account reference could not be resolved | Verify via `list_accounts` or contact seller | | `ACCOUNT_SETUP_REQUIRED` | correctable | Account needs setup before use | Check `details.setup` for URL or instructions | | `ACCOUNT_AMBIGUOUS` | correctable | Natural key resolves to multiple accounts | Pass explicit `account_id` or a more specific natural key | | `ACCOUNT_PAYMENT_REQUIRED` | terminal | Outstanding balance requires payment | Buyer must resolve billing | | `ACCOUNT_SUSPENDED` | terminal | Account has been suspended | Contact seller to resolve | **`AUTH_REQUIRED` sub-cases — do not auto-retry rejected credentials.** The wire code carries two operationally distinct cases that agents MUST handle differently: * **Credentials missing** → provide credentials and retry once. Correctable inside the agent loop. * **Credentials presented but rejected** (expired, revoked, or malformed signature) → do **not** auto-retry; escalate to operator for credential rotation. Re-presenting a rejected credential against an SSO endpoint creates retry-storm patterns indistinguishable from brute-force probes — the seller's fraud detection may rate-limit, suspend, or alert on the calling agent. `CREDENTIAL_IN_ARGS` is the related — but distinct — case where authentication material or caller-supplied trust material was placed in the **task payload** instead of the relevant transport authentication or trust channel. That code is `terminal` (auto-retry re-logs the credential) and the rule + carve-outs (push-notification webhook auth, relay topology) live in [Credential placement](/dist/docs/3.1.20/building/by-layer/L2/authentication#credential-placement). A future minor release splits this code into `AUTH_MISSING` (correctable) and `AUTH_INVALID` (terminal). Until then, agents branch on whether credentials were attached to the failing request: ```javascript theme={null} case 'AUTH_REQUIRED': { // The caller's request builder records whether an auth header was attached. // The error-handling SDK surfaces this on `error.request_had_credentials` (or you // pass it in from your own request wrapper). const requestHadCredentials = Boolean(error.request_had_credentials); if (!requestHadCredentials) { // Sub-case (a) — provide credentials and retry. await refreshCredentials(); return retry(); } // Sub-case (b) — credentials were presented and rejected. // Treat as terminal at the application layer; surface to operator. console.error('Credential rejected — needs human rotation:', error.message); throw error; } ``` ### Billing and Account Setup Returned by [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) when the request's billing or account-shape values are not acceptable to the seller. The two billing-rejection codes distinguish *which gate* fired so agents can dispatch on the right recovery — autonomous retry vs surface-to-human — without parsing prose. See [Buyer-agent identity](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#buyer-agent-identity) for the two-layer identity model these codes sit on top of. | Code | Recovery | Description | Resolution | | --------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BILLING_NOT_SUPPORTED` | correctable | Seller declines the requested `billing` value at the seller-wide capability level (`supported_billing` does not include the value) or at the per-account-relationship level (e.g., the seller accepts `operator` billing in general but has no direct relationship with the operator on this specific account) | Check `get_adcp_capabilities` for `supported_billing`, resubmit with a supported value, or omit `billing`; when present, dispatch on `error.details.scope` (`"capability"` or `"account"`) per [`billing-not-supported.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/billing-not-supported.json) | | `BILLING_NOT_PERMITTED_FOR_AGENT` | correctable | Seller-wide capability accepts the requested value, but the calling buyer agent's commercial relationship does not (e.g., onboarded as passthrough-only — no payments relationship — so only `operator` billing is permitted) | Retry with `error.details.suggested_billing` (typically `operator`) when present; when absent, the rejection is terminal-pending-onboarding — the agent MUST NOT auto-retry and MUST surface to a human at the buyer to complete payments-relationship onboarding with the seller offline | | `PAYMENT_TERMS_NOT_SUPPORTED` | correctable | Seller does not accept the requested `payment_terms` value | Omit `payment_terms` to accept the default, retry with a different supported value, or negotiate offline | | `BRAND_REQUIRED` | correctable | Billable operation attempted without a brand reference | Include `brand` (`domain` plus optional `brand_id`) on the request | Normative requirements: * **Uniform response without established agent identity.** `BILLING_NOT_PERMITTED_FOR_AGENT` differs from `BILLING_NOT_SUPPORTED` based on the caller's onboarded commercial state with the seller. Returning the per-agent code without established identity lets unauthenticated probes use the code-choice as an oracle for "is this agent onboarded as agent-billable?" — same shape as the [`*_NOT_FOUND` uniform-response rule](#standard-error-codes). The bright line: sellers MUST emit `BILLING_NOT_PERMITTED_FOR_AGENT` only when agent identity has been established via signed-request derivation per [Agent identity](/dist/docs/3.1.20/building/by-layer/L1/security#agent-identity) or via a credential-to-agent mapping in the seller's onboarding record. In all other cases — including bearer credentials not mapped to a specific agent record — sellers MUST return `BILLING_NOT_SUPPORTED`, and `error.details.scope` MUST be omitted on this path so a `"account"` scope hint cannot itself act as a per-account-relationship oracle. * **`BILLING_NOT_PERMITTED_FOR_AGENT` details shape is clamped.** `error.details` MUST conform to [`error-details/billing-not-permitted-for-agent.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/billing-not-permitted-for-agent.json): `rejected_billing` (echoed) plus an optional single `suggested_billing` retry value. The schema sets `additionalProperties: false`. The shape MUST NOT carry the agent's full permitted-billing subset, rate cards, payment terms, credit limit, billing entity, or any other per-agent commercial state — full-subset disclosure in a single probe is exactly the oracle the clamp prevents. * **One-shot retry.** A buyer agent that retries with `error.details.suggested_billing` and receives a second `BILLING_NOT_PERMITTED_FOR_AGENT` MUST surface to a human rather than retrying again. Recovery is bounded to a single seller-suggested fallback; further iteration indicates seller misconfiguration or onboarding state the agent cannot resolve autonomously. **Recovery dispatch — example.** The two billing codes recover differently. Implementers SHOULD branch explicitly rather than collapsing to a single retry path. The snippet below uses the response shape returned directly by the seller (the `accounts[].errors[]` array on a `sync_accounts` response — see [task reference](/dist/docs/3.1.20/accounts/tasks/sync_accounts)), not the `@adcp/sdk/testing` wrapper used elsewhere in task examples. ```javascript theme={null} async function syncAccountsWithRecovery(client, account) { const result = await client.syncAccounts({ accounts: [account] }); const error = result.accounts[0]?.errors?.[0]; if (!error) return result; switch (error.code) { case 'BILLING_NOT_SUPPORTED': { // Seller-wide or per-account gate. Check capabilities, dispatch on scope. const scope = error.details?.scope; // "capability" | "account" if (scope === 'capability') { // The seller never accepts this value. Pick from supported_billing. const supported = error.details?.supported_billing ?? []; if (supported.length === 0) return surfaceToHuman(error); return client.syncAccounts({ accounts: [{ ...account, billing: supported[0] }], }); } // Per-account-relationship reject — the operator-on-this-account isn't // billable directly. Try the next-most-permissive value the seller's // capability allows. return tryNextBillingValue(client, account, error); } case 'BILLING_NOT_PERMITTED_FOR_AGENT': { // Per-buyer-agent commercial gate. Autonomous retry only when the seller // suggests a fallback; otherwise surface — the agent cannot extend its // own commercial relationship. const suggested = error.details?.suggested_billing; if (!suggested) return surfaceToHuman(error); return client.syncAccounts({ accounts: [{ ...account, billing: suggested }], }); } default: throw error; } } ``` **Example envelope** for `BILLING_NOT_PERMITTED_FOR_AGENT` — passthrough-only buyer agent receives a fallback to `operator`: ```json theme={null} { "accounts": [{ "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "operator": "pinnacle-media.com", "action": "failed", "status": "rejected", "errors": [{ "code": "BILLING_NOT_PERMITTED_FOR_AGENT", "message": "This buyer agent is onboarded as passthrough-only; only operator billing is permitted.", "recovery": "correctable", "details": { "rejected_billing": "agent", "suggested_billing": "operator" } }] }] } ``` ### Authorization (RBAC) Returned when the caller is authenticated but lacks the specific scope for the request. Enforcement is seller-local; discoverability is via the `authorization` object on per-account entries in [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) responses. See [Caller authorization](/dist/docs/3.1.20/accounts/overview#caller-authorization) for the full shape. | Code | Recovery | Description | Resolution | | --------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PERMISSION_DENIED` | correctable | Generic authorization failure, or a required signed credential (e.g., `governance_context`) is missing, failed verification, or was issued for a different plan/seller/phase | Call `check_governance` to mint a valid token, or contact the seller to resolve the underlying permission | | `SCOPE_INSUFFICIENT` | correctable | The invoked task is not in the caller's `allowed_tasks` for this account | Re-read `authorization` on the account via `sync_accounts` or `list_accounts` to discover the caller's actual `allowed_tasks`; use a permitted task or request a broader scope | | `READ_ONLY_SCOPE` | correctable | Caller's scope is `read_only: true`; the invoked task would mutate state | Use a non-mutating alternative, or request a scope that permits mutation | | `FIELD_NOT_PERMITTED` | correctable | A request field is not in the caller's `field_scopes` allowlist for this task | Drop the disallowed field, or request broader field scope | | `AGENT_SUSPENDED` | terminal | Calling buyer agent's commercial relationship with this seller is temporarily paused | Surface to a human at the buyer; re-onboarding with the seller offline may resolve. The agent cannot unilaterally lift a suspension. | | `AGENT_BLOCKED` | terminal | Calling buyer agent's commercial relationship with this seller is permanently denied | Surface to a human at the buyer; relationships are reinstated only through offline operator action with the seller. | Normative requirements: * **`FIELD_NOT_PERMITTED` MUST populate `error.field`** with the exact offending field path (e.g., `packages[0].budget`, `end_time`). Without it, agents cannot reliably auto-recover by stripping the field and retrying. When multiple fields are disallowed, sellers SHOULD return one error per offending field so each `error.field` is unambiguous; if returning a single error, `error.details.fields` MAY contain the full list. * **`SCOPE_INSUFFICIENT` SHOULD include `error.details.introspection_hint`** when the seller supports the `authorization` object on sync/list. Strawman shape: `{ "task": "sync_accounts", "account": { ... } }` pointing the caller at where to rediscover scope. This closes the "I hit an error; what do I do?" loop for coding agents. * **All four codes MUST populate both the `adcp_error` envelope field and the payload `errors[]` array** per the two-layer model above — scope errors carry through to typed client libraries the same way schema errors do. `SCOPE_INSUFFICIENT` vs. `PERMISSION_DENIED`: use `SCOPE_INSUFFICIENT` when the task itself is not granted to this caller on this account. Reserve `PERMISSION_DENIED` for credential-shaped failures (missing signed context, failed signature verification) and for generic seller policy rejections where scope is not the right abstraction. `SCOPE_INSUFFICIENT` vs. `UNSUPPORTED_FEATURE`: when both apply — the seller doesn't implement the task AND the caller wouldn't be scoped for it anyway — sellers SHOULD return `SCOPE_INSUFFICIENT` because it is more actionable (the caller can request broader scope) than `UNSUPPORTED_FEATURE` (the caller must switch sellers). A seller that returns `UNSUPPORTED_FEATURE` for a task it *does* implement but is not exposed to this caller is leaking capability information the caller cannot act on. `FIELD_NOT_PERMITTED` vs. `VALIDATION_ERROR`: the field is valid per the task schema and would be accepted from a differently-scoped caller; it is rejected specifically because this caller's `field_scopes` does not include it. **About `recovery: correctable` on authorization errors.** All four authz codes classify as `correctable`, meaning *the request can be fixed and re-sent*. This does NOT mean the agent can fix the request autonomously — the "correction" for `SCOPE_INSUFFICIENT` and `READ_ONLY_SCOPE` is an out-of-band scope grant from the operator, which the agent cannot perform on its own. Agents SHOULD surface authz errors to the operator rather than auto-retrying against the same credential; a retry loop against a fixed scope is a bug, not defensive posture — with the narrow exception of the bounded disambiguation retries described below. Only `FIELD_NOT_PERMITTED` has an agent-autonomous recovery path (strip the disallowed field and resubmit). **Retry disambiguation for `SCOPE_INSUFFICIENT` and `READ_ONLY_SCOPE`.** A single response with either code inside the seller's 300s authorization refresh window is observationally indistinguishable from cross-replica flicker — a transient infrastructure artifact that the spec forbids sellers from producing but buyers will still encounter in practice. Before classifying the error as a definitive `correctable` signal requiring operator intervention, buyers MAY exhaust a bounded retry budget (no more than 3 attempts, each separated by 1–5 seconds of jittered backoff) to establish whether the scope is genuinely insufficient. This is disambiguation logic — not recovery from a correctable error. After bounded retries exhaust without success, buyers MUST surface the error and MUST NOT autonomously continue retrying. This retry exception does NOT apply to `FIELD_NOT_PERMITTED` — the agent-autonomous strip-and-resubmit path supersedes it. See [Buyer response to SCOPE\_INSUFFICIENT within the refresh window](/dist/docs/3.1.20/accounts/overview#buyer-response-to-scope_insufficient-within-the-refresh-window) for the full guidance including the `READ_ONLY_SCOPE` caveat on revocation scenarios. **`FIELD_NOT_PERMITTED` — example envelope and payload populating both layers:** ```json theme={null} { "adcp_error": { "code": "FIELD_NOT_PERMITTED", "message": "Caller's field_scopes for update_media_buy does not include this field", "recovery": "correctable", "field": "packages[0].budget" }, "payload": { "errors": [ { "code": "FIELD_NOT_PERMITTED", "message": "Caller's field_scopes for update_media_buy does not include this field", "recovery": "correctable", "field": "packages[0].budget", "details": { "task": "update_media_buy", "permitted_fields": ["reporting_webhook"] } } ] } } ``` The `field` value identifies exactly what to strip; `details.permitted_fields` (optional, advisory) lists the allowlist for the offending task so an agent can verify before retrying. For `SCOPE_INSUFFICIENT`, populate `details.introspection_hint: { "task": "list_accounts", "account": { ... } }` to point the caller at where to rediscover scope. #### Per-Agent Authorization Gate The per-buyer-agent gate fires across three distinct rejection paths, each with its own discriminator so callers can dispatch without parsing prose: | Code | `details.scope` | `details.reason` | Meaning | | ------------------- | ----------------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AGENT_SUSPENDED` | — (no `details.scope`; the code is the discriminator) | — | Agent's commercial relationship with the seller is temporarily paused. Re-onboarding may resolve; `recovery: "terminal"`. | | `AGENT_BLOCKED` | — (no `details.scope`; the code is the discriminator) | — | Agent's commercial relationship is permanently denied. No autonomous recovery; `recovery: "terminal"`. | | `PERMISSION_DENIED` | `"agent"` | `"sandbox_only"` | Agent is provisioned for sandbox traffic only and the request was against a non-sandbox account. Per [`error-details/agent-permission-denied.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/agent-permission-denied.json), `additionalProperties: false`. | Per-agent commercial-status rejections (`AGENT_SUSPENDED`, `AGENT_BLOCKED`) follow the [`BILLING_NOT_PERMITTED_FOR_AGENT`](#billing-and-account-setup) precedent — the code itself is the discriminator and the response carries no explicit `error.details.scope` field. The `PERMISSION_DENIED + scope:"agent"` path is reserved for non-status provisioning gates whose `reason` is registered in the closed enum on `error-details/agent-permission-denied.json`. The `"agent"` value is a registered subset of the shared discriminator vocabulary in [`enums/error-scope.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/error-scope.json). **When to mint a new code vs. extend the `reason` enum.** Lifecycle-terminal per-agent states (suspended, blocked, future deny-list-style states) get dedicated codes — they carry distinct `recovery` classifications and warrant first-class discriminators. Non-status provisioning gates and transient rejections extend the `error-details/agent-permission-denied.json` `reason` enum (e.g., `sandbox_only`) instead; throttling-shaped rejections reuse `RATE_LIMITED`, not a new per-agent code. The dedicated-code-per-state pattern composes only because the lifecycle vocabulary is finite — not as an open registry for every new gate. **Migration note from the 3.0.5 placeholder.** 3.0.5 shipped `agent-permission-denied.json` with a `details.status: ["suspended", "blocked"]` axis as a placeholder for the per-agent lifecycle. 3.1 consolidates that placeholder into the dedicated `AGENT_SUSPENDED` / `AGENT_BLOCKED` codes — the `status` axis is removed from `agent-permission-denied.json` and the schema accepts only `scope:"agent" + reason:"sandbox_only"`. Sellers that integrated against the 3.0.5 placeholder MUST migrate to the dedicated codes; the placeholder shape is not preserved. Normative requirements (apply to all three codes uniformly): * **Uniform response without established agent identity.** Per-agent rejections are meaningful only when buyer-agent identity has been established via signed-request derivation per [Agent identity](/dist/docs/3.1.20/building/by-layer/L1/security#agent-identity) or via a credential-to-agent mapping in the seller's onboarding record. Sellers MUST emit `AGENT_SUSPENDED` / `AGENT_BLOCKED` or `PERMISSION_DENIED` with `details.scope: "agent"` ONLY on that path; in all other cases — including bearer credentials not mapped to a specific agent record — sellers MUST return generic `PERMISSION_DENIED` and MUST omit `error.details.scope`. Returning the per-agent code (or per-agent scope) without established identity lets unauthenticated probes use the code-choice as a cross-tenant onboarding oracle, the same shape the [`*_NOT_FOUND` uniform-response rule](#standard-error-codes) and [`BILLING_NOT_PERMITTED_FOR_AGENT`](#billing-and-account-setup) close. * **Channels covered by the omit-on-unestablished-identity rule.** The MUST applies across every observable channel — `error.code` / `error.message` / `error.field` / `error.details` (message MUST be generic on the unestablished-identity path); HTTP status, A2A `task.status.state`, and MCP `isError`; response headers (ETag, Cache-Control, per-agent rate-limit buckets, CDN tags); side effects (audit-log writes, webhook dispatch, background-job enqueues, per-agent quota counters, DB-shard routing, onboarding-record cache population); observability (logs, APM spans, Sentry/Datadog/Rollbar tags MUST NOT carry agent identity, agent-record references, or per-agent gate classification on the unestablished-identity path). Polymorphism is evaluated against the tool-schema's declared parameter shape *before* any onboarding-record lookup — a tool's declared shape MUST be identical across all callers regardless of whether identity has been established. * **Latency parity.** Sellers MUST execute the same shape of onboarding-record lookup on both paths (e.g., resolve-then-authorize against an empty agent-record on the unmapped-credential path) so that lookup latency does not distinguish "credential is not mapped to an agent" from "credential maps to a suspended/blocked agent." Resolve-then-authorize, decision-of-equivalent-shape, the same posture the `*_NOT_FOUND` rule requires. Cache population MUST NOT be gated on identity establishment — the cache itself is otherwise an oracle through hit/miss timing. * **Retry-counter side channel.** The "no autonomous retry" rule (below) MUST NOT itself become a side channel. Sellers MUST NOT increment a per-agent retry/backoff counter, mint a different `retry_after`, or surface different rate-limit headers on the unestablished-identity path than they would on a true-unauthenticated path. Buyer agents that comply with the no-retry rule MUST NOT have that compliance reflected in seller-side per-agent observability that another tenant can read. * **No per-agent commercial state on any of the three paths.** None of `AGENT_SUSPENDED`, `AGENT_BLOCKED`, or `PERMISSION_DENIED + scope:"agent"` carry the agent's full permitted-billing subset, rate cards, payment terms, credit limit, billing entity, contact channels, custom reason strings, or any other per-agent commercial state — full-subset disclosure in a single probe is exactly the oracle the clamp prevents. `AGENT_SUSPENDED` / `AGENT_BLOCKED` carry no `error.details` payload; `PERMISSION_DENIED + scope:"agent"` MUST conform to `error-details/agent-permission-denied.json` (`scope` + registered `reason`, with `additionalProperties: false`). New `reason` values MUST be added to the schema's enum so cross-language SDKs can dispatch without parsing prose; future per-agent state surfaces (escalation channels, lift-policy URLs) belong on new dedicated codes with their own clamped details shapes, NOT by relaxing this shape. The schema's `additionalProperties: false` is a schema-validation guard; sellers MUST treat unrecognized or extension keys received from a seller-side composer as a defect and drop them before transmission. * **No autonomous retry on the per-agent gate.** All three codes are terminal in practice: `AGENT_SUSPENDED` and `AGENT_BLOCKED` declare it directly via `recovery: "terminal"` in `enumMetadata`; the `PERMISSION_DENIED + scope:"agent"` path is `correctable` at the wire level (matching the registered `PERMISSION_DENIED` classification) but is terminal-pending-onboarding in practice — the agent cannot unilaterally lift a sandbox-only provisioning. Buyer agents MUST surface to a human at the buyer rather than auto-retrying on any of the three; re-attempts only reinforce the gate. **Recovery dispatch — example.** Branch on `error.code` first; only fall through to `details.scope` for the `PERMISSION_DENIED` per-agent gate: ```javascript theme={null} async function dispatchAuthzError(error) { // Per-agent commercial status — code is the discriminator, no details payload. if (error.code === 'AGENT_SUSPENDED' || error.code === 'AGENT_BLOCKED') { return surfaceToHuman({ code: error.code }); } if (error.code !== 'PERMISSION_DENIED') throw error; // Generic credential-shaped failure — no scope on details. if (!error.details?.scope) { return refreshGovernanceContextAndRetry(error); } // Per-agent provisioning gate — terminal-pending-onboarding. if (error.details?.scope === 'agent') { const { reason } = error.details; // reason: 'sandbox_only' return surfaceToHuman({ code: error.code, reason }); } throw error; // Unknown scope — surface rather than guess. } ``` **Example envelope** for `AGENT_SUSPENDED`: ```json theme={null} { "adcp_error": { "code": "AGENT_SUSPENDED", "message": "Buyer agent's commercial relationship with this seller is suspended.", "recovery": "terminal" } } ``` **Example envelope** for `PERMISSION_DENIED` with the sandbox-only provisioning gate: ```json theme={null} { "adcp_error": { "code": "PERMISSION_DENIED", "message": "This buyer agent is provisioned for sandbox traffic only.", "recovery": "correctable", "details": { "scope": "agent", "reason": "sandbox_only" } } } ``` The wire-level `recovery: "correctable"` on the sandbox-only path is the registered classification on `PERMISSION_DENIED` per [`error-code.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/error-code.json) `enumMetadata` — SDKs MUST NOT switch the registered value based on `details.scope`. Buyer agents MUST treat the rejection as terminal-pending-onboarding regardless of the wire-level `recovery` field, surface to a human, and not auto-retry. (For the suspended/blocked paths, the code itself carries `recovery: "terminal"` directly, so this caveat does not apply.) ### Request Validation | Code | Recovery | Description | Resolution | | ------------------------ | ----------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | `INVALID_REQUEST` | correctable | Request is malformed or violates schema constraints | Check request parameters and fix | | `UNSUPPORTED_FEATURE` | correctable | Requested feature not supported by this seller | Check `get_adcp_capabilities` and remove unsupported fields | | `POLICY_VIOLATION` | correctable | Request violates content or advertising policies | Review policy requirements in the error details | | `COMPLIANCE_UNSATISFIED` | correctable | Required disclosure cannot be satisfied by the target format | Choose a format that supports the required disclosure capabilities | | `GOVERNANCE_DENIED` | correctable | A registered governance agent denied the transaction | Restructure the buy, escalate to human spending authority, or contact the governance agent | ### Inventory and Products | Code | Recovery | Description | Resolution | | ---------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PRODUCT_NOT_FOUND` | correctable | Referenced product IDs are unknown or expired | Remove invalid IDs, or re-discover with `get_products` | | `PRODUCT_UNAVAILABLE` | correctable | Product is sold out or no longer available | Choose a different product | | `PROPOSAL_EXPIRED` | correctable | Referenced proposal has passed its `expires_at` | Run `get_products` to get a fresh proposal | | `PROPOSAL_NOT_FOUND` | correctable | `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` with `buying_mode: "refine"` + `action: "finalize"` to obtain a current proposal\_id | | `MULTI_FINALIZE_UNSUPPORTED` | correctable | `refine[]` carried multiple `action: "finalize"` entries; seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls (one finalize per `get_products` call) | | `REQUOTE_REQUIRED` | correctable | Requested update falls outside the envelope (budget, dates, volume, targeting) the original quote was priced against; `pricing_option` remains locked | Adjust the update to fit the current quote, rediscover products/terms, add packages when available, or create a separate media buy. 3.1 does not define an amendment-quote artifact for `update_media_buy`. | | `SIGNAL_NOT_FOUND` | correctable | Referenced signal does not exist in the catalog | Verify `signal_id` via `get_signals`, or confirm availability from this agent | | `AUDIENCE_TOO_SMALL` | correctable | Audience segment below minimum size | Broaden targeting or upload more audience members | ### Budget and Creative | Code | Recovery | Description | Resolution | | -------------------- | ----------- | ------------------------------------------------- | ------------------------------------------------------------------------------ | | `BUDGET_TOO_LOW` | correctable | Budget below seller's minimum | Increase budget or check `capabilities.media_buy.limits` | | `BUDGET_EXHAUSTED` | terminal | Account or campaign budget fully spent | Buyer must add funds or increase budget cap | | `CREATIVE_NOT_FOUND` | correctable | Referenced creative does not exist in the library | Verify `creative_id` via `list_creatives`, or register it via `sync_creatives` | | `CREATIVE_REJECTED` | correctable | Creative failed content policy review | Revise per seller's `advertising_policies` | ### System | Code | Recovery | Description | Resolution | | --------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `RATE_LIMITED` | transient | Request rate exceeded | Wait for `retry_after` seconds, then retry | | `SERVICE_UNAVAILABLE` | transient | Seller service temporarily unavailable | Retry with exponential backoff | | `STALE_RESPONSE` | transient (advisory) | Non-fatal: seller served a populated payload from cache past its freshness target because an upstream/sub-agent was unreachable. Distinct from `SERVICE_UNAVAILABLE` (empty payload + fatal). `error.details` follows [`error-details/stale-response.json`](pathname:///schemas/v3/error-details/stale-response.json) | Accept the cached payload, or retry later for fresh data — inspect `error.details.cache_age_seconds` to decide | | `CONFIGURATION_ERROR` | terminal | Seller-side deployment misconfiguration (e.g., missing `mock_upstream_url`, undeclared `upstream_url`, unset env var). Distinct from `SERVICE_UNAVAILABLE` (transient) and `INVALID_REQUEST` (buyer-fixable). Sellers MUST flip transport failure markers (HTTP 5xx, MCP `isError: true`, A2A `failed`); `error.message` carries operator-actionable detail and MUST NOT include credentials, connection strings, or stack traces | Surface to the seller's operator; do not auto-retry — retries will not resolve a misconfigured deployment | | `CONFLICT` | transient | Concurrent modification detected | Re-read the resource and retry with current state | | `REFERENCE_NOT_FOUND` | correctable | Generic fallback for referenced resources without a dedicated not-found code. See [Not-found precedence](#standard-error-codes) | Verify the identifier via the appropriate discovery task; prefer a resource-specific code when one exists | ## Recovery Classification Use the `recovery` field to determine how to handle errors: | Recovery | Meaning | Action | | ------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------- | | `transient` | Temporary failure (rate limit, service unavailable, conflict) | Retry after `retry_after` or with exponential backoff | | `correctable` | Request can be fixed and resent (invalid field, budget too low, creative rejected) | Modify the request and retry | | `terminal` | Requires human action (account suspended, payment required) | Escalate to a human operator | For unknown `recovery` values (forward compatibility), treat as `terminal`. ```javascript theme={null} function isRetryable(error) { // Use recovery field when available if (error.recovery) { return error.recovery === 'transient'; } // Network errors are retryable if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { return true; } // Fall back to error code matching return ['RATE_LIMITED', 'SERVICE_UNAVAILABLE', 'CONFLICT'].includes(error.code); } ``` ## Retry Logic The rules in this section bound every `transient`-classified error a caller may retry, including the `transient` default applied to unknown error codes under [§ Forward-compatible decoding](#forward-compatible-decoding-normative). A receiver that decodes an unknown code and falls back to `transient` MUST apply `maxRetries` and the jittered exponential-backoff schedule below; the open-enum decoding rule does not exempt receivers from the retry budget. A hostile or buggy sender emitting `code=GO_FOREVER, recovery=transient` cannot induce unbounded retries against a conformant client. ### Normative throttling behavior These rules apply when a caller receives a throttling-category error (`RATE_LIMITED`, or any error whose `recovery` is `transient` and whose `details` conform to the [`rate-limited`](https://adcontextprotocol.org/schemas/3.1.20/error-details/rate-limited.json) detail shape): * Callers **MUST** honor `retry_after` when present and **MUST NOT** retry the same request sooner than the indicated number of seconds. * Callers **SHOULD** use exponential backoff with jitter when `retry_after` is absent. A base of 2 seconds, a cap of 60 seconds, and ±25% jitter is a safe default. * Callers **MUST NOT** treat non-throttling errors (e.g., `INVALID_REQUEST`, `CREATIVE_REJECTED`) as if they were throttled. Retrying a rejected-for-other-reasons response at backoff cadence is a bug, not a defensive posture. * Callers **SHOULD** surface repeated throttling to their operator rather than retrying indefinitely; a persistent `RATE_LIMITED` response is a capacity or policy signal, not a transient blip. * Sellers **SHOULD** return `RATE_LIMITED` with a populated `retry_after` rather than silently queuing or dropping requests, so well-behaved callers can back off intentionally. * Sellers **MAY** populate the [`rate-limited`](https://adcontextprotocol.org/schemas/3.1.20/error-details/rate-limited.json) detail shape (`limit`, `remaining`, `window_seconds`, `scope`) to let callers plan ahead rather than react per-429. ### Exponential Backoff Implement exponential backoff for retryable errors: ```javascript theme={null} async function retryWithBackoff(fn, options = {}) { const { maxRetries = 3, baseDelay = 1000, maxDelay = 60000 } = options; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { if (!isRetryable(error) || attempt === maxRetries) { throw error; } // Use retry_after when available, otherwise exponential backoff const retryAfter = error.retry_after || Math.min(baseDelay * Math.pow(2, attempt), maxDelay); // Add jitter to prevent thundering herd const jitter = retryAfter * (0.75 + Math.random() * 0.5); await sleep(jitter); } } } ``` ### Rate Limit Handling ```javascript theme={null} async function handleRateLimit(error, retryFn) { if (error.recovery !== 'transient' && error.code !== 'RATE_LIMITED') { throw error; } const retryAfter = error.retry_after || 60; console.log(`Rate limited. Waiting ${retryAfter} seconds...`); await sleep(retryAfter * 1000); return retryFn(); } ``` ## Error Handling Patterns ### Basic Error Handler ```javascript theme={null} async function handleAdcpError(error) { // Use recovery classification when available switch (error.recovery) { case 'transient': const delay = error.retry_after ? error.retry_after * 1000 : 5000; await sleep(delay); return retry(); case 'correctable': // Surface suggestion so the request can be fixed if (error.suggestion) { console.log('Suggestion:', error.suggestion); } if (error.field) { console.log('Problem field:', error.field); } throw error; case 'terminal': console.error('Terminal error:', error.message); throw error; } // Fall back to error code matching switch (error.code) { case 'AUTH_REQUIRED': { // Two sub-cases share this code; see the AUTH_REQUIRED warning above. const requestHadCredentials = Boolean(error.request_had_credentials); if (!requestHadCredentials) { await refreshCredentials(); return retry(); } // Credentials were presented and rejected — surface to operator. throw error; } case 'INVALID_REQUEST': console.error('Validation error:', error); throw error; default: console.error('AdCP error:', error); throw error; } } ``` ### User-Friendly Messages Convert technical errors to user-friendly messages: ```javascript theme={null} const USER_MESSAGES = { 'RATE_LIMITED': 'Too many requests. Please wait a moment and try again.', 'BUDGET_TOO_LOW': 'This is below the seller\'s minimum budget. Increase your budget.', 'PRODUCT_NOT_FOUND': 'One or more products could not be found. Try searching again.', 'ACCOUNT_SUSPENDED': 'Your account has been suspended. Contact the seller to resolve.', 'SERVICE_UNAVAILABLE': 'The service is temporarily unavailable. Please try again in a few minutes.', 'CREATIVE_REJECTED': 'Your creative did not pass policy review. Check the suggestion for details.', 'AUDIENCE_TOO_SMALL': 'Your target audience is too small. Try broadening your targeting.' }; function getUserMessage(code, fallbackMessage) { return USER_MESSAGES[code] || fallbackMessage || 'An unexpected error occurred. Please try again.'; } ``` ### Structured Error Logging Log errors with context for debugging: ```javascript theme={null} function logError(error, context = {}) { console.error('AdCP Error:', { code: error.code, recovery: error.recovery, message: error.message, field: error.field, timestamp: new Date().toISOString(), ...context, // Don't log sensitive data // NO: credentials, briefs, PII }); } ``` ## Webhook Error Handling ### Failed Webhook Delivery When webhook delivery fails, fall back to polling: ```javascript theme={null} class WebhookErrorHandler { async onDeliveryFailure(taskId, error) { console.warn(`Webhook delivery failed for ${taskId}:`, error); // Start polling as fallback this.startPolling(taskId); // Track failure for monitoring this.metrics.incrementCounter('webhook_failures'); } async startPolling(taskId) { const response = await adcp.call('tasks/get', { task_id: taskId, include_result: true }); if (['completed', 'failed', 'canceled'].includes(response.status)) { await this.processResult(taskId, response); } else { // Schedule next poll setTimeout(() => this.startPolling(taskId), 30000); } } } ``` ### Webhook Handler Errors Handle errors in your webhook endpoint gracefully: ```javascript theme={null} app.post('/webhooks/adcp', async (req, res) => { try { // Always respond quickly res.status(200).json({ status: 'received' }); // Process asynchronously await processWebhookAsync(req.body); } catch (error) { // Log error but don't fail the response console.error('Webhook processing error:', error); // Move to dead letter queue for investigation await deadLetterQueue.add(req.body, error); } }); ``` ## Recovery Strategies ### Context Recovery If context expires, start a new conversation: ```javascript theme={null} async function callWithContextRecovery(request) { try { return await adcp.call(request); } catch (error) { if (error.code === 'INVALID_REQUEST' && error.message?.includes('context not found')) { // Clear stale context and retry delete request.context_id; return await adcp.call(request); } throw error; } } ``` ### Partial Success Handling Some operations may partially succeed: ```json theme={null} { "status": "completed", "message": "Created media buy with warnings", "media_buy_id": "mb_123", "errors": [ { "code": "COMPLIANCE_UNSATISFIED", "message": "Required disclosure position not supported by one placement", "field": "packages[0].placements[2]", "suggestion": "Choose a format that supports the required disclosure positions" } ] } ``` Handle partial success: ```javascript theme={null} function handlePartialSuccess(response) { if (response.status === 'completed' && response.errors?.length) { // Show warnings to user for (const warning of response.errors) { showWarning(warning.message, warning.suggestion); } } // Continue with successful result return response; } ``` ## Governance Error Patterns [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) returns a `status` field rather than an error object. Governance results are not errors in the protocol sense — they are decisions. Handle them separately from AdCP task errors. | Governance status | Meaning | Action | | ----------------- | ------------------------- | -------------------------- | | `approved` | Plan passes governance | Proceed | | `conditions` | Approved with constraints | Apply conditions, re-check | | `denied` | Plan violates governance | Block the operation | If the governance agent needs human review internally (e.g., the action exceeds the agent's authority), `check_governance` behaves like any async task — it returns `submitted`/`working` status and eventually resolves to `approved` or `denied`. Handle this with the standard [async task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle), not special-case logic. Governance errors from the protocol layer (as opposed to governance decisions) use the standard error format. The most common: | Code | Recovery | When it occurs | | ----------------- | ----------- | ----------------------------------------------------- | | `PLAN_NOT_FOUND` | correctable | `sync_plans` was not called before `check_governance` | | `INVALID_REQUEST` | correctable | Missing required fields (e.g., `plan_id`, `caller`) | | `AUTH_REQUIRED` | correctable | Governance agent requires authentication | ## Configuration Error Patterns `CONFIGURATION_ERROR` signals a seller-side deployment defect — an account declared with `mode: 'mock'` but no `mock_upstream_url`, a platform on `mode: 'live'` or `mode: 'sandbox'` with no `upstream_url`, a required environment variable unset on the seller process. The buyer cannot fix it; retries cannot resolve it; an operator at the seller has to. The catalog has no generic `INTERNAL_ERROR` code by design, and `CONFIGURATION_ERROR` is deliberately narrower — it covers the actionable slice where the remediation is "report this to the seller's operator." Opaque crashes that don't fit that profile remain catalog-uncoded; sellers MAY return platform-specific codes, and buyers fall back to the `recovery` classification per the [forward-compatibility rule](#recovery-classification). ### Aggregate signal: per-request terminal, per-seller outage A single `CONFIGURATION_ERROR` is `terminal` for the request that received it — the buyer MUST surface to a human at the seller and MUST NOT auto-retry. Repeated `CONFIGURATION_ERROR` from the same seller in a short window is an operational signal of a different kind: a seller-side outage. Buyer-side dashboards and alerting SHOULD treat aggregate `CONFIGURATION_ERROR` rate per seller as an outage indicator (e.g., page on N occurrences in M minutes from a single seller), distinct from the per-request-terminal handling. This convergence matters because a buyer that buckets aggregate `CONFIGURATION_ERROR` with generic terminal errors loses the seller-isolated outage signal that motivated the code's existence. ### error.message: operator-actionable, not deployment-internal The code itself is the discriminator — `CONFIGURATION_ERROR` carries no `error.details` shape (the [minimal-disclosure precedent](#per-agent-authorization-gate) of `AGENT_SUSPENDED` / `AGENT_BLOCKED` applies). `error.message` carries the diagnostic, and sellers SHOULD calibrate it to a level useful to a seller-side operator without leaking deployment internals to the buyer. The message is wire-visible — it MUST NOT include credentials, connection strings, full file paths, or stack traces. Useful (operator can act, buyer learns nothing exploitable): ```json theme={null} { "code": "CONFIGURATION_ERROR", "message": "account is mode='mock' but no mock_upstream_url declared in metadata; populate it in the AccountStore", "recovery": "terminal" } ``` Not useful (the operator already knew there was a problem; the buyer learns where the seller's filesystem is): ```json theme={null} { "code": "CONFIGURATION_ERROR", "message": "configuration error", "recovery": "terminal" } ``` Leaks (don't): ```json theme={null} { "code": "CONFIGURATION_ERROR", "message": "ECONNREFUSED postgres://admin:hunter2@10.0.1.42:5432/prod (at /opt/seller/src/db/pool.ts:127)", "recovery": "terminal" } ``` ## Best Practices 1. **Check `recovery` first** — it's the most reliable signal for how to handle an error 2. **Implement retries** — use exponential backoff for transient errors 3. **Respect rate limits** — honor `retry_after` values 4. **Handle unknown codes gracefully** — fall back to `error.recovery`; default to `transient` when absent (see [Forward-compatible decoding](#forward-compatible-decoding-normative)) 5. **Log with context** — include `code`, `recovery`, and `field` for debugging 6. **Fallback strategies** — always have a backup (e.g., polling for webhooks) 7. **Don't retry terminal errors** — escalate to a human operator 8. **Handle partial success** — process warnings in successful responses ## Next Steps * **Transport Bindings**: See [Transport Errors](/dist/docs/3.1.20/building/operating/transport-errors) for how errors travel over MCP and A2A * **Task Lifecycle**: See [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for status handling * **Webhooks**: See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for webhook error handling * **Security**: See [Security](/dist/docs/3.1.20/building/by-layer/L1/security) for authentication errors # L3 — Protocol semantics Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L3/index Protocol-semantics layer of the AdCP stack. Lifecycle state machines, idempotency, error catalog, async-task contract, conformance test surface, webhook emission. Where most of an SDK's value lives. L3 enforces what AdCP *means* on the agent side. The wire shape is well-formed (L0); the caller is authentic (L1) and authorized (L2); now: is the request legal given the current state of the world? For agents, L3 is the bulk of the protocol surface — the [3–4 person-month from-scratch build](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#why-sdks-matter-more-in-adcp-than-in-eg-http) lives almost entirely here. For callers, L3 is consumer-side: weeks of handler glue, classifying error codes and handling state transitions rather than enforcing them. ## What an SDK at L3 must provide If you're picking an SDK or porting one to a new language, this is the L3 build target: * **Lifecycle state-machine graphs** for all spec-defined resources, with a transition-assertion primitive that emits the spec-correct error code (`NOT_CANCELLABLE` / `INVALID_STATE` / etc.). * **Idempotency cache** with cross-payload conflict detection and the no-payload-echo invariant on `IDEMPOTENCY_CONFLICT` envelopes. * **Async-task store + dispatcher** — tools opt into async; the SDK returns `task_id`, accepts polling, and emits the terminal artifact. * **Webhook emitter** — signed, retried, idempotent. * **The conformance test surface** (`comply_test_controller`), wired to drive state deterministically when the resolved account is in sandbox or mock mode (and rejected otherwise). * **Per-resource persistence primitives** that handle the spec's echo contracts. * **Server-construction entry point** that ties all of the above together with sane defaults. For the cumulative cross-layer story (what L0+L1+L2+L3 buys you), see the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#l3--protocol-semantics). For what changed at L3 between 2.5 and 3.0, see [What changed at L3 in 3.0](/dist/docs/3.1.20/building/cross-cutting/version-adaptation#what-changed-at-l3-in-3-0). ## Pages in this layer * **[Task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle)** — status values, transitions, polling. * **[Async operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations)** — sync, async, and interactive task handling. * **[Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks)** — push notifications, signing, retry, idempotency. * **[Error handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling)** — error categories, codes, recovery classification. * **[`comply_test_controller`](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller)** — sandbox-only conformance test surface. # Task Lifecycle Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle AdCP task lifecycle: status values (submitted, working, input-required, completed, failed), state transitions, response structure, and polling patterns for all operations. Every AdCP response includes a `status` field that tells you exactly what state the operation is in and what action you should take next. This is the foundation for handling any AdCP operation. :::note Application-layer task state The status values and lifecycle described here are transport-independent AdCP application state. MCP and A2A task mechanisms may wrap, stream, or deliver an AdCP response, but they do not replace AdCP's `task_id`, webhook payloads, or polling/reconciliation surfaces. For `submitted` operations, observe the AdCP task with [push notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks) or the AdCP polling surface. In 3.x, that polling surface is legacy `tasks/get`, with optional `get_task_status` when the seller advertises the alias. Transport-native MCP/A2A `tasks/*` methods use their own wire shapes and are separate from AdCP task polling. ::: ## Status Values AdCP uses the same status values as the [A2A protocol's TaskState enum](https://a2a-protocol.org/dev/specification/#63-taskstate-enum): | Status | Meaning | Your Action | | ---------------- | ------------------------------------------- | -------------------------------------------------------------------- | | `submitted` | Task queued, blocked on external dependency | Configure webhook, show "queued" indicator | | `working` | Agent actively processing (>30s) | Wait for result — out-of-band progress signal, not a polling trigger | | `input-required` | Needs information from you | Read `message` field, prompt user, send follow-up | | `completed` | Successfully finished | Process `data`, show success message | | `canceled` | User/system canceled task | Show cancellation notice, clean up | | `failed` | Error occurred | Show error from `message`, handle gracefully | | `rejected` | Agent rejected the request | Show rejection reason, don't retry | | `auth-required` | Authentication needed | Prompt for auth, retry with credentials | | `unknown` | Indeterminate state | Log for debugging, may need manual intervention | ## Response Structure Every AdCP response uses a **flat structure** where task-specific fields are at the top level: ```json theme={null} { "status": "completed", // Always present: what state we're in "message": "Found 5 products", // Always present: human explanation "context_id": "ctx-123", // Session continuity "context": { // Application-level context echoed back "ui": "buyer_dashboard" }, "products": [...] // Task-specific fields at top level } ``` :::warning Single status field required Agents MUST NOT emit the legacy `task_status` or `response_status` fields alongside `status`. The `status` field is the single authoritative task state. Agents emitting either alongside `status` are non-conformant. ::: ## Status Handling ### Basic Pattern ```javascript theme={null} function handleAdcpResponse(response) { switch (response.status) { case 'completed': // Success - process the data (task fields are at top level) showSuccess(response.message); return processData(response); case 'input-required': // Need more info - prompt user const userInput = await promptUser(response.message); return sendFollowUp(response.context_id, userInput); case 'working': // Server is actively processing — just wait, result will arrive showProgress(response.message); return response; case 'failed': // Error - show message and handle gracefully showError(response.message); return handleError(response.errors); case 'auth-required': // Authentication needed const credentials = await getAuth(); return retryWithAuth(credentials); default: // Unexpected status console.warn('Unknown status:', response.status); showMessage(response.message); } } ``` ### Clarification Flow When status is `input-required`, the message tells you what's needed: ```json theme={null} { "status": "input-required", "message": "I need more information about your campaign. What's your budget and target audience?", "context_id": "ctx-123", "products": [], "suggestions": ["budget", "audience", "timing"] } ``` **Client handling:** ```javascript theme={null} if (response.status === 'input-required') { // Extract what's needed from the message const missingInfo = extractRequirements(response.message); // Prompt user with specific questions const answers = await promptForInfo(missingInfo); // Send follow-up with same context_id return sendMessage(response.context_id, answers); } ``` ### Approval Flow Human approval at the task layer is modelled as `input-required` (when the buyer must respond, e.g. confirm a budget) or `submitted` (when the seller is waiting on an internal human, e.g. IO signing). These implement the [Embedded Human Judgment](/dist/docs/3.1.20/governance/embedded-human-judgment) principle that judgment cannot be delegated to software — when an action exceeds autonomous authority, the system halts for human review rather than proceeding. > `pending_approval` is an Account status, not a task status and not a MediaBuy status. It indicates the seller is reviewing an account (credit, contracts) before it can be used. Don't reuse the name for task-level approval. ```json theme={null} { "status": "input-required", "message": "Media buy exceeds auto-approval limit ($100K). Please approve to proceed with campaign creation.", "context_id": "ctx-123", "approval_required": true, "amount": 150000, "reason": "exceeds_limit" } ``` **Client handling:** ```javascript theme={null} if (response.status === 'input-required' && response.approval_required) { // Show approval UI const approved = await showApprovalDialog(response.message, response); // Send approval decision const decision = approved ? "Approved" : "Rejected"; return sendMessage(response.context_id, decision); } ``` ### Operations Over 30 Seconds Operations that take longer than 30 seconds return either `working` or `submitted`. These statuses mean different things: * **`working`**: The server is actively processing and will deliver the result when ready. No polling needed — the server sends progress out-of-band and the result arrives on the open connection. * **`submitted`**: The operation is blocked on an external dependency (human approval, publisher review). Configure a webhook or poll. ```json theme={null} { "status": "submitted", "message": "Media buy submitted for publisher approval", "context_id": "ctx-123", "task_id": "task-456" } ``` **Handling for `submitted` operations:** * **All transports**: Use [push notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks) (recommended) or poll the AdCP task with `get_task_status` / legacy `tasks/get`. * **MCP/A2A transport tasks**: Treat native task state as transport state. A native task can finish once it has delivered an AdCP payload that still says `status: 'submitted'`. ## Status Progression Tasks progress through predictable states: ``` submitted → working → completed ↓ ↓ ↑ input-required → → → → → ↓ failed ``` * **`submitted`**: Task queued, blocked on external dependency — configure webhook or poll * **`working`**: Agent actively processing (>30s) — wait for result, no polling needed * **`input-required`**: Need user input, continue conversation * **`completed`**: Success, process results * **`failed`**: Error, handle appropriately ## Polling and Timeouts ### Polling is for `submitted` only Don't poll for `working` — the server delivers the result on the open connection. Polling is a backup for `submitted` operations (webhooks are preferred). Send `include_result: true` to receive the terminal task payload on the polled response once the task reaches `status: completed`. The `result` object on the response carries the same shape the original task would have returned synchronously — for example, polling a `create_media_buy` task returns `result: { media_buy_id, packages, status }`. For `failed` tasks, read the existing `error` field instead. Webhooks remain the supported delivery mechanism (see [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks)); `include_result` is the typed polling alternative for buyers that prefer pull over push. ```javascript theme={null} // Polling is only for 'submitted' operations async function pollForResult(taskId, pollInterval = 30_000) { while (true) { await sleep(pollInterval); const response = await adcp.call('get_task_status', { task_id: taskId, include_result: true }); if (['completed', 'failed', 'canceled'].includes(response.status)) { // response.result carries the terminal payload when status === 'completed' // response.error carries error details when status === 'failed' return response; } } } ``` ### Timeout Configuration ```javascript theme={null} const TIMEOUTS = { sync: 30_000, // 30 seconds — most operations complete here working: 300_000, // 5 minutes — connection timeout for active processing interactive: 300_000, // 5 minutes for human input submitted: 86_400_000 // 24 hours for external dependencies }; function getTimeout(status) { if (status === 'submitted') return TIMEOUTS.submitted; if (status === 'working') return TIMEOUTS.working; if (status === 'input-required') return TIMEOUTS.interactive; return TIMEOUTS.sync; } ``` ## Task Reconciliation Use `list_tasks` (or legacy `tasks/list` in 3.x) to recover from lost state: ```javascript theme={null} // Find all pending operations const pending = await session.call('list_tasks', { filters: { statuses: ["submitted", "working", "input-required"] } }); // Reconcile with local state const missingTasks = pending.tasks.filter(task => !localState.hasTask(task.task_id) ); // Resume tracking missing tasks for (const task of missingTasks) { startPolling(task.task_id); } ``` ## Best Practices 1. **Always check status first** - Don't assume success 2. **Handle all statuses** - Include a default case for unknown states 3. **Preserve context\_id** - Required for conversation continuity 4. **Use task\_id for tracking** - Especially for long-running operations 5. **Implement timeouts** - Don't wait forever 6. **Log status transitions** - Helps with debugging and auditing ## Next Steps * **Async Operations**: See [Async Operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations) for handling different operation types * **Webhooks**: See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for push notification patterns * **Error Handling**: See [Error Handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling) for error categories and recovery # Push Notifications Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L3/webhooks AdCP push notifications: how sellers deliver async task status updates to your webhook endpoint via RFC 9421–signed POST requests (with legacy HMAC fallback). Setup, URL templates, and idempotency. Push notifications let sellers deliver task status updates to you directly, instead of requiring you to poll. You provide a webhook URL in the task request; the seller POSTs status changes to that URL as the task progresses. ## How it works 1. A unique operation ID is generated per task invocation for webhook correlation 2. A webhook URL is built for your receiver. The URL may contain your own routing token, but it is opaque to the seller 3. `push_notification_config` is injected into the task request body with the URL and explicit `operation_id` — no shared secret required 4. The seller POSTs webhook notifications to your URL as the task status changes, signing each POST with its `adcp_use: "request-signing"` key published in its own brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window 5. You verify the signature against the seller's published JWKS and dedupe by `idempotency_key` 6. Each notification echoes the explicit `operation_id` back in the payload so you can correlate it without parsing the URL ``` create_media_buy request └── push_notification_config └── url: "https://you.com/adcp/webhook/create_media_buy/agent_123/route_abc123" └── operation_id: "op_456" // No shared secret — the seller signs with its own key, you verify against // its published JWKS. See "Signature verification" below. ↓ seller processes task ↓ POST https://you.com/adcp/webhook/create_media_buy/agent_123/route_abc123 Signature-Input: sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest"); created=1706097600;expires=1706097900;nonce="...";keyid="seller-webhook-2025"; alg="ed25519";tag="adcp/webhook-signing/v1" Signature: sig1=:: Content-Digest: sha-256=:: Content-Type: application/json { "idempotency_key": "whk_01HW9D3H8FZP2N6R8T0V4X6Z9B", ← dedup by this "task_id": "task_456", "operation_id": "op_456", ← echoed from push_notification_config.operation_id "status": "completed", "result": { ... } } ``` If you're using the `@adcp/sdk` library, this entire flow is handled automatically. As a **buyer**, configure `webhookUrlTemplate` and your agent URL on the client; `push_notification_config` is injected into every outgoing task call, and incoming webhooks are verified against the seller's JWKS automatically. As a **seller emitting webhooks**, publish a signing JWK at your brand.json `agents[]` entry. New signers use `adcp_use: "request-signing"`; if you want webhook-only key material, publish a second `request-signing` JWK with a distinct `kid`. :::warning Legacy HMAC fallback (deprecated) Buyers integrating with receivers that have not yet adopted the RFC 9421 webhook profile MAY opt into the legacy HMAC-SHA256 scheme by populating `push_notification_config.authentication.credentials`. That path is deprecated and removed in AdCP 4.0 — see [Legacy HMAC-SHA256 fallback](#legacy-hmac-sha256-fallback-deprecated) below. Because the inbound request that registers the webhook is typically not 9421-signed in 3.0, the `authentication` block is susceptible to on-path strip/inject — see [Downgrade and injection resistance](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-callbacks) for the operational mitigations. ::: ## Naming: snake\_case vs camelCase This trips people up. There are two naming conventions in play: | Context | Field name | Example | | ---------------------------------- | -------------------------- | ---------------------------------------------------------------------------- | | **MCP task arguments** (AdCP JSON) | `push_notification_config` | `{ push_notification_config: { url: ..., operation_id: ... } }` | | **A2A configuration object** | `pushNotificationConfig` | `configuration: { pushNotificationConfig: { url: ..., operation_id: ... } }` | The AdCP field name is always **`push_notification_config`** (snake\_case). It goes in the task request body alongside your other task parameters. For A2A, the A2A protocol wraps it in a `configuration` envelope using camelCase — but the object's contents are identical. ## Adding push\_notification\_config to a request ### MCP Include `push_notification_config` as a task argument, merged with the rest of your task parameters: ```json theme={null} { "brand": { "brand_id": "acme" }, "start_time": { "type": "date", "date": "2025-03-01" }, "end_time": "2025-06-30T23:59:59Z", "packages": [...], "push_notification_config": { "url": "https://you.com/webhooks/adcp/create_media_buy/route_abc123", "operation_id": "op_abc123" } } ``` `authentication` is omitted in the default case — the seller signs with its own `adcp_use: "request-signing"` key. Deprecated `webhook-signing` keys remain accepted during the compatibility window. Include `authentication.credentials` only if you need the legacy HMAC-SHA256 fallback. ### A2A For A2A, skill parameters stay in `message.parts[].data.parameters`. The push notification config goes in the top-level `configuration` object: ```json theme={null} { "message": { "parts": [{ "kind": "data", "data": { "skill": "create_media_buy", "parameters": { "packages": [...] } } }] }, "configuration": { "pushNotificationConfig": { "url": "https://you.com/webhooks/adcp/create_media_buy/route_abc123", "operation_id": "op_abc123" } } } ``` ## Operation IDs and URL templates Operation IDs let you correlate incoming webhooks to the right task invocation. The pattern: 1. Buyer generates a unique operation ID per task call 2. Buyer threads it through to the seller as `push_notification_config.operation_id`. The webhook URL structure is the buyer's choice and is **opaque to the seller** 3. Seller echoes `operation_id` verbatim in every webhook payload — no URL parsing needed **Normative wire contract:** * **Buyers SHOULD** supply `operation_id` to the seller for every webhook registration and generate a unique value per task invocation (UUID recommended). Sellers MAY reject a webhook registration that omits `operation_id` with `INVALID_REQUEST`. * **Sellers MUST** echo the buyer-supplied `operation_id` value in every webhook payload exactly as received. The payload field is the **only** source of truth for correlation. * **Sellers MUST NOT** derive `operation_id` by parsing `push_notification_config.url` — the URL structure (path template, query parameters, opaque token, etc.) is implementation-defined from the seller's point of view and cannot be reliably reversed across implementations. A buyer's URL convention is not part of the protocol. * **Receivers MAY** dispatch HTTP endpoints by URL path or query string, but **MUST NOT** use URL-derived values as the operation correlation key. The wire-level correlation identifier is the payload field. This matches the precedent set by every comparable async-notification protocol in ad tech (OpenRTB `nurl`/`burl`, VAST tracking pixels, A2A `PushNotificationConfig`): the entity firing the HTTP call never parses the receiver's URL for correlation data. **URL template pattern (buyer-side convention only):** ``` https://you.com/webhooks/{task_type}/{agent_id}/{route_token} ``` The template above is a useful **server-side routing aid for the buyer** — it lets a buyer's HTTP server dispatch on path segments without first parsing the body — but it is not normative and sellers cannot rely on it. A buyer who prefers `?route=…`, a flat path, or an entirely opaque token is fully conformant as long as the seller-side `operation_id` is supplied through the SDK's send-side API. **Example (client library handles this automatically):** ```typescript theme={null} import { randomUUID } from 'crypto'; const operationId = randomUUID(); // e.g. "cd51e063-2b79-4a6d-afac-ed7789c3a443" const routeId = randomUUID(); // receiver-local routing token; opaque to the seller const webhookUrl = `https://you.com/adcp/webhook/create_media_buy/${agentId}/${routeId}`; // pass both webhookUrl and operationId in push_notification_config ``` The seller's webhook payload will include `"operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443"`, so your handler can correlate to the right pending operation by reading the payload field directly. URL paths can select an HTTP handler, but not the protocol operation ID. **Seller-SDK implementations** surface `operation_id` as an explicit parameter on the send-side webhook API (e.g., Python `WebhookSender.send_mcp(url=…, operation_id=…)`). The seller's application code threads the value through from the original task request to the webhook fire; the SDK never attempts to recover it from the URL. ### Echoing the caller's `context` object When the originating request carried a top-level `context` object, the seller MUST echo that same object verbatim in every webhook payload for the same operation, alongside `operation_id`. This is the same contract that applies to synchronous and async-status responses — see [Context and sessions — Normative echo contract](/dist/docs/3.1.20/building/by-layer/L2/context-sessions#normative-echo-contract). The echo MUST carry through `working`, `input-required`, `completed`, `failed`, and `canceled` deliveries; dropping `context` between the initial response and a later webhook breaks buyer-side correlation exactly where it's needed most. Buyers routing by `context.trace_id` or `context.internal_campaign_id` rely on verbatim echo on every delivery. ## When webhooks fire Webhooks are sent for each status change after the initial response, as long as `push_notification_config` is in the request. If the task completes synchronously (initial response is already terminal, such as `completed`, `failed`, or `rejected`), no webhook is sent — you already have the result. Only operations whose initial response is non-terminal (`working` or `submitted`) may later emit AdCP task webhooks. For an inline terminal response, sellers MUST NOT synthesize a task webhook by inventing a `task_id` or replaying the inline result to `push_notification_config.url`; sync-only operations, and sync-only operation modes where `push_notification_config` is not meaningful, MAY reject that field as a well-formed runtime error instead. This is a seller-to-buyer wire rule: buyer SDKs MAY normalize synchronous responses into local callbacks, promises, or handler invocations, but those local SDK conveniences are not AdCP webhooks. If a future AdCP version adds a buyer-signaled sync-completion notification mode, it will be explicit and capability-advertised; the 3.x task webhook contract does not define one. **Status changes that trigger webhooks:** | Status | Meaning | | ---------------- | ---------------------------------------------- | | `working` | Task is processing — may include progress info | | `input-required` | Waiting for human approval or clarification | | `completed` | Final result available | | `failed` | Task failed with error details | | `canceled` | Task was canceled | ## Webhook payload formats ### MCP ```json theme={null} { "idempotency_key": "whk_01HW9D3H8FZP2N6R8T0V4X6Z9B", "task_id": "task_456", "operation_id": "cd51e063-2b79-4a6d-afac-ed7789c3a443", "task_type": "create_media_buy", "domain": "media-buy", "status": "completed", "timestamp": "2025-01-22T10:30:00Z", "message": "Media buy created successfully", "result": { "media_buy_id": "mb_12345", "packages": [ { "package_id": "pkg_001", "context": { "line_item": "li_ctv_sports" } } ] } } ``` Every webhook payload carries a required `idempotency_key` — a sender-generated key that is stable across retries of the same event. This is the canonical dedup field; see [Reliability](#reliability) below. ### Payload anatomy: envelope vs. result Webhook receivers must distinguish the **wire envelope** from the task-specific **result**. The full MCP webhook envelope is the JSON object sent as the HTTP POST body. Delivery-report content lives under `result`; it is not valid as the top-level POST body by itself. For a delivery reporting fire, the full wire payload looks like: ```json theme={null} { "idempotency_key": "whk_20260526_example_000031", "operation_id": "delivery_report_67_2026_04", "task_id": "delivery_report_67_2026_04_000031", "task_type": "media_buy_delivery", "status": "completed", "timestamp": "2026-05-26T09:00:44.582Z", "message": "Scheduled media buy delivery report available", "result": { "notification_type": "scheduled", "sequence_number": 31, "reporting_period": { "start": "2026-05-25T00:00:00Z", "end": "2026-05-25T23:59:00Z" }, "currency": "USD", "media_buy_deliveries": [ { "media_buy_id": "mb_001", "status": "active", "totals": { "impressions": 125000, "spend": 5625.0, "clicks": 250 }, "by_package": [] } ] } } ``` This inner result object is valid delivery-report content, but it is **not valid as the top-level webhook POST body**: ```json theme={null} { "notification_type": "scheduled", "sequence_number": 31, "reporting_period": { "start": "2026-05-25T00:00:00Z", "end": "2026-05-25T23:59:00Z" }, "currency": "USD", "media_buy_deliveries": [] } ``` The top-level `status` is the async webhook status (`completed`, `failed`, `working`, and so on). The nested `media_buy_deliveries[].status` is the media buy lifecycle or reporting state (`active`, `paused`, `reporting_delayed`, and so on). Do not collapse the two fields. For delivery-report data events such as `scheduled`, `final`, `delayed`, and `adjusted`, `notification_id` is absent by design; dedupe the transport event with `idempotency_key`. The `aggregated_totals` field is API-only for `get_media_buy_delivery` responses and must not be emitted in reporting webhook result payloads. Signatures and content digests are computed over the exact raw JSON bytes sent as this full envelope. Re-serializing just the `result` object, adding whitespace, or changing field order before verification changes the bytes and breaks signature validation. ### A2A A2A sends a `Task` object (for final states) or `TaskStatusUpdateEvent` (for progress). For final states (`completed`, `failed`), AdCP result data is in `.artifacts[0].parts[]`. For interim states (`working`, `input-required`), data is in `status.message.parts[]`. ```json theme={null} { "id": "task_456", "contextId": "ctx_123", "status": { "state": "completed", "timestamp": "2025-01-22T10:30:00Z" }, "artifacts": [{ "artifactId": "result", "parts": [ { "kind": "text", "text": "Media buy created successfully" }, { "kind": "data", "data": { "media_buy_id": "mb_12345", "packages": [ { "package_id": "pkg_001", "context": { "line_item": "li_ctv_sports" } } ] } } ] }] } ``` ### Protocol comparison | | MCP | A2A | | ------------------- | ----------------------------------------- | ------------------------------------------------------------------------------ | | **Config field** | `push_notification_config` (in task args) | `configuration.pushNotificationConfig` (separate from skill params) | | **Envelope** | `mcp-webhook-payload.json` | Native `Task` / `TaskStatusUpdateEvent` | | **Result location** | `result` field | `.artifacts[0].parts[].data` (final) / `status.message.parts[].data` (interim) | | **Data schemas** | Identical AdCP schemas | Identical AdCP schemas | ### Registration channel determines envelope shape Webhook envelope shape is determined by **which registration mechanism the buyer used**, not by which transport the sync request was sent over: | Registered via | Delivered envelope | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | AdCP `push_notification_config` (task argument, MCP/A2A/REST) | [`mcp-webhook-payload.json`](#mcp) | | A2A `TaskPushNotificationConfig` ([`CreateTaskPushNotificationConfig`](https://a2a-protocol.org/latest/specification/) RPC, or inline `task_push_notification_config` on `SendMessage`) | A2A native `Task` / `TaskStatusUpdateEvent` per A2A 1.0 §4.3.3 | The two channels are independent. A buyer MAY register both for the same task and receive both webhooks per status change. **Why this is the model, not "match inbound transport".** Each channel is purpose-built for its envelope: AdCP `push_notification_config` is the AdCP-layer registration for the AdCP `mcp-webhook-payload` shape; A2A `TaskPushNotificationConfig` is the A2A-layer registration for A2A's own `StreamResponse`-wrapped delivery. The buyer picks the channel that matches the receiver — there's no need for a discriminator field, and no ambiguity to override. **Typical case: A2A sync, AdCP-shape webhooks.** A buyer orchestrating from an MCP-native runtime that uses A2A for one specific high-throughput sync operation puts `push_notification_config` in the AdCP task arguments inside its `SendMessage` body. The seller honors it as an AdCP-shape registration, regardless of A2A being the sync transport. The buyer's receiver gets the same `mcp-webhook-payload` shape it gets from every other AdCP webhook in its pipeline. **A2A buyers wanting A2A-shape webhooks** register through A2A's native push notification mechanism; AdCP doesn't need to add anything for that case. ### Status-specific result data | Status | `result` / `data` contains | | ---------------------- | ----------------------------------------------------- | | `completed` / `failed` | Full task response | | `working` | Progress: `percentage`, `current_step`, `total_steps` | | `input-required` | Reason and any validation errors | | `submitted` | Minimal acknowledgment | ## Signature verification Every AdCP 3.0 webhook is signed under the [RFC 9421 webhook profile](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-callbacks). The seller signs with its `adcp_use: "request-signing"` key published in its own brand.json `agents[]` entry; deprecated `webhook-signing` keys remain accepted during the compatibility window. You verify against the seller's published JWKS. No shared secret crosses the wire. **Publisher sends three headers** (plus `Content-Type`): ``` Signature-Input: sig1=("@method" "@target-uri" "@authority" "content-type" "content-digest"); created=;expires=;nonce=; keyid=;alg="ed25519";tag="adcp/webhook-signing/v1" Signature: sig1=:: Content-Digest: sha-256=:: ``` Covered components are fixed: `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`. `content-digest` is REQUIRED — the body is the event; a signature that doesn't cover it isn't protecting the attack surface that matters. **Verification** follows the 14-step [request verifier checklist](/dist/docs/3.1.20/building/by-layer/L1/security#verifier-checklist-requests) with three webhook substitutions: * Error codes use the `webhook_signature_*` prefix (see [Webhook error taxonomy](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-error-taxonomy)). * `tag` MUST be `adcp/webhook-signing/v1`. * Resolve `keyid` via the seller operator's `brand.json` `agents[].jwks_uri`, applying any publisher `adagents.json` `signing_keys[]` pin when present (you already have the seller's agent URL from your integration). **Receiver implementation sketch:** ```typescript theme={null} import { createRemoteJWKSet, jwtVerify } from 'jose'; // Use a validated RFC 9421 library (e.g., `http-message-signatures`) pinned to the AdCP profile. app.post('/webhooks/adcp/*', async (req, res) => { try { // 1. Parse Signature-Input / Signature headers and reject on malformed. // 2. Resolve keyid against the seller operator's brand.json JWKS. // 3. Run the AdCP webhook verifier checklist (14 steps). await verifyAdcpWebhookSignature(req, { sellerAgentUrl: req.sellerContext.agentUrl, // known from your integration requiredTag: 'adcp/webhook-signing/v1', allowedAlgs: ['ed25519', 'ecdsa-p256-sha256'], }); } catch (err) { return res.status(401) .setHeader('WWW-Authenticate', `Signature error="${err.code}"`) .end(); } // 4. Dedup by idempotency_key before applying side effects (see Reliability below). processWebhook(req.body); res.status(200).end(); }); ``` :::caution Raw body and content-digest Your `Content-Digest` verification (step 11 of the checklist) requires the raw HTTP body bytes. Capture them before JSON parsing — any re-serialization will break the digest match. In Express: ```typescript theme={null} app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf.toString('utf-8'); }, })); ``` ::: :::note Replay protection The `created`/`expires`/`nonce` sig-params enforce a 5-minute max validity window and `(keyid, nonce)` replay dedup. See [Transport replay dedup](/dist/docs/3.1.20/building/by-layer/L1/security#transport-replay-dedup) for the per-keyid cap and memory-bounding rules. ::: ### Legacy HMAC-SHA256 fallback (deprecated) :::warning Deprecated — removed in AdCP 4.0 The HMAC-SHA256 scheme below is a compatibility affordance for 3.x only. New integrations SHOULD omit `push_notification_config.authentication` and use the [9421 webhook profile](#signature-verification) above. Sellers MAY decline to support the legacy scheme. ::: Buyers can opt into HMAC-SHA256 by populating `push_notification_config.authentication.credentials`. When present, the seller signs with HMAC-SHA256 using a shared secret and includes a timestamp for replay protection. **Configuration (legacy):** ```json theme={null} { "authentication": { "schemes": ["HMAC-SHA256"], "credentials": "your_shared_secret_min_32_chars" } } ``` **Publisher sends two headers (legacy):** ``` X-ADCP-Signature: sha256= X-ADCP-Timestamp: ``` **Signature algorithm (legacy):** The signed message is `{unix_timestamp}.{raw_json_body}` — the Unix timestamp (in seconds), a dot, then the exact JSON bytes being sent in the HTTP body. ``` Signature = sha256= + hex( HMAC-SHA256( secret, "{timestamp}.{rawBody}" ) ) ``` The `rawBody` **must** be the exact bytes sent on the wire. When serializing a JSON payload to produce the body, use **compact separators** (`","` and `":"`, no surrounding whitespace) — this matches JavaScript `JSON.stringify` and most HTTP-client defaults, and is what the receiver sees as `raw_body`. The common cross-SDK failure here is a signer that calls a language default which inserts spaces (e.g., Python `json.dumps(payload)`) while the HTTP client writes compact bytes on the wire — the signer then signs over bytes the receiver never sees. Use `json.dumps(payload, separators=(",", ":"))` (or equivalent) for byte-equality. See [Webhook Security — legacy normative rules](/dist/docs/3.1.20/building/by-layer/L1/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40) for the full rules on canonical on-wire form and verifier input handling. **Publisher implementation (legacy):** ```typescript theme={null} import { createHmac } from 'crypto'; function signWebhook(rawBody: string, secret: string): { signature: string; timestamp: string } { const timestamp = Math.floor(Date.now() / 1000).toString(); const message = `${timestamp}.${rawBody}`; const hex = createHmac('sha256', secret).update(message).digest('hex'); return { signature: `sha256=${hex}`, timestamp }; } ``` **Receiver implementation (legacy):** ```typescript theme={null} import { createHmac, timingSafeEqual } from 'crypto'; function verifyWebhook( rawBody: string, signature: string, timestamp: string, secret: string, ): boolean { const ts = parseInt(timestamp, 10); if (isNaN(ts)) return false; const now = Math.floor(Date.now() / 1000); if (Math.abs(now - ts) > 300) return false; const message = `${ts}.${rawBody}`; const expected = `sha256=${createHmac('sha256', secret).update(message).digest('hex')}`; if (signature.length !== expected.length) return false; return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } ``` Normative rules for the legacy scheme are in [Webhook Security](/dist/docs/3.1.20/building/by-layer/L1/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40). ### Legacy Bearer token (deprecated) The A2A `authentication.schemes: ["Bearer"]` scheme is also supported for compatibility and removed in AdCP 4.0. Bearer provides no tamper protection on the body. The 9421 profile is stronger on signer identity (JWKS-anchored, rotatable, revocable) and key management (no shared secret on the wire); body-integrity protection is comparable to the legacy HMAC scheme since both cover the body bytes. Sellers SHOULD refuse Bearer for any mutating callback. ```json theme={null} { "authentication": { "schemes": ["Bearer"], "credentials": "your_bearer_token_min_32_chars" } } ``` ```javascript theme={null} app.post('/webhooks/adcp', (req, res) => { const token = req.headers.authorization?.replace('Bearer ', ''); if (token !== process.env.ADCP_WEBHOOK_TOKEN) return res.status(401).end(); processWebhook(req.body); res.status(200).end(); }); ``` ## Reliability Webhooks use **at-least-once delivery** — you may receive the same event more than once, and events may arrive out of order. ### Dedup by `idempotency_key` Every webhook payload — MCP task envelope, governance list-change webhooks (`collection_list_changed`, `property_list_changed`), artifact push webhooks, and rights `revocation-notification` — carries a required `idempotency_key`. Publishers generate this key once per distinct event and reuse it on every retry. Receivers MUST dedupe by it. **Sender requirements:** * The key MUST be cryptographically random (UUID v4 recommended). Sequential, timestamp-only, or otherwise predictable values are non-conformant: receivers dedupe on the raw value, so a predictable key lets an attacker pre-seed a receiver's cache to suppress a later legitimate event. * The key MUST be stable across retries of the same event and MUST NOT be reused for a distinct event. **Receiver requirements:** * Dedup scope is `(authenticated sender identity, idempotency_key)`. "Authenticated sender identity" means the sender's cryptographic identity as established by signature verification — under the 9421 default, the resolved `keyid` → signer `agents[]` entry URL; under the legacy fallback, the credential binding from the verified HMAC secret or Bearer token. Never derive identity from a payload field. Keys from different senders MUST be kept in independent keyspaces; a receiver integrated with multiple sellers MUST NOT collapse them. During an HMAC→9421 migration, a receiver SHOULD map both sender-identity forms for the same logical seller to one keyspace so that a duplicate across schemes still dedupes. * **Cross-endpoint dedup (MUST).** A receiver that exposes more than one webhook endpoint (per-integration, per-environment, per-tenant, or per-pod in a horizontally-scaled fleet) MUST share the `(sender identity, idempotency_key)` keyspace across every endpoint a given sender can reach — per-pod in-memory caches are non-conformant. Without a shared tier, the same signed event replayed to a sibling endpoint executes twice. See [Webhook replay dedup sizing](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-replay-dedup-sizing) for the transport-layer companion rule on `(keyid, nonce)` scoping. * Dedup state MUST persist for at least 24h in durable storage that survives process restarts, pod replacements, and region failovers. Publishers SHOULD NOT retry beyond that window; retries arriving after the receiver's TTL will be reprocessed as fresh events. An in-memory-only cache (per-pod `Map` or LRU without a backing tier) is non-conformant — the asymmetry between the \~360 s signature-nonce window and the 24h idempotency window creates a **displaced-replay window** in which a legitimate signed retry (fresh nonce, same `idempotency_key`) passes signature verification and finds no cache entry because the receiver dropped in-memory state. Side effects run twice. Receivers whose cache tier cannot durably honor 24h MUST document the shorter effective window to every sender they integrate with — silent shortening is the unsafe mode. * Receivers SHOULD bound dedup cache size per sender and return `429 Too Many Requests` (or drop the connection) rather than grow unbounded — a misbehaving or hostile seller emitting high-volume fresh keys is otherwise a storage-amplification vector. * **Duplicates MUST be answered with `2xx`** (typically `200 OK`), not `409 Conflict`. At-least-once senders interpret any non-2xx response as "delivery failed" and retry with exponential back-off; returning `4xx` on a successfully-deduped event turns correct receiver behavior into a retry storm. A duplicate is a no-op, not an error. * Webhook receivers do **not** verify payload equivalence across key reuse. If a sender reuses a key with a changed payload (a sender bug), the receiver's cached first copy wins and the second is silently deduped. This differs from the request-side `IDEMPOTENCY_CONFLICT` behavior — senders are solely responsible for generating a fresh key on every distinct event. ```javascript theme={null} app.post('/webhooks/adcp', async (req, res) => { const payload = req.body; const { idempotency_key, task_id, status, timestamp, result } = payload; // Scope dedup to the authenticated sender — never trust a payload field for identity. const sender = req.verifiedSenderId; // set by 9421 verifier (keyid → agent URL) or legacy HMAC/Bearer middleware // Dedup: same (sender, idempotency_key) within the replay window → already processed. // Return 200 (not 409) so the sender stops retrying. if (await db.webhookAlreadyProcessed(sender, idempotency_key)) { return res.status(200).end(); } await db.markWebhookProcessed(sender, idempotency_key); // before side effects — fail-closed on crash // Ordering: separately, don't apply a stale status on top of a newer one. // Ordering state is keyed on task_id, not idempotency_key — two distinct events // (different keys) can still arrive out of order. Still a 200: we received it cleanly. const task = await db.getTask(task_id); if (task?.updated_at >= timestamp) { return res.status(200).end(); } await db.updateTask(task_id, { status, updated_at: timestamp, result }); await triggerBusinessLogic(task_id, status); res.status(200).end(); }); ``` **Always implement polling as backup.** Webhooks can fail due to network issues or server downtime. Use a slower poll interval when webhooks are configured (e.g., every 2 minutes instead of 30 seconds), and stop polling once you receive a terminal status via webhook. ### Diagnosing missing fires When a buyer suspects a webhook isn't reaching its endpoint — gateway 5xx, stale-sequence dedup, drifted webhook URL, suppressed fires under a tripped circuit breaker — call [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys#webhook-activity) with `include_webhook_activity: true`. Each returned media buy will carry a `webhook_activity` array of recent fires for the calling principal, including `idempotency_key` (matches the payload's dedup key — correlate against your own endpoint log), `status` (`success` / `failed` / `timeout` / `connection_error` / `pending`), `http_status_code`, `attempt`, and `error_message`. The scope is the calling principal's own fires; no operator ticket required. ## Best practices 1. **Always implement polling as backup** — webhooks can fail; poll at a reduced interval (e.g. every 2 minutes) when webhooks are configured, and stop once you receive a terminal status 2. **Dedupe by `idempotency_key`** — every payload carries a required key stable across retries; track processed keys for at least 24h 3. **Return 2xx on duplicates** — a successfully-deduped event is a no-op, not an error; returning non-2xx triggers the sender's retry back-off and creates retry storms 4. **Verify signatures before processing** — run the 9421 webhook verifier checklist (or the legacy HMAC check if you opted in) before any side effects 5. **Acknowledge immediately** — return `200` before doing any heavy processing to avoid seller timeouts and unnecessary retries 6. **Don't rely on URL structure** — use `operation_id` from the payload for business correlation; URL paths may only demux endpoints 7. **Plan for HMAC removal in 4.0** — if you're currently on the legacy HMAC fallback, migrate to the 9421 webhook profile during 3.x ## Payload extraction Webhook receivers need to detect the format and extract AdCP data. The buyer typically knows the format because it configured the transport, but defensive detection is useful for multi-format receivers. ### Format detection | Signal | Format | | --------------------------------------- | ------ | | `status` is a string, `task_id` present | MCP | | `status` is an object with `.state` | A2A | ### Extraction **MCP webhooks:** Extract data from the `result` field directly. **A2A webhooks:** Use the [A2A response extraction](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-extraction) algorithm — final states extract from `.artifacts[0].parts[]` (last DataPart), interim states from `status.message.parts[]` (first DataPart). ```javascript theme={null} function extractAdcpResponseFromWebhook(payload, knownFormat) { const format = knownFormat || detectFormat(payload); if (format === 'mcp') return payload.result ?? null; if (format === 'a2a') return extractAdcpResponseFromA2A(payload); return null; } function detectFormat(payload) { if (payload.status && typeof payload.status === 'object' && !Array.isArray(payload.status) && payload.status.state) return 'a2a'; if (typeof payload.status === 'string' && payload.task_id) return 'mcp'; return null; } ``` ### Security requirements * **Content-Type validation**: Senders MUST send `application/json`. Receivers MUST reject other types before signature verification. * **Payload size limit**: Receivers SHOULD enforce a 1MB limit. Reject before signature verification — computing a digest or HMAC over large payloads is a DoS vector. Return `413 Payload Too Large`. * **Deduplication**: `idempotency_key` is the canonical dedup field. Signature verification (9421 or legacy HMAC) plus replay dedup protect the transport; `idempotency_key` protects against duplicate side effects at the application layer. * **Format detection**: Auto-detection is a defensive fallback. Receivers SHOULD use the known format from their transport configuration (`knownFormat` parameter) rather than relying solely on payload inspection. A compromised intermediary could craft an ambiguous payload that routes extraction to the wrong path. ### Test vectors Machine-readable test vectors are available at [`/static/test-vectors/webhook-payload-extraction.json`](https://adcontextprotocol.org/test-vectors/webhook-payload-extraction.json). Client libraries SHOULD validate their format detection and extraction logic against these vectors. ## Reporting webhooks Reporting webhooks are separate from task status webhooks. They deliver periodic performance data for active media buys and are configured via `reporting_webhook` in `create_media_buy`, not via `push_notification_config`. See [Task Reference](/dist/docs/3.1.20/media-buy/task-reference) for details on `reporting_webhook`. ## Persistent channel contract Task webhooks fire once per logical task and stop when the task settles. **Persistent webhooks** — `reporting_webhook` and `push_notification_config` on a media buy — outlive any single operation and fire repeatedly for the life of the resource. The contract below applies to persistent channels. This section is the transport half of the [Snapshot and log contract](/dist/docs/3.1.20/protocol/snapshot-and-log). For the read-side rules (snapshot is authoritative, replay = re-read), see that page. ### Delivery semantics * **At-least-once delivery.** Sellers MAY re-fire the same logical event under retry. Receivers MUST dedupe transport retries by `idempotency_key`. For state-shaped events that also carry a typed `notification_id` (see [`mcp-webhook-payload.json`](https://adcontextprotocol.org/schemas/3.1.20/core/mcp-webhook-payload.json) and [snapshot-and-log Rule 1](/dist/docs/3.1.20/protocol/snapshot-and-log#1-two-distinct-ids-per-fire-and-per-state)), receivers MUST also track `notification_id` to correlate fires to current snapshot state — seeing the same `notification_id` under two different `idempotency_key` values is a re-emission signal, not a transport retry. * **No ordering guarantee.** Two events on the same resource within seconds MAY arrive out of order. Receivers MUST reconcile via the resource snapshot rather than treating webhook ordering as canonical. * **Idempotent application.** Apply the same payload twice and the resulting receiver state MUST be identical. ### Coalescence For state-shaped event types, sellers SHOULD coalesce multiple near-simultaneous changes on the same resource into a single push. **Coalescence windows are per event type and not a flat ceiling** — a latency-sensitive event (fraud, brand safety) cannot wait the same window an advisory can. | Event type | Default coalescence window | Notes | | -------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `impairment` (general) | 5 minutes (SHOULD NOT exceed) | Default for resource-state impairments — audience suspended, creative revoked, etc. | | `impairment` (latency-sensitive) | Sub-minute / no coalescence | Fraud-driven, brand-safety-driven, or other classes where the buyer's response window is short. Sellers MUST NOT apply the general default to these. | | Future advisory events | Hours to daily | Higher noise tolerance; bigger window appropriate. | | Future defect events | Minutes to hours | Between impairment and advisory in urgency. | Sellers MAY declare a shorter coalescence window via `get_agent_capabilities` for receivers that need sub-default latency. Sellers MUST NOT exceed the per-type default without explicit buyer opt-in declared on the receiver side. Delivery report fires (`scheduled`, `final`) follow their own cadence and are not subject to this coalescence rule. ### Replay and recovery If a buyer's receiver was offline and missed a fire, recovery is **read the snapshot**. Two paths exist for every persistent channel and they're at parity in content: * Missed `impairment` event → call `get_media_buys` and read `impairments[]` (full state recovery). * Missed delivery report fire → call `get_media_buy_delivery` for the window in question with `time_granularity` set to the granularity the seller declared in `reporting_capabilities.windowed_pull_granularities` (#4590). The pull returns the same per-window slices the webhook delivered. Sellers that have not yet declared a windowed granularity return date-range aggregates and daily breakdowns only and cannot reconstruct sub-daily fires. * Missed any other state-shaped event → call the corresponding `get_*` task. AdCP does not commit to an event-replay primitive at the transport layer. The webhook delivery visibility surface (`webhook_activity[]` on `get_media_buys`, proposed in [#4278](https://github.com/adcontextprotocol/adcp/issues/4278)) exposes recent fires within a retention window for **debugging** — buyers use it to verify that the seller fired and what HTTP status the receiver returned. It is not a data recovery channel; that's what the snapshot's per-window pull (#4590) is for. ### Mutability and rotation `push_notification_config` and `reporting_webhook` on a media buy MAY be updated via `update_media_buy` without re-creating the buy. Common reasons: rotating the receiver URL, replacing an expired bearer token, swapping signing keys. Sellers MUST honor the updated config on the next fire after the update is acknowledged. There is no formal handoff window — buyers MAY receive a small number of fires against the prior URL during the propagation window and SHOULD treat both URLs as live until the prior URL has been quiet for a coalescence window. ### Auth renewal Persistent webhooks outlive bearer tokens. Receivers using bearer auth (legacy HMAC profile or token-based mTLS) SHOULD rotate tokens via `update_media_buy` before expiry. Receivers using the 9421 signing profile do not need token rotation — verification is against the seller's published JWKS, which the seller rotates independently. If a seller's fire receives a 401 from the receiver, the seller SHOULD treat this as a transient receiver-side configuration error: retry per the standard schedule, surface the failure in `webhook_activity[]` for debugging, and do not auto-disable the webhook. ### Termination Persistent webhooks fire through the buy's terminal lifecycle moves: * `final` delivery report fires after the buy reaches `completed`, `canceled`, or `rejected`. * Any pending `impairment` events fire (or are coalesced and fired) before termination if the seller has them queued. * After the final fire, no further events fire against the configured URLs. Sellers MAY retain `webhook_activity[]` for the retention window after termination so buyers can audit the closing sequence. ## Next steps * [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) — status values and transitions * [Async Operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations) — handling long-running tasks * [Error Handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling) — webhook error patterns # Build a caller Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L4/build-a-caller Client-side guide for AdCP buyers and demand-side applications. Install the SDK, discover an agent's capabilities, make calls, handle async responses and errors, and ingest reporting. Weeks of handler glue, not months. If you're building the **buy side** — a DSP, planning tool, agentic client, or any application that calls AdCP agents to plan, buy, and report — start here. Caller-side L0–L3 is weeks of handler glue, not the [3–4 person-month build](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#why-sdks-matter-more-in-adcp-than-in-eg-http) the agent side requires. The full-stack SDK in your language carries L0–L3; you write the calling logic, response handling, and whatever your application does with the data. **Spec-level reference vs build-shaped guide.** This page walks you through the build. The wire-level invariants — every rule that applies to every mutating call — live at [Calling an AdCP agent](/dist/docs/3.1.20/protocol/calling-an-agent). Read it once before going to production; refer back whenever you're debugging a wire-shape error. **Try it against a live agent.** AAO runs a public test agent at `https://test-agent.adcontextprotocol.org` with per-domain endpoints — `/sales/mcp`, `/creative/mcp`, `/signals/mcp`, `/governance/mcp`. Point your client at the matching endpoint and `getAdcpCapabilities()` works without auth. Use it to verify your install before pointing at a real seller. ## Install the SDK The same SDKs that ship server primitives also ship the calling client. Install one and you have both. ```bash theme={null} npm install @adcp/sdk ``` ```typescript theme={null} import { createSingleAgentClient } from '@adcp/sdk'; const client = createSingleAgentClient({ id: 'sales', name: 'Sales agent', agent_uri: 'https://sales.example.com/mcp', protocol: 'mcp', }); ``` For multi-agent fan-out (one client driving many sellers in parallel) use `ADCPMultiAgentClient` instead — same call surface, indexed by agent id. * [NPM Package](https://www.npmjs.com/package/@adcp/sdk) * [GitHub Repository](https://github.com/adcontextprotocol/adcp-client) ```bash theme={null} pip install adcp ``` ```python theme={null} from adcp import ADCPClient, AgentConfig, Protocol client = ADCPClient(AgentConfig( id="sales", agent_uri="https://sales.example.com/mcp", protocol=Protocol.MCP, )) ``` * [PyPI Package](https://pypi.org/project/adcp/) * [GitHub Repository](https://github.com/adcontextprotocol/adcp-client-python) ```bash theme={null} go get github.com/adcontextprotocol/adcp-go/adcp ``` The Go caller surface is in active development. See the [adcp-go README](https://github.com/adcontextprotocol/adcp-go) for current coverage. ## Authenticate Most agents require credentials before they'll respond to anything beyond `get_adcp_capabilities`. The SDK accepts auth at construction: ```typescript theme={null} const client = createSingleAgentClient({ id: 'sales', name: 'Sales agent', agent_uri: 'https://sales.example.com/mcp', protocol: 'mcp', auth_token: process.env.ADCP_API_KEY, }); ``` ```typescript theme={null} const client = createSingleAgentClient({ id: 'sales', name: 'Sales agent', agent_uri: 'https://sales.example.com/mcp', protocol: 'mcp', signing: { keyId: 'your-key-id', privateKey: /* PEM or KMS handle */ }, }); ``` If your first call returns a 401 / `AUTH_REQUIRED`, the credentials aren't reaching the agent — check the constructor option, not the request payload. See the [L1 security implementation profile](/dist/docs/3.1.20/building/by-layer/L1/security) for the full credential model. ## First call: discover the agent Before calling tools by hand, ask the agent what it supports. ```typescript theme={null} const capabilities = await client.getAdcpCapabilities(); // → { supported_protocols: [...], adcp_versions: [...], features: {...} } ``` `get_adcp_capabilities` returns the agent's protocol coverage, AdCP version range, and feature flags. Use it to gate calls — if `media_buy` isn't in `supported_protocols`, don't call `create_media_buy` against this agent. See the [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) reference for the full response shape. For the rest of the discovery chain (agent card, `tools/list`, `get_schema`), see the [Discovery chain section of Calling an agent](/dist/docs/3.1.20/protocol/calling-an-agent#discovery-chain). ## Make a call Typed methods on the client correspond to AdCP tools. The SDK validates your request against the bundled schemas before sending and parses the response into a typed value. ```typescript theme={null} const products = await client.getProducts({ brief: 'Video campaign for pet owners, 18–34, US, $50K monthly', }); const buy = await client.createMediaBuy({ idempotency_key: crypto.randomUUID(), account: { brand: { domain: 'acme.com' }, operator: 'sales.example' }, packages: [/* … */], }); ``` Two things worth knowing on the first call: * **Generate a fresh `idempotency_key` per logical operation.** Same key on retry → server replays the same response. Fresh key after a failure creates duplicate buys. See the [Idempotency rules](/dist/docs/3.1.20/protocol/calling-an-agent#idempotency-replay-vs-new-operation). * **`account` is a discriminated `oneOf`.** Pick one variant (`{account_id}` from `sync_accounts` / `list_accounts`, or `{brand, operator}` as the natural key — `brand.domain` is the buyer's brand domain, `operator` is the seller agent's deployment hostname or brand.json identifier) and send only its required fields. Merging them fails both. See [`account` is `oneOf`](/dist/docs/3.1.20/protocol/calling-an-agent#account-is-oneof--pick-exactly-one-variant). ## Handle the three response shapes Every mutating tool returns one of three shapes. Handle them explicitly. ```typescript theme={null} const response = await client.createMediaBuy({/* … */}); if ('errors' in response) { // Error: don't retry without fixing — read response.adcp_error.issues[] // for correctable failures (validation, oneOf, etc.) } else if (response.status === 'submitted') { // Async: the work is queued, NOT done. The completion payload arrives // later — either via webhook (preferred) or by polling the AdCP task. } else { // Sync success: response carries the completion payload directly. // (e.g., response.media_buy_id, response.packages) } ``` The SDK steers you to webhooks for async completion. Configure `webhookUrlTemplate` and a status-change handler at construction; the SDK verifies the inbound webhook against the seller's JWKS and fires your handler with the same `result` shape that synchronous responses carry — see [Receive webhooks](#receive-webhooks) below. If you must poll instead of using webhooks (e.g. inside a one-shot script), call the AdCP polling surface: `get_task_status` when the seller advertises it, otherwise legacy AdCP `tasks/get` in 3.x. The [Async responses section of Calling an agent](/dist/docs/3.1.20/protocol/calling-an-agent#async-responses-status-submitted-means-queued) has the wire contract. ## Recover from errors When you see `adcp_error` in a response, read `issues[]` and act based on `recovery`: ```typescript theme={null} const { code, recovery, issues } = response.adcp_error; switch (recovery) { case 'correctable': // Buyer-side fix. Patch the JSON pointers from issues[], resend with // the SAME idempotency_key (fresh key = new operation). break; case 'transient': // Retry with the SAME idempotency_key. Same key on retry replays the // cached response if the work landed. break; case 'terminal': // Human action required. Don't retry. break; } ``` `issues[]` is the actionable part: each entry has a JSON Pointer (`pointer`), an Ajv keyword (`required`, `oneOf`, `enum`, etc.), and — for `oneOf` failures — a `variants[]` array listing each variant's required fields. See the [Error recovery section](/dist/docs/3.1.20/protocol/calling-an-agent#error-recovery--read-issues) for the full envelope and recovery semantics. ## Receive webhooks For async tasks, you can either poll or register a webhook. The webhook delivers the same `result` payload as AdCP task polling with `include_result: true`. The SDK ships an RFC 9421 webhook verifier (`createWebhookVerifier` from `@adcp/sdk/signing/server`) that resolves keys via the seller's brand.json, enforces the replay window, and surfaces structured errors for the [webhook error taxonomy](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-error-taxonomy). If you wire the multi-agent client with a `webhookUrlTemplate` and status-change handlers (per the [@adcp/sdk README](https://github.com/adcontextprotocol/adcp-client#readme)), incoming webhooks are verified and dispatched into your handlers automatically — your HTTP route is one line that hands the request to the client. For the wire-level requirements your endpoint MUST satisfy regardless of how it's wired (covered components, `content-digest` enforcement, dedup discipline), see [L3 — Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks#signature-verification). ## Ingest reporting Reporting is read-only and follows the same call/response shape as everything else. Pull delivery for the buys you own and iterate on a windowed cadence: ```typescript theme={null} const delivery = await client.getMediaBuyDelivery({ media_buy_ids: ['mb_123', 'mb_456'], window: { start: '2026-05-01T00:00:00Z', end: '2026-05-02T00:00:00Z' }, }); ``` For sellers that support performance webhooks, you'll receive deltas via the same webhook receiver shown above; otherwise poll on whatever cadence your reporting needs. Caller L4 — optimization, pacing alerts, attribution joins, dashboards — is your application code on top of the typed `delivery` object. ## What you didn't have to write Caller-side L0–L3 is weeks of handler glue because the SDK already shipped: * **L0** — typed request builders, response parsers, schema validation against the bundled schemas. * **L1** — outbound RFC 9421 signing (per-call), inbound webhook verification, key rotation. * **L2** — agent registry lookup, agent-card publication, credential composition. * **L3** — async-task polling, webhook receiver, idempotency-key generation helpers, error-recovery classification. The [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack) decomposes each layer; the [server-vs-client comparison table](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#server-vs-client-at-each-layer) is the side-by-side view of the cost asymmetry. ## What's next * **[Calling an agent](/dist/docs/3.1.20/protocol/calling-an-agent)** — the canonical wire-contract reference. Read once before going to production. * **[Schemas](/dist/docs/3.1.20/building/by-layer/L0/schemas)** — schema bundle, type generation, version pinning. * **[Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks)** — push notifications, signing, retry, reliability patterns. * **[Error handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling)** — error categories, codes, recovery classification. * **[Validate your agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** — storyboards also exist for caller-side wire conformance; run them once your calls are working. Per-protocol task references: * [Media buy task reference](/dist/docs/3.1.20/media-buy/task-reference/index) * [Creative task reference](/dist/docs/3.1.20/creative/task-reference) * [Signals task reference](/dist/docs/3.1.20/signals/tasks/get_signals) * [Brand protocol tasks](/dist/docs/3.1.20/brand-protocol) # Build an Agent Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L4/build-an-agent Use AdCP SDK skill files to generate storyboard-compliant agents with a coding agent in minutes. The fastest way to build an AdCP agent is to point a coding agent (Claude Code, Codex, Cursor, Windsurf) at a skill file from an AdCP SDK. Each skill produces a protocol-compliant, storyboard-validated agent in 2–8 minutes. **Publisher without an engineering team?** Protocol compliance is one piece of going live — product management, activation into your ad server, and hosting are separate lifts. See **[Operating an Agent](/dist/docs/3.1.20/building/operating/operating-an-agent)** for the three paths: partner with a managed platform, self-host a prebuilt agent, or build your own. ## Install the SDK Each SDK handles protocol compliance — schema validation, error formats, version negotiation, and response builders — so you write business logic, not protocol plumbing. ```bash theme={null} npm install @adcp/sdk ``` The JS/TS SDK provides typed tool registration, response builders, and a built-in storyboard runner. Most agents in production use this SDK. * [NPM Package](https://www.npmjs.com/package/@adcp/sdk) * [GitHub Repository](https://github.com/adcontextprotocol/adcp-client) ```bash theme={null} pip install adcp ``` The Python SDK provides the same capabilities — subclass `ADCPHandler`, implement tools, and use response builders for every return value: ```python theme={null} from adcp.server import ADCPHandler, serve from adcp.server.responses import capabilities_response class MySeller(ADCPHandler): async def get_adcp_capabilities(self, params, context=None): return capabilities_response(["media_buy"]) # ... implement tools, use response builders for every return serve(MySeller(), name="my-seller") ``` Response builders (`adcp.server.responses`) handle schema compliance so you don't construct raw JSON. Use them for every tool return. * [PyPI Package](https://pypi.org/project/adcp/) * [GitHub Repository](https://github.com/adcontextprotocol/adcp-client-python) ```bash theme={null} go get github.com/adcontextprotocol/adcp-go/adcp ``` The Go SDK provides typed tool registration, response builders, and a compliance test controller. Types are generated from canonical AdCP schemas. | Component | Import | | ----------------- | ------------------------------------------------------------ | | Tool registration | `adcp.AddTool(server, name, desc, handler)` | | HTTP server | `adcp.Serve(createAgent)` | | Response builders | `adcp.ProductsResponse(data)`, `adcp.MediaBuyResponse(data)` | | Test controller | `adcp.RegisterTestController(server, store)` | See the [Go SDK README](https://github.com/adcontextprotocol/adcp-go) for complete examples. Response builders (`adcp.ProductsResponse()`, `adcp.MediaBuyResponse()`, etc.) handle schema compliance so you return typed structs, not raw JSON. * [GitHub Repository](https://github.com/adcontextprotocol/adcp-go) **Use the SDK for your language.** All three SDKs — JS/TS, Python, and Go — handle schema validation, error formats, and protocol negotiation. You do not need to use a different language for protocol compliance. ## Choose a skill Each SDK ships skills that walk a coding agent through building a specific agent type. Common skills across SDKs: * `build-seller-agent` — publisher, SSP, or media network selling inventory * `build-signals-agent` — CDP or data provider serving audience segments * `build-creative-agent` — ad server or CMP rendering creatives * `build-generative-seller-agent` — AI ad network generating ads from briefs * `build-retail-media-agent` — retail media network with catalog-driven creative For example, the JS/TS seller skill lives at [`adcp-client/skills/build-seller-agent/SKILL.md`](https://github.com/adcontextprotocol/adcp-client/tree/main/skills/build-seller-agent). Skill coverage and naming vary per language since each SDK includes implementation guidance specific to its stack. Browse the directory for your language: * **JS/TS** — [adcp-client/skills](https://github.com/adcontextprotocol/adcp-client/tree/main/skills) * **Python** — [adcp-client-python/skills](https://github.com/adcontextprotocol/adcp-client-python/tree/main/skills) * **Go** — [adcp-go/skills](https://github.com/adcontextprotocol/adcp-go/tree/main/skills) ### Which domain and specialisms do you claim? Each agent declares its `supported_protocols` (domains) and `specialisms` on `get_adcp_capabilities`. Each skill's storyboard verifies the domain baseline — to also claim a specialism, your agent must pass that specialism's storyboard. Skills-to-specialism mapping: | Skill | Typical `supported_protocols` | Typical `specialisms` (pick one or combine) | | ------------------------------- | ----------------------------- | ---------------------------------------------- | | `build-seller-agent` | `["media_buy", "creative"]` | `sales-guaranteed`, `sales-non-guaranteed` | | `build-generative-seller-agent` | `["media_buy", "creative"]` | `creative-generative` + `sales-non-guaranteed` | | `build-retail-media-agent` | `["media_buy", "creative"]` | `sales-catalog-driven` | | `build-signals-agent` | `["signals"]` | `signal-owned`, `signal-marketplace` | | `build-creative-agent` | `["creative"]` | `creative-ad-server`, `creative-template` | **Picking a sales specialism:** See [Choosing a sales specialism](/dist/docs/3.1.20/building/verification/compliance-catalog#choosing-a-sales-specialism) in the Compliance Catalog for the full decision tree. Quick reference: * **`sales-guaranteed`** — IO approval, fixed pricing. Set `media_buy.supports_proposals: true` if you support RFP/proposal flows; `false` (or omit) for direct-buy only. * **`sales-non-guaranteed`** — auction / PMP. * **`sales-broadcast-tv`**, **`sales-catalog-driven`**, **`sales-social`** — channel-specific; see the decision tree. You can claim more than one. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for the full taxonomy and per-specialism storyboards. Building a **brand rights** agent (licensing talent, music, stock media)? There's no skill today — see the [Brand Protocol docs](/dist/docs/3.1.20/brand-protocol) and claim `brand-rights` under the `brand` domain. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for every domain and specialism with its storyboard and status (stable, preview, deprecated). Storyboard passing earns the **[AAO Verified (Spec)](/dist/docs/3.1.20/building/verification/aao-verified)** qualifier — validated against seeded test data on a test-mode endpoint. Once your agent is running against real production inventory, consider enrolling in the **(Live)** qualifier, which adds continuous observability of real delivery on a dedicated compliance account. An agent can hold (Spec), (Live), or both; enterprise buyers that treat AdCP as production infrastructure filter on (Live). ## Build the agent Point your coding agent at the skill file for your agent type. In Claude Code: ``` Fetch https://raw.githubusercontent.com/adcontextprotocol/adcp-client/main/skills/build-seller-agent/SKILL.md, then build a seller agent for a premium sports news publisher with guaranteed CTV and OLV inventory. ``` ``` Fetch https://raw.githubusercontent.com/adcontextprotocol/adcp-client-python/main/skills/build-seller-agent/SKILL.md, then build a seller agent for a premium sports news publisher with guaranteed CTV and OLV inventory. ``` Point at the `adcp-client-python` skill for your agent type. If the exact skill isn't there yet, browse [adcp-client-python/skills](https://github.com/adcontextprotocol/adcp-client-python/tree/main/skills) for the closest match. ``` Fetch https://raw.githubusercontent.com/adcontextprotocol/adcp-go/main/skills/build-seller-agent/SKILL.md, then build a seller agent for a premium sports publisher. ``` In Cursor or Windsurf, download the skill file and include it as context with your prompt. Each skill walks the coding agent through: 1. Business model decisions (what you sell, how you price, approval workflow) 2. Tool registration with correct schemas 3. Response shapes that pass storyboard validation 4. Error handling and edge cases ## Validate with storyboards The storyboard runner requires Node.js, regardless of what language your agent is written in. Once the agent is running, validate it against the matching storyboard: ```bash theme={null} # JS/TS agent npx tsx agent.ts & npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp media_buy_seller --json # Python agent python agent.py & npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp media_buy_seller --json # Go agent go run main.go & npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp media_buy_seller --json ``` Storyboards exercise every required tool call and validate response shapes. The storyboard runner uses sandbox mode by default — your agent receives `sandbox: true` on all account references and should return simulated data without real platform calls. A passing run means your agent is protocol-compliant. ``` media_buy_seller (9 steps) ✓ get_adcp_capabilities ✓ sync_accounts ✓ get_products ✓ create_media_buy ✓ list_creative_formats ✓ sync_creatives ✓ list_creatives ✓ get_media_buy_delivery ✓ provide_performance_feedback 9/9 passed ``` **Protocol-compliant ≠ production-ready.** A passing run means your agent speaks AdCP correctly. Going live requires business infrastructure behind each tool call — products and pricing, activation into your ad server, order management, hosting, and discovery registration via `adagents.json`. See **[Operating an Agent](/dist/docs/3.1.20/building/operating/operating-an-agent)** for the full list and whether to partner, self-host, or build. Each skill includes variant storyboards for different business models — non-guaranteed, guaranteed with approval, proposal mode, and more. Run `npx @adcp/sdk@latest storyboard list` to see all available storyboards. See **[Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** for the full testing workflow — debugging failing steps, running compliance checks, and validating interactively through Addie. If your agent **wraps an upstream platform** (DSP, SSP, retail data, creative server, signal marketplace), see **[Validate adapter agents with mock upstream fixtures](/dist/docs/3.1.20/building/verification/validate-with-mock-fixtures)** for the pre-staging gate that surfaces façade bugs storyboards alone don't catch. ## Additional resources The JS/TS SDK includes documentation designed for both humans and coding agents: | Resource | JS/TS location | Purpose | | ------------- | ------------------------------------------------------ | --------------------------------------------------------------- | | Protocol spec | `node_modules/@adcp/sdk/docs/llms.txt` | Full protocol in one file — tools, types, error codes, examples | | Server guide | `node_modules/@adcp/sdk/docs/guides/BUILD-AN-AGENT.md` | Server-side implementation patterns | Python and Go equivalents are in each SDK's GitHub repository. See [adcp-client-python](https://github.com/adcontextprotocol/adcp-client-python) and [adcp-go](https://github.com/adcontextprotocol/adcp-go). ## What's next * **[Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** — Storyboards, compliance checks, and the build-validate-fix loop * **[Operating an Agent](/dist/docs/3.1.20/building/operating/operating-an-agent)** — What sits behind the protocol layer, and whether to partner, self-host, or build * **[Choose your SDK](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk)** — Schema access, CLI tools, and SDK package exports * **[MCP integration guide](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide)** — Transport, sessions, and auth details * **[Task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle)** — Status values, transitions, and polling * **[Error handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling)** — Error categories, codes, and recovery # Choose your SDK Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk Pick the AdCP SDK for your language. Coverage matrix, install commands, and what each SDK ships at L0–L3 so you can focus on L4 business logic. The AdCP SDKs absorb L0–L3 (wire, signing, auth, protocol semantics) so you write L4 business logic. Pick the SDK in the language you're already in — all three SDKs target the same wire conformance bar. For the layered model behind this — what each layer contains and what an SDK at each layer should provide — see the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack). ## Coverage matrix What "shipped" means at each layer is the [L0–L3 checklist](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#what-an-sdk-at-each-layer-should-provide). *Last updated: 2026-05-03.* | SDK | Production GA | Beta / dev | L0 | L1 | L2 | L3 | | -------------------- | ------------- | ---------- | :-: | :-: | :-: | :-: | | **`@adcp/sdk`** (TS) | `6.9.0` | — | ✅ | ✅ | ✅ | ✅ | | **`adcp`** (Python) | `3.x` | `4.x` | ✅ | ⚠️ | ⚠️ | ⚠️ | | **`adcp-go`** | — | `v1.x` | ⚠️ | ❌ | ❌ | ❌ | Legend: ✅ shipped · ⚠️ partial / in flight · ❌ not yet covered. **Production GA** is the line you should pin to today. **Beta / dev** is what's in flight on the next major. **`@adcp/sdk` 6.x ships full L0–L3** on AdCP 3.0 — adopters write L4 only. The 5.x line is on security-only support and not recommended for new builds; cut over to 6.x. **`adcp` (Python) 3.x is the production line** with full L0 type coverage; the 4.x rewrite (in beta on PyPI) closes the L1–L3 gap — outbound RFC 9421 signing, brand-resolution helpers, webhook emission. Track [adcp-client-python releases](https://github.com/adcontextprotocol/adcp-client-python/releases) for the 4.0 GA cut. **`adcp-go`** is in active development; types + transport land first, see [adcp-go README](https://github.com/adcontextprotocol/adcp-go) for what's in scope per release. Pre-3.0 callers should work through the [3.0 migration guide](/dist/docs/3.1.20/reference/migration/index) before upgrading. For the at-a-glance status of every published protocol version, see [Versions & Compatibility](/dist/docs/3.1.20/reference/versions). For procurement-grade context on the support window, see the [v2 sunset timeline](/dist/docs/3.1.20/reference/v2-sunset) and [versioning & governance](/dist/docs/3.1.20/reference/versioning). **Python and TypeScript carry full L0–L4 coverage as the first-class supported languages.** **Go** is moving in the same direction. **Other languages** are not on the official roadmap; community-maintained ports are welcome — see the [Builders Working Group](/dist/docs/3.1.20/community/working-group) and the [Slack community](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg). ## JavaScript / TypeScript [![npm version](https://img.shields.io/npm/v/@adcp/sdk)](https://www.npmjs.com/package/@adcp/sdk) ```bash theme={null} npm install @adcp/sdk ``` ```javascript theme={null} import { createSingleAgentClient } from '@adcp/sdk'; const client = createSingleAgentClient({ id: 'sales', name: 'Sales agent', agent_uri: 'https://sales.example.com/mcp', protocol: 'mcp', }); const products = await client.getProducts({ brief: 'Video campaign for pet owners', }); ``` **Resources:** * [NPM Package](https://www.npmjs.com/package/@adcp/sdk) * [GitHub Repository](https://github.com/adcontextprotocol/adcp-client) **Package exports:** * `@adcp/sdk` — main entry: caller (`createSingleAgentClient`, `ADCPMultiAgentClient`) and shared types * `@adcp/sdk/server` — agent-side server primitives (`createAdcpServerFromPlatform`, `createAdcpServer`, decisioning-platform interfaces) * `@adcp/sdk/server/legacy/v5` — legacy v5 handler-bag entry, still supported for mid-migration codebases * `@adcp/sdk/signing` — RFC 9421 signing primitives * `@adcp/sdk/signing/server` — webhook + request verifiers (`createWebhookVerifier`, `verifyRequestSignature`, `createExpressVerifier`) * `@adcp/sdk/signing/client` — outbound signing (`signRequest`, `signWebhook`, `createSigningFetch`) * `@adcp/sdk/testing` — buyer-side storyboard runner (`runStoryboard`, `comply`, `testAgent`) and seller-side controller scaffold (`createComplyController` — see [Get Test-Ready](/dist/docs/3.1.20/building/verification/get-test-ready)) * `@adcp/sdk/conformance` — assertion + storyboard helpers for conformance harnesses * `@adcp/sdk/schemas` — bundled AdCP JSON Schemas * `@adcp/sdk/types` — TypeScript type definitions * `@adcp/sdk/types/v2-5` — v2.5 type co-existence imports for cross-version callers ## Python [![PyPI version](https://img.shields.io/pypi/v/adcp)](https://pypi.org/project/adcp/) ```bash theme={null} pip install adcp ``` ```python theme={null} from adcp import ADCPClient, AgentConfig, Protocol, GetProductsRequest client = ADCPClient(AgentConfig( id='sales', agent_uri='https://sales.example.com/mcp', protocol=Protocol.MCP, )) result = await client.get_products( GetProductsRequest(brief='Video campaign for pet owners'), ) ``` **Resources:** * [PyPI Package](https://pypi.org/project/adcp/) * [GitHub Repository](https://github.com/adcontextprotocol/adcp-client-python) ## Go ```bash theme={null} go get github.com/adcontextprotocol/adcp-go/adcp ``` The Go SDK provides typed tool registration, response builders, and a compliance test controller. Types are generated from canonical AdCP schemas. | Component | Import | | ----------------- | ------------------------------------------------------------------------------------------------------------ | | Tool registration | `adcp.AddTool(server, name, desc, handler)` | | HTTP server | `adcp.Serve(createAgent)` | | Response builders | `adcp.ProductsResponse(data)`, `adcp.MediaBuyResponse(data)`, etc. | | Test controller | `adcp.RegisterTestController(server, store)` | | Skills | [github.com/adcontextprotocol/adcp-go/skills](https://github.com/adcontextprotocol/adcp-go/tree/main/skills) | See the [Go SDK README](https://github.com/adcontextprotocol/adcp-go) for the full API reference. **Resources:** * [GitHub Repository](https://github.com/adcontextprotocol/adcp-go) ## CLI tools The JavaScript and Python SDKs include command-line tools for testing and development. Both SDKs share the same positional shape: `adcp [tool] [payload]`. The first positional is an alias, a built-in (`test-mcp`, `test-a2a`), or a URL — protocol is auto-detected. Save aliases with `--save-auth` to avoid retyping. ### JavaScript CLI ```bash theme={null} npx @adcp/sdk@latest --help npx @adcp/sdk@latest --save-auth my-agent https://sales.example.com/mcp npx @adcp/sdk@latest my-agent get_products '{"brief":"CTV campaign"}' # or against the built-in public test agent: npx @adcp/sdk@latest test-mcp get_products '{"brief":"CTV campaign"}' ``` The CLI also drives storyboards (`adcp storyboard run`), conformance grading (`adcp grade`), and registry diagnostics. See `--help` for the full surface. ### Python CLI ```bash theme={null} uvx adcp --help uvx adcp --save-auth my-agent https://sales.example.com/mcp uvx adcp my-agent get_products '{"brief":"CTV campaign"}' ``` ## What's next * **[Build an agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent)** — server-side L4 path. Skill files + coding agent. * **[Build a caller](/dist/docs/3.1.20/building/by-layer/L4/build-a-caller)** — client-side L4 path. Install, call, handle responses, ingest reporting. * **[Schemas](/dist/docs/3.1.20/building/by-layer/L0/schemas)** — schema bundle, type generation, version pinning. * **[Migrate from hand-rolled](/dist/docs/3.1.20/building/by-layer/L4/migrate-from-hand-rolled)** — already running an AdCP agent built before the SDKs covered much? Swap one layer at a time. # L4 — Business logic Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L4/index Business-logic layer of the AdCP stack. What an SDK leaves to you: inventory and pricing on the agent side, planning and buying on the caller side. The default starting layer for ~95% of adopters. L4 is what makes your agent or caller yours. An AdCP SDK ships L0–L3 (wire, signing, auth, protocol semantics) and leaves L4 to you. For most adopters this is the right starting layer — your team's value-add lives here, not in re-implementing the protocol underneath. See the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack) for what each layer below contains and what an SDK at each layer must provide. ## Pages in this layer * **[Choose your SDK](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk)** — language coverage matrix, install commands, package exports, CLI tools. Start here if you haven't picked an SDK. * **[Build an agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent)** — server side. Point a coding agent at a skill file; get a storyboard-compliant AdCP agent. * **[Build a caller](/dist/docs/3.1.20/building/by-layer/L4/build-a-caller)** — client side. Install the SDK, discover the agent, make calls, handle responses, ingest reporting. * **[Migrate from hand-rolled](/dist/docs/3.1.20/building/by-layer/L4/migrate-from-hand-rolled)** — already running an AdCP agent built before the SDKs were mature? Swap one layer at a time. # Migrate from a hand-rolled agent Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/by-layer/L4/migrate-from-hand-rolled Incremental migration path for adopters with a working AdCP agent in production. Inventory step, lowest-risk-first swap order, conflict modes (idempotency, account-mode, webhook signing, state-machine drift), per-step rollback playbook, intermediate states that pass conformance, and when not to migrate. This guide is for adopters with a **working AdCP agent in production** who want to move to an official SDK without a flag-day rewrite. Your agent serves real traffic; you have engineers who built the current stack and will defend it; you can't afford a multi-week freeze. The path is incremental — swap one layer at a time, ship after each step, re-certify as you go. If you're greenfield, you're in the wrong doc — see [Build an Agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent). If you're still deciding whether to migrate at all, see the [hand-rolled re-evaluation check](/dist/docs/3.1.20/building#two-checks-before-you-start) on the building overview. ## 0. Inventory what you own today Before swapping anything, write down what your hand-rolled stack provides at each layer of [the AdCP stack](/dist/docs/3.1.20/building/cross-cutting/sdk-stack). Use the L0–L3 checklists in that doc as the rubric. Mark each row: *shipped* / *partial* / *not yet*. This determines order of operations. The lowest-risk swap is usually the layer where you have the **least** coverage today, because there's the least existing behavior to reconcile. ## 1. Get to spec compliance first, before changing any code Single most important step: 1. Stand up a **mock-mode account** in your agent. (If you don't have the live/sandbox/mock distinction yet, see [Account-mode mismatch](#account-mode-mismatch) below — add the flag at your boundary first.) 2. Route mock-mode traffic to the reference mock-server. 3. Run the AdCP storyboards against your agent (see [Conformance](/dist/docs/3.1.20/building/verification/conformance) and [Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent)). 4. Read the pass/fail report. The failure list is your migration backlog, ordered. It converts "I think the SDK adds value" into "here are the storyboards we fail and which L3 component each one points at." Without this step you're buying an SDK based on a sales pitch instead of measured gap. You may discover you're more conformant than you thought (in which case the migration is smaller than you expected) or less (in which case the case for adopting the SDK strengthens). Either outcome is useful. ## 2. Order of operations (lowest risk first) Recommended swap order: 1. **Conformance test surface** ([`comply_test_controller`](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller)). Pure additive — mock-mode traffic now goes through the SDK; live traffic untouched. Earns spec-compliance certification on the spot. 2. **Error code catalog**. Replace your error-envelope construction with the SDK's error builders. Recovery classifications and code precedence (e.g., `NOT_CANCELLABLE` over `INVALID_STATE`) come for free. See [Error handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling). 3. **Idempotency cache**. Riskiest swap — see [Two idempotency caches in series](#two-idempotency-caches-in-series). Spec contract: [Idempotency](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency). 4. **Async-task store + dispatcher**. Adopt the SDK's `task_id` + terminal-artifact contract. Often touches your worker queue. See [Task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle). 5. **State machines**, one resource at a time. MediaBuy first ([lifecycle reference](/dist/docs/3.1.20/media-buy/media-buys/lifecycle)) if it's where you spend the most maintenance time. Re-run lifecycle storyboards after each. 6. **Webhook emission** (signed, retried, idempotent). Independent of 1–5; can be parallelized. See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks). 7. **RFC 9421 signing + verification**. Independent of everything above; can be parallelized. See [Security implementation](/dist/docs/3.1.20/building/by-layer/L1/security). 8. **Auth / account store**. Last. Your hand-rolled L2 probably encodes business decisions that don't move easily. You can stop at any step. Adopting at L3 (steps 1–6) without L2 is a perfectly valid endpoint — your auth layer keeps doing what it does, the SDK takes over protocol semantics. See [What you can leave hand-rolled](#5-what-you-can-leave-hand-rolled). ## 3. Conflict modes to watch for These are the "two stacks fighting each other" failure modes that make incremental migration painful if you don't see them coming. ### Two idempotency caches in series Your existing cache fields requests at the perimeter; the SDK's cache fields requests at the protocol boundary. Symptoms: same `idempotency_key` returns different envelopes depending on which cache hit first; cross-payload reuse is detected by one and not the other. **Resolution.** Pick one, retire the other. Usually retire yours — the SDK's enforces the *no-payload-echo* invariant on `IDEMPOTENCY_CONFLICT` (stolen-key read-oracle threat) and the cross-payload conflict detection that the spec mandates. If you need to keep your storage backend (Redis, Postgres), point the SDK's cache contract at it as a custom backend instead of forking the SDK. ### Account-mode mismatch The SDK distinguishes `live` / `sandbox` / `mock` accounts (see [Sandbox](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox)). If your hand-rolled stack lacks the distinction, mock-mode storyboards may dispatch to live handlers. Symptoms: storyboards mutate production state; conformance certification refuses to dispatch. **Resolution.** Add the account-mode flag at your boundary before adopting the SDK's conformance controller. The `comply_test_controller` refuses to run against any account that isn't sandbox or mock — that refusal is a feature, not a bug. ### Webhook signature ownership If both stacks try to sign outbound webhooks, the receiver sees two `Signature` headers (or one wins and the other is silently overwritten by the proxy). Either way, signatures don't verify. **Resolution.** Pick one signer at the boundary; usually the SDK's, since it tracks key rotation against the public key registry and handles the RFC 9421 canonicalization correctly. Keep your KMS-backed key material; configure the SDK's signing-provider abstraction to use it. ### State machine drift Your hand-rolled state machine probably has edges the SDK rejects (e.g., direct `pending_creatives → completed` skipping `active`, or `active → canceled` without distinguishing the `NOT_CANCELLABLE` vs `INVALID_STATE` precedence). Symptoms: lifecycle storyboards fail with `INVALID_STATE` where you expected to succeed. **Resolution.** Run the lifecycle storyboards against your agent *before* swapping the state machine. Reconcile your edge set to the spec — fix obvious bugs, file spec issues for ambiguities. Then swap the SDK's state machine in; it'll enforce what you just hand-converged on. ### Webhook delivery transport If your queue/worker stack delivers webhooks today, the SDK's emitter would double-deliver if you wire it on without retiring yours. Symptoms: receivers see duplicate idempotency keys with the same payload at slightly different times. **Resolution.** The SDK builds the envelope; how you ship it is yours. Configure the SDK to hand off to your existing transport instead of running its built-in HTTP delivery — that's the seam. ### Schema validation collisions If you validate inbound payloads against your own schema bundle, and the SDK validates again at its boundary, you get either duplicate work (cheap) or contradictory verdicts (a real bug — your bundle drifted from the published schemas). **Resolution.** Retire your local validator after the SDK is in. While both run, treat any discrepancy as your bundle being stale, not the SDK being wrong. ## 4. Intermediate states that pass conformance After each step, you can re-run mock-mode storyboards and re-certify. You don't need to finish the migration to claim conformance — you only need to pass the storyboards at whatever cut-line the SDK's conformance suite enforces. | After step | What you have | Conformance status | | ---------- | --------------------------------------------- | ------------------------------------------------------------------------- | | 1 | Conformance controller wired; agent unchanged | **Spec compliance** (mock-mode storyboards run against your unchanged L3) | | 2 | + SDK error envelopes | Same; better recovery semantics | | 3 | + SDK idempotency | Same; tighter security on cross-payload reuse | | 4 | + SDK async-task contract | Same; uniform task lifecycle | | 5 | + SDK state machines | Same; transition validation no longer your problem | | 6 | + SDK webhook envelope | Same; signed, retried, dedup-keyed | | 7 | + SDK signing | **Live compliance** (when that storyboard set ships) | | 8 | + SDK account store | Full L4-on-SDK | You ship after each step. Production traffic stays up. ### Rollback per step Every step in the swap order should be reversible inside \~5 minutes. The general pattern: each swap is a **feature-flagged switch between your hand-rolled component and the SDK's**, not a deletion. You ship the swap behind a per-account flag, observe, then flip the default. Rollback is the same flag in the other direction. What to plan for, swap by swap: | Step | Revert mechanism | What state may have leaked | First thing to verify on rollback | | ------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | 1. Conformance controller | Disable the SDK route on mock-mode accounts | None — mock-mode is sandbox-only by design | Mock storyboards still pass against your hand-rolled L3 | | 2. Error envelopes | Flip back to your error builder | Outbound envelopes from the swap window may carry SDK-shape error codes; downstream consumers that key off your old codes may have logged them | Your error-monitoring dashboard is reading the right envelope shape again | | 3. Idempotency cache | Flip back to your cache | **Both caches saw traffic during the swap window.** Same `idempotency_key` may now exist in both stores with different envelopes. After rollback, your cache is authoritative; flush the SDK's cache on revert | No `IDEMPOTENCY_CONFLICT` storms from the dual-cache window | | 4. Async-task contract | Flip back to your task store | Outstanding tasks that started under the SDK's contract may have a different terminal-artifact shape than your worker expects | Drain or abort tasks created during the swap window before the revert lands | | 5. State machines | Flip back to your state machine | The SDK may have rejected transitions your machine would have accepted (or vice versa). Affected resources are in a state your code may not know how to advance | Run a one-shot reconciliation query: "any resources in a state that's legal under both machines? Any in a state that's only legal under one?" | | 6. Webhook envelope | Flip back to your emitter | Receivers got SDK-shape webhooks during the swap window. They may have already acked. Don't re-emit on rollback | Your dedup table accepts both old and new `idempotency_key` shapes (transitional) | | 7. RFC 9421 signing | Flip back to your signer | Receivers got SDK-signed webhooks/responses during the window. Their key registry must still resolve your old `keyid` | Your old `keyid` is still published in your JWKS | | 8. Account store | Flip back to your store | Account resolution decisions made under the SDK's store may have routed traffic to different tenants than your store would have | Run a reconciliation on accounts that were resolved during the swap window before fully reverting | ### The 2 a.m. production failure If swap N fails in production at 2 a.m., the on-call recipe: 1. **Flip the per-account flag back** to your hand-rolled component for the affected accounts (or globally if you can't isolate the blast radius). This is the only step required to stop the bleeding. 2. **Confirm the leakage row** above for that step. Most steps leave residual state somewhere — note what it is so the morning debug doesn't start cold. 3. **Don't rerun the conformance suite mid-incident.** It runs against mock-mode; production failures are a different signal. 4. **File the incident against the swap step**, not against the SDK in general. The migration guide's swap-order is the unit of investigation; the SDK's coverage matrix is downstream of that. **Don't plan for irreversible swaps.** If a step doesn't fit the flag-and-flip pattern (e.g., a destructive schema migration), do it as a separate, named project — not as part of the swap order. ## 5. What you can leave hand-rolled The SDK is opinionated where the spec is opinionated, and pluggable where it isn't. You don't have to give up your existing infra: * **Signing provider.** Keep your KMS integration. The SDK accepts a custom signer. * **Account store.** Keep your multi-tenant routing. The SDK's account-store interface is the seam. * **Idempotency backend.** Keep your Redis / Postgres. The SDK's cache contract is pluggable. * **Webhook delivery transport.** Keep your queue. The SDK builds the envelope; how you ship it is yours. * **Schema validation library.** Keep your validator if you want; the SDK uses its own at its boundary, not yours. If your hand-rolled stack has good answers to these, swap them in as **configuration**, not as forks. ## 6. Versioning during the migration Two version axes you'll be juggling: * **Spec version of your buyers.** A migration is a great moment to add per-call `adcpVersion` pinning so you stop forking handlers by buyer-version. See [Version Adaptation](/dist/docs/3.1.20/building/cross-cutting/version-adaptation). * **SDK version.** Don't migrate to a legacy import path as a final state — migrate *through* it. The legacy subpath exists so you can adopt one specialism at a time on the new entry point while the rest stays on the old one. Greenfield code in the same project uses the new framework directly. ### Worked example: two buyers, mid-swap You're on step 3 (idempotency), buyer A is on AdCP 2.5, buyer B is on AdCP 3.0. You've adopted the SDK's controller, error envelopes, and idempotency cache for the accounts you've cut over. Buyer A is mid-flight to the SDK; buyer B is greenfield on the SDK. Your inbound side: identify each peer's spec version up front (from the agent registry, the agent card, or the `adcp_version` field if present), pin `adcpVersion` on the agent / per call, and let the SDK adapt the wire shape. Example with `@adcp/sdk`: ```ts theme={null} import { ADCPMultiAgentClient } from '@adcp/sdk'; const buyerA = new ADCPMultiAgentClient([{ id: 'buyer-a', agent_uri: 'https://buyer-a.example.com/mcp/', protocol: 'mcp', auth_token: process.env.BUYER_A_TOKEN, adcpVersion: 'v2.5', // ← per-agent pin, no fork in handlers }]); const buyerB = new ADCPMultiAgentClient([{ id: 'buyer-b', agent_uri: 'https://buyer-b.example.com/a2a', protocol: 'a2a', auth_token: process.env.BUYER_B_TOKEN, adcpVersion: '3.0', // ← canonical / current }]); ``` Your outbound (server) side, declaring what *you* accept: ```ts theme={null} import { createAdcpServer } from '@adcp/sdk/server'; createAdcpServer({ capabilities: { major_versions: [3], supported_versions: ['3.0'], }, // …handlers, all on the canonical 3.0 shape; // 2.5 callers are translated by client-side adapters before they reach you. }); ``` What this looks like for one call from each buyer, mid-step-3: | | Buyer A (2.5 wire) | Buyer B (3.0 wire) | | --------------------- | ------------------------------------------ | ----------------------- | | **Inbound shape** | 2.5 `create_media_buy` | 3.0 `create_media_buy` | | **Adapter does** | Translates 2.5 → 3.0 shape | Pass-through | | **Your handler sees** | 3.0 typed object | 3.0 typed object (same) | | **Idempotency cache** | SDK's (you're on step 3) | SDK's | | **Error envelope** | SDK's, translated back to 2.5 on outbound | SDK's | | **Outbound shape** | 2.5 (translated by adapter on the way out) | 3.0 | One handler codebase. Two wire versions. Both buyers see the envelope shape they expect. Adopting `adcpVersion` pinning at step 3 is cheap — most of the version work is in the adapter modules, which the SDK already ships. If you fall back (rollback step 3 to your hand-rolled cache), buyer A still goes through the SDK's translation adapters — the version machinery and the idempotency machinery are independent. You don't re-fork your handler code on rollback. ## 7. When to *not* migrate If your agent serves a frozen wire surface for a small set of named buyers and your engineers spend \~zero time on protocol maintenance, the migration ROI is low. Reasonable holds: * You're on AdCP 2.5, none of your buyers want 3.x, and you're willing to deprecate when they do. * Your conformance gap (from step 1) is small enough to fix in place without adopting the SDK. * You have a hard regulatory or operational reason for owning every layer end-to-end. In those cases, do step 1 anyway — route mock-mode through the reference mock-server for spec compliance certification — and revisit the migration question at AdCP 4.0 or when your buyer mix moves. The migration is for adopters whose **maintenance load is real and growing**. The cost claim ([\~3–4 person-months for L0–L3 from scratch](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#why-sdks-matter-more-in-adcp-than-in-eg-http)) is what you're *buying back* by adopting incrementally — but only if that maintenance load actually exists. ## See also * [The AdCP stack](/dist/docs/3.1.20/building/cross-cutting/sdk-stack) — layered architecture reference * [Where to start](/dist/docs/3.1.20/building) — decision page * [Version Adaptation](/dist/docs/3.1.20/building/cross-cutting/version-adaptation) — three-mechanism reference * [Conformance](/dist/docs/3.1.20/building/verification/conformance) — how the storyboard suite grades your agent * [Build an Agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent) — greenfield path; useful as a reference for what the L4-on-SDK end state looks like # AdCP and OpenRTB Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/concepts/adcp-vs-openrtb AdCP vs OpenRTB: how they differ and work together. AdCP handles agent workflows, OpenRTB handles impression-time decisions, and the Trusted Match Protocol connects the two for use cases like cross-publisher frequency capping. AdCP and OpenRTB are complementary standards that operate at different layers of the advertising stack. They are not competing — a platform can (and often will) implement both. One of the most important connection points is **cross-publisher frequency capping**. AdCP enables it the same way OpenRTB enables programmatic buying: by exposing each impression to a buyer-controlled real-time decision layer before the ad server serves. In AdCP, that integration point is the **[Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match)**. ## What each standard does | | OpenRTB | AdCP | | ------------------ | ------------------------------ | ------------------------------------------ | | **Layer** | Impression-level transactions | Agent-level workflows | | **Core operation** | Real-time bid request/response | Task-based campaign management | | **Participants** | DSPs and SSPs | AI agents and advertising platforms | | **Timing** | Real-time (milliseconds) | Asynchronous (seconds to days) | | **Scope** | Single impression auction | End-to-end campaign lifecycle | | **Maintained by** | IAB Tech Lab | AgenticAdvertising.org | | **Maturity** | Production (v2.6) | Production (v3.0) | | **Transport** | HTTP POST | MCP (tool calling) or A2A (agent-to-agent) | ## Where they overlap Both standards touch media buying, but at different granularities: * **OpenRTB** handles individual impression decisions: "Should I bid on this impression, and how much?" * **AdCP** handles campaign-level decisions: "What inventory is available? Execute this campaign with this budget and targeting." A single AdCP `create_media_buy` task might result in thousands of OpenRTB bid requests over the campaign's lifetime. ## Where they're different **Scope.** OpenRTB is focused on the auction — bid requests, bid responses, win notifications, and billing events. AdCP covers the full campaign lifecycle: product discovery, creative management, audience activation, campaign execution, and delivery reporting. AdCP also works independently of real-time bidding: a direct-sold publisher, sponsored content business, broadcast seller, or commerce media network can implement AdCP without any OpenRTB integration. **Communication model.** OpenRTB uses synchronous HTTP: a bid request arrives, and the bidder must respond within a few hundred milliseconds. AdCP is asynchronous: an agent submits a `create_media_buy` task, and the platform processes it on its own timeline, returning status updates. **Participants.** OpenRTB connects demand-side platforms (DSPs) to supply-side platforms (SSPs) in automated auctions. AdCP connects AI agents to any advertising platform — including but not limited to DSPs and SSPs. **Data model.** OpenRTB defines impression objects, bid objects, and deal objects. AdCP defines media products, media buys, creative formats, audience signals, and brand governance rules. ## How they work together A typical integration uses both standards at different layers: 1. A **buyer agent** uses AdCP to discover available products on a publisher's platform (`get_products`) 2. The agent creates a campaign via AdCP (`create_media_buy`) with budget, targeting, and scheduling 3. At impression time, the publisher sends a **TMP Context Match** (page content, available packages) and an **Identity Match** (opaque user token, package IDs) to the TMP Router 4. TMP evaluates cross-publisher exposure and returns offers and eligibility decisions — the publisher joins them locally 5. The buyer agent checks delivery via AdCP (`get_media_buy_delivery`) to monitor overall campaign performance In this model, AdCP handles the strategic layer (what to buy, how much to spend, who to target), TMP handles the real-time execution layer (which packages activate on which impressions), and OpenRTB handles the tactical auction layer where applicable (which specific impressions to win). ## TMP: The Real-Time Bridge The [Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match) is how AdCP reaches impression-time decisioning. It gives the buyer a real-time look at each impression opportunity so cross-publisher data can affect the serve decision, without turning AdCP itself into an auction protocol. TMP defines two structurally separated operations: ```mermaid theme={null} flowchart LR buyer["**Buyer Agent**
Creates media buy in AdCP"] pub["**Publisher**
Impression opportunity"] ctx["**Context Match**
Page content + packages
(no user identity)"] id["**Identity Match**
User token + package IDs
(no page context)"] join["**Publisher Join**
Intersect offers × eligibility"] decision["**Activate or suppress**"] buyer --> pub pub --> ctx pub --> id ctx --> join id --> join join --> decision ``` For cross-publisher frequency capping, this means: 1. AdCP defines the campaign, budget, and packages 2. At impression time, the publisher sends a Context Match request (which packages match this content?) and an Identity Match request (is this user eligible for these packages?) 3. The buyer's Identity Match agent checks exposure history across all publishers connected to the TMP Router — frequency caps, audience membership, and purchase history are evaluated here 4. The publisher joins the two responses locally: packages that matched the context *and* passed identity eligibility activate; everything else is suppressed The buyer never sees both context and identity simultaneously. Cross-publisher frequency capping is enforced through the Identity Match path, where the buyer maintains a shared exposure store across publishers. ## Other standards in the ecosystem AdCP and OpenRTB exist alongside several other standards: | Standard | Purpose | Maintained by | | --------------------------------- | ------------------------------------------------------------- | ------------- | | **MCP** (Model Context Protocol) | AI tool calling — how an AI model calls external tools | Anthropic | | **A2A** (Agent-to-Agent Protocol) | Multi-agent collaboration — how autonomous agents communicate | Google | | **VAST** / **VPAID** | Video ad serving and interactive video | IAB Tech Lab | | **ads.txt** / **sellers.json** | Supply chain transparency and authorized seller verification | IAB Tech Lab | | **Open Measurement SDK** | Viewability and attention measurement | IAB Tech Lab | AdCP uses MCP and A2A as transport layers. It references IAB content taxonomies and audience segment standards where applicable. ## Frequently asked questions No. They serve different purposes. OpenRTB handles real-time impression auctions. AdCP handles campaign-level agent workflows. A platform can implement both. No. AdCP works independently. A platform that doesn't use real-time bidding (for example, a direct-sold publisher or a commerce media network) can implement AdCP without any OpenRTB integration. Yes. When a buyer agent creates a media buy via AdCP, the sell-side platform can use any internal mechanism to fulfill the order — including OpenRTB auctions, direct insertion orders, private marketplace deals, or TMP-mediated real-time activation for things like cross-publisher frequency caps. No. AgenticAdvertising.org is an independent member organization. It is not a subsidiary, working group, or affiliate of IAB Tech Lab. However, AdCP is designed to be compatible with IAB standards. # How AI agents communicate ad specs across platforms Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/concepts/how-agents-communicate How AI advertising agents discover inventory, exchange campaign data, and execute buys across platforms using AdCP's standardized task schemas and MCP transport. When an AI agent manages a campaign across multiple platforms, it needs to do the same things on each one: find available inventory, submit a buy, provide creatives, and check delivery. The challenge is that every platform describes these operations differently. AdCP solves this by defining a standard set of tasks — each with a fixed request schema and response schema — that agents use regardless of which platform they're talking to. ## The communication problem Consider a buyer agent managing campaigns across three platforms: | Operation | Platform A | Platform B | Platform C | | -------------- | --------------------- | ------------------------ | ------------------ | | Find inventory | `GET /api/products` | `POST /inventory/search` | `GET /catalogue` | | Execute a buy | `POST /api/campaigns` | `PUT /orders/new` | `POST /media-buys` | | Check delivery | `GET /api/reports` | `POST /analytics/query` | `GET /stats/{id}` | Without a standard, the agent needs custom code for each platform — different endpoints, different field names, different response formats. This limits how many platforms an agent can work with. With AdCP, the agent uses the same tasks everywhere: | Operation | AdCP task | | -------------- | ------------------------ | | Find inventory | `get_products` | | Execute a buy | `create_media_buy` | | Check delivery | `get_media_buy_delivery` | The schemas are identical across platforms. Only the transport connection differs. ## Two transport protocols AdCP tasks travel over two protocols, depending on the integration type: ### MCP (Model Context Protocol) MCP is how AI assistants call external tools. An AdCP MCP server exposes tasks as tools that Claude, Cursor, or any MCP-compatible client can call. ``` AI Assistant → MCP Client → AdCP MCP Server → Platform ``` The agent calls `get_products` as a tool. The MCP server translates the request to the platform's internal API and returns a standardized response. **Best for:** Human-in-the-loop workflows where an AI assistant helps a media buyer interact with platforms. ### A2A (Agent-to-Agent Protocol) A2A is how autonomous agents communicate with each other. A buyer agent sends structured messages to a seller agent, which processes them and returns results — potentially over long-running operations. ``` Buyer Agent → A2A Client → Seller Agent → Platform ``` The buyer agent sends a `create_media_buy` task as a message. The seller agent processes it (which might take seconds or hours), streaming status updates back. **Best for:** Automated workflows where agents operate independently, with human approval at key checkpoints. ### Same tasks, different transport The critical point: AdCP task definitions are transport-agnostic. A `get_products` request has the same fields whether it travels over MCP or A2A. A platform implements the domain logic once and serves it over both transports. ## What agents exchange AdCP defines tasks across several domains. Here's what agents actually send back and forth in a typical campaign: ### Product discovery The buyer agent asks "what can I buy?" and gets back a structured catalog of media products — each with pricing, formats, targeting options, and delivery types. ```json theme={null} { "buying_mode": "brief", "brief": "Premium video inventory on sports content for Q2" } ``` The response includes product IDs, pricing models (CPM, CPC, flat rate), available creative formats, and audience reach estimates. The agent can compare products across platforms because the schema is the same everywhere. ### Creative specs Before submitting creatives, the agent checks what formats the platform accepts by calling `list_creative_formats`. The response includes structured format objects with dimensions, accepted file types, and rendering roles: ```json theme={null} { "formats": [ { "format_id": { "agent_url": "https://ads.publisher.example.com", "id": "video_preroll_16x9" }, "name": "Pre-roll video (16:9)", "renders": [{ "role": "primary", "dimensions": { "width": 1920, "height": 1080, "unit": "px" } }] } ] } ``` The agent then submits matching creatives via `build_creative`, which generates or adapts ads to the platform's requirements. ### Audience data Agents exchange audience signals — targeting segments, contextual data, first-party data — through `get_signals`. The buyer describes what they need, and the data provider returns matching segments with reach estimates and pricing, which the agent can evaluate before activating via `activate_signal`. ### Campaign execution The agent creates a media buy with budget, schedule, and brand identity: ```json theme={null} { "account": { "account_id": "acct-12345" }, "brand": { "brand_id": "nova-electronics" }, "proposal_id": "prop-sports-video", "total_budget": { "amount": 25000, "currency": "USD" }, "start_time": "2026-04-01T00:00:00Z", "end_time": "2026-06-30T23:59:59Z" } ``` The platform can process this immediately or asynchronously. AdCP's status system (`completed`, `working`, `submitted`, `input-required`) communicates progress back to the agent. ### Delivery reporting The agent calls `get_media_buy_delivery` to pull performance data — impressions, clicks, spend, and conversion events — in a standardized format. Because the delivery schema is the same across platforms, the agent can aggregate and compare performance without reconciling different metric names or calculation methods. ## A worked example Pinnacle Media, a fictional agency, runs a campaign for a consumer electronics brand across three AdCP-enabled platforms. ### Discover agents The buyer agent checks `adagents.json` on each publisher's domain to find their sales agents and supported protocols. ### Compare inventory The agent calls `get_products` on all three platforms in parallel. It receives structured product catalogs and compares pricing, formats, and reach — all in the same schema. ### Check creative requirements The agent calls `list_creative_formats` on each platform and identifies common formats across all three, reducing creative production to a shared set. ### Execute buys The agent calls `create_media_buy` on each platform with budget allocations based on its comparison analysis. Some platforms confirm immediately; others return `working` status and confirm later. ### Monitor delivery The agent polls `get_media_buy_delivery` across all three platforms daily, aggregating results into a unified performance view for the agency's planning team. The agency's team sets strategy and reviews results. The agent handles the cross-platform execution, format negotiation, and unified reporting. ## Getting started Detailed technical comparison of MCP and A2A as AdCP transports. Implementation guides, SDKs, and integration patterns. The buyer-side perspective: how agents automate media buying across platforms. How platforms expose their inventory to AI buyer agents. What sits behind a protocol-compliant agent, and whether to build or buy. # Why AdCP Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/concepts/index Why AdCP exists: the fragmentation problem across RTB, platform APIs, and direct IO — and how a universal agent protocol for advertising solves it. ## Allocation, Not Day Trading RTB treats advertising like day trading: *"What is this impression worth?"* It works for fungible inventory but commoditizes everything. AdCP enables portfolio-level allocation: *"How should I invest my ad budget?"* This mirrors how advertisers actually think—they buy outcomes, not impressions. Why the RTB mental model doesn't fit how advertisers actually make decisions. ## The Structural Problem Programmatic advertising defaults to a single question: *"What is this impression worth right now?"* That framing embeds CPM-based, auction-centric assumptions into the transaction layer — assumptions that do not hold for most global ad spend. Direct deals, sponsorships, broadcast, out-of-home, and retail media all operate on different pricing models, different timelines, and different success metrics. They are not edge cases; they are the majority. AdCP sits at the **campaign-workflow layer**, above the auction. It does not replace OpenRTB or compete with serve-time decisioning — it coordinates the planning, negotiation, proposal, and deal-creation steps that happen before and after the auction fires. The [protocol comparison](/dist/docs/3.1.20/building/concepts/adcp-vs-openrtb) explains how AdCP and OpenRTB coexist. By structuring these workflow steps as protocol-level tasks, AdCP enables agents to apply [brand identity](/dist/docs/3.1.20/brand-protocol/brand-json), [content standards](/dist/docs/3.1.20/governance/content-standards/), and governance checks at decision time — not as an afterthought. Agents that use these tasks can reason about brand suitability, creative fit, and compliance as part of the buying process rather than bolting them on post-execution. The protocol makes this possible; individual agents decide how much of it to use. ## The Fragmentation Problem Today, advertisers face three completely different buying systems: | Paradigm | Era | How It Works | | ---------------- | ----------------- | ------------------------------------- | | **RTB/Biddable** | Legacy web | Real-time bidding via OpenRTB | | **API-based** | Modern social, AI | Platform-specific APIs (Meta, TikTok) | | **Direct IO** | Legacy | Insertion orders, manual deals | Each requires different integrations, tools, workflows, and expertise. 90% of ad spend never touches RTB—it lives in walled gardens, direct deals, and premium inventory. AdCP creates a **universal API standard** that unifies all three under one umbrella. ## Omnichannel By Design Buying billboards is fundamentally different from buying social links: * **Different pricing**: Flat rate vs CPM vs engagement-based * **Different creatives**: Static images vs dynamic video vs conversational AI * **Different measurement**: Impressions vs engagement vs footfall AdCP creates a conceptual layer that abstracts these differences while preserving what makes each channel unique. One protocol, any channel. ## Why Agents? Intelligent agents reduce the cost of managing complex, negotiated deals: * **Adapt to nuances** without over-specifying everything in code * **Handle variability** across platforms and channels * **Natural language** lets buyers describe intent, not configure parameters * **Scale relationships** from 3-5 platforms to 20+ without scaling teams ## The Protocol Layer for AI AdCP isn't just unifying legacy systems—it's the protocol layer for emerging AI surfaces. ### Sponsored Intelligence Like VAST defined video ad serving, SI defines conversational brand experiences in AI assistants. When an AI says *"Delta has flights to Boston—want me to connect you with their assistant?"*—SI defines what happens next. How conversational AI changes the economics of advertising. ### Brand identity Standardized brand identity for AI-powered creative generation. Brands express who they are—colors, tone, assets—in formats AI systems can consume. Together, these support fully AI-powered systems like Performance Max that need structured brand inputs, not manual campaign configuration. Monetizing AI surfaces — the reversed data flow, product spectrum, and SI Chat Protocol. Standardized brand identity for AI creative generation. ## Design Implications These goals drive AdCP's technical design: * **Asynchronous**: Deals take time. This is not a real-time protocol—operations may take minutes to days. * **Human-in-the-loop**: Some decisions need human approval. Publishers can require manual sign-off. * **Multiple transports**: MCP and A2A provide the same tasks through different protocols. ## The Protocol Family | Protocol | Purpose | Key Tasks | | -------------------------- | -------------------------------------- | ---------------------------------- | | **Media Buy** | Campaign execution | `get_products`, `create_media_buy` | | **Signals** | Audience targeting | `get_signals`, `activate_signal` | | **Creative** | Ad creative management | `build_creative`, `sync_creatives` | | **Governance** | Brand suitability, quality, compliance | Property lists, content standards | | **Sponsored Intelligence** | Conversational brand experiences | `si_initiate_session` | | **Curation** | Inventory packaging | Coming soon | ## Next Steps MCP vs A2A—when to use which, and what's the same. Why agentic advertising raises the stakes, what AdCP defends against, and a checklist for brand IT and CISOs. JavaScript and Python libraries for AdCP. # AI advertising standards landscape Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/concepts/industry-landscape AI advertising standards landscape: how AdCP, OpenRTB, MCP, and A2A relate. Comparison of protocols, standards bodies, and their roles in agentic advertising. The key standards shaping AI-powered advertising are AdCP (agent workflow coordination), OpenRTB (impression auctions), MCP (AI tool calling), and A2A (agent-to-agent communication). Advertising is moving from programmatic (machine-executed auctions) to agentic (AI agents managing full campaign lifecycles), and these protocols define how the pieces connect. This page maps the landscape: what exists, who maintains it, and how they fit together. ## Active protocols ### Ad Context Protocol (AdCP) AdCP defines how AI agents interact with advertising platforms. It covers the full campaign lifecycle: product discovery, media buying, creative generation, audience activation, brand governance, and delivery reporting. | | | | ------------------- | ----------------------------------------------------------------------- | | **Scope** | Agent-level advertising workflows | | **Transport** | MCP (tool calling) or A2A (agent-to-agent) | | **Maintained by** | [AgenticAdvertising.org](https://agenticadvertising.org) | | **License** | Apache 2.0 (open source) | | **Current version** | 3.0 (GA, April 2026) | | **Key tasks** | `get_products`, `create_media_buy`, `build_creative`, `activate_signal` | AdCP is transport-agnostic: the same task definitions work over both MCP and A2A. A platform implements once; agents connect via either transport. ### OpenRTB OpenRTB handles real-time impression auctions — the bid request / bid response cycle that powers most programmatic display and video advertising. | | | | ------------------- | ----------------------------------------------------- | | **Scope** | Impression-level transactions | | **Transport** | HTTP POST | | **Maintained by** | [IAB Tech Lab](https://iabtechlab.com) | | **Current version** | 2.6 / 3.0 | | **Key objects** | Bid request, bid response, win notice, billing notice | OpenRTB and AdCP are complementary. OpenRTB handles "should I bid on this impression?"; AdCP handles "create a campaign with this budget and targeting." See [AdCP and OpenRTB](/dist/docs/3.1.20/building/concepts/adcp-vs-openrtb) for details. ### Model Context Protocol (MCP) MCP defines how AI models call external tools. Developed by Anthropic, it's the standard for connecting AI assistants to APIs, databases, and services. | | | | ----------------- | ------------------------------------------------- | | **Scope** | AI tool calling | | **Transport** | JSON-RPC over stdio or SSE | | **Maintained by** | [Anthropic](https://modelcontextprotocol.io) | | **Used by** | Claude, Cursor, Windsurf, and other AI assistants | AdCP uses MCP as one of its transport layers. An AdCP MCP server exposes advertising tasks (like `get_products` or `create_media_buy`) as tools that any MCP-compatible AI assistant can call. ### Agent-to-Agent Protocol (A2A) A2A defines how autonomous agents communicate with each other. Developed by Google, it enables multi-agent workflows where specialized agents collaborate. | | | | ----------------- | --------------------------------------- | | **Scope** | Agent-to-agent collaboration | | **Transport** | HTTP + JSON-RPC with SSE streaming | | **Maintained by** | [Google](https://google.github.io/A2A/) | | **Used by** | Multi-agent orchestration frameworks | AdCP uses A2A as its other transport layer. In an A2A setup, a buyer agent sends AdCP tasks to a seller agent as structured messages, with support for long-running operations via streaming. ## Standards bodies and organizations | Organization | Focus | Key outputs | | ------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------ | | **[AgenticAdvertising.org](https://agenticadvertising.org)** | AI agent advertising standards | AdCP specification, JSON schemas, client SDKs | | **[IAB Tech Lab](https://iabtechlab.com)** | Digital advertising standards | OpenRTB, VAST, ads.txt, sellers.json, content taxonomy | | **[Anthropic](https://anthropic.com)** | AI safety and research | MCP specification | | **[Google](https://google.github.io/A2A/)** | AI and cloud | A2A specification | | **[W3C](https://www.w3.org)** | Web standards | Privacy Sandbox APIs, Topics API | AgenticAdvertising.org is an independent member organization. It is not a subsidiary or working group of IAB Tech Lab, Anthropic, Google, or any other company. Its members include platform providers, advertisers, agencies, and developers. ## Other standards in the ecosystem | Standard | Purpose | Maintained by | | ------------------------------ | -------------------------------------- | ----------------------- | | **VAST** / **VPAID** | Video ad serving and interactive video | IAB Tech Lab | | **ads.txt** / **sellers.json** | Supply chain transparency | IAB Tech Lab | | **Open Measurement SDK** | Viewability and attention measurement | IAB Tech Lab | | **Unified ID 2.0** | Privacy-preserving identity | The Trade Desk / Prebid | | **Privacy Sandbox** | Cookie-less targeting APIs | Google / W3C | ## How the layers fit together These protocols operate at different layers of the stack: ``` ┌──────────────────────────────────────────────┐ │ Strategy layer (AdCP) │ │ Campaign planning, budget allocation, │ │ cross-platform coordination │ ├──────────────────────────────────────────────┤ │ Transport layer (MCP / A2A) │ │ How agents call tools and exchange data │ ├──────────────────────────────────────────────┤ │ Execution layer (OpenRTB / platform APIs) │ │ Impression-level auctions, ad serving, │ │ creative rendering │ ├──────────────────────────────────────────────┤ │ Measurement layer (OMSDK / UID2 / Privacy) │ │ Viewability, attribution, identity │ └──────────────────────────────────────────────┘ ``` A typical workflow: an AI agent uses **MCP** to call **AdCP** tasks on a publisher's platform, creating a campaign. The publisher's ad server uses **OpenRTB** to execute impression-level delivery. **OMSDK** measures viewability. **ads.txt** verifies the supply chain. ## What's changing Three trends are reshaping how these standards interact: **Agent-mediated buying.** Instead of humans navigating dashboards, AI agents will manage campaigns across platforms. This creates demand for standardized agent interfaces — which is what AdCP provides. **Protocol convergence.** MCP and A2A are establishing the transport layer for agent communication. Domain-specific protocols like AdCP build on top of them. This mirrors how HTTP became the transport layer and domain-specific APIs built on top. **Vertical specialization.** Generic agent protocols (MCP, A2A) handle communication. Vertical protocols handle domain logic. AdCP handles advertising. The same transport-plus-domain pattern may emerge in other verticals as agent adoption grows. ## Getting involved Understand the protocol architecture and core concepts. Participate in working groups that shape protocol direction. Detailed comparison of AdCP and OpenRTB — how they complement each other. Technical comparison of MCP and A2A as AdCP transport layers. # Managing Response Size Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/concepts/managing-response-size How to keep AdCP responses lean: field selection, pagination, buying modes, truncation flags, and the wire-vs-context distinction that matters most. When a buyer agent calls an AdCP seller, the response can range from a handful of curated products to a full wholesale catalog with detailed cards, signal metadata, and placement specs. This page covers the controls AdCP already provides to keep responses right-sized — and the architectural insight that makes most token-budget concerns a client-side problem, not a protocol problem. ## Wire response ≠ model context The single most important point: **the bytes on the wire are not what your model has to consume.** AdCP responses are structured data. An MCP response arrives as `structuredContent` — typed JSON that the client parses before any model sees it. An A2A response pairs a human-readable `TextPart` with an authoritative `DataPart`. In both cases, a well-built client stores the full response and projects or summarizes it before prompting the next model turn. ``` Seller agent → structured response (full data) ↓ Client stores raw response ↓ Client projects / summarizes → model context (lean) ``` If your agent is forwarding raw tool results into the model context unchanged, the fix is in the client, not the protocol. Store the response, extract what the model needs for its next decision, and reference the stored data by ID when details are needed later. **Naive-host caveat.** Some MCP hosts pass the entire `structuredContent` blob into the model context as-is. If you control the host, project before prompting. If you don't, the controls below become your primary defense against oversized context. ## Ask the seller to curate — don't pull the feed Use `buying_mode: "brief"` with a tight `pagination.max_results` to get curated recommendations: ```json theme={null} { "buying_mode": "brief", "brief": "Premium video placements for a CPG brand targeting US adults 25-54", "pagination": { "max_results": 5 } } ``` The seller returns its best matches — ranked, priced, and ready to execute. This is the lightweight path. **Avoid `buying_mode: "wholesale"` unless you're building a catalog mirror.** Wholesale enumerates the seller's entire product feed, paginated. It exists for storefronts and feed synchronization, not for discovery. If your agent is paginating through hundreds of wholesale results to find a few good ones, switch to `brief` mode and let the seller do the filtering. Use `buying_mode: "refine"` when iterating on a previous `brief` response — adjusting budget, narrowing geography, or swapping out products. Refine operates on the seller's existing curation rather than re-running discovery from scratch. ## `fields` selector for lightweight discovery When you only need a subset of product data, pass `fields` to restrict the response: ```json theme={null} { "buying_mode": "brief", "brief": "Sports streaming inventory for Q4", "fields": ["product_id", "name", "pricing_options"] } ``` This is useful when: * Your agent is scanning multiple sellers and only needs IDs and prices for initial comparison * You want to skip heavy fields like `product_card`, `product_card_detailed`, `placements`, or signal metadata * You're building a summary view before drilling into specific products Without `fields`, the seller returns the full product object — including visual card definitions, placement specs, and any bundled signal metadata. For a first-pass discovery across multiple sellers, that's more data than you need. ## Pagination for cardinality control All `get_products` modes support cursor-based pagination: | Parameter | Description | | ------------------------ | --------------------------------------------- | | `pagination.max_results` | Maximum products per page (1–100, default 50) | | `pagination.cursor` | Opaque cursor from the previous response | Response: | Field | Description | | ------------------------ | ------------------------------------------- | | `pagination.has_more` | Whether additional pages exist | | `pagination.cursor` | Cursor for the next page | | `pagination.total_count` | Total products in the result set (optional) | **For brief and refine modes**, set `max_results` to the number of options your agent can actually reason about. Five curated products are more useful than fifty if the model is going to compare them anyway. **For wholesale mode**, pagination walks the feed. Pass `cursor` from each response to get the next page. If you're maintaining a mirror, check [`wholesale_feed_versioning`](/dist/docs/3.1.20/media-buy/task-reference/get_products#wholesale-feed-versioning) to skip unchanged feeds entirely. ## Truncation flags on delivery [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) returns breakdown arrays (by geo, by creative, by day, etc.) that can grow large. Each breakdown array has a sibling boolean flag — `by_geo_truncated`, `by_creative_truncated`, etc. — that tells you whether the returned rows are the complete set or just the top-N: | Flag value | Meaning | | ---------- | ---------------------------------------------- | | `false` | All rows are present | | `true` | Additional rows exist beyond what was returned | When a flag is `true`, the returned rows are sorted by the requested metric descending — you have the most significant breakdowns, and the tail is omitted. This is by design: delivery breakdowns are for optimization decisions, not archival reporting. If you need the full dataset, use the seller's native reporting API. ## Putting it together A token-efficient AdCP integration follows this pattern: 1. **Discover with `brief` + `fields` + tight `max_results`.** Get curated products with only the fields you need for initial comparison. 2. **Refine with `refine` mode.** Iterate on the seller's curation rather than re-querying. 3. **Store raw responses client-side.** Don't feed full product objects into the model context. Extract what matters, summarize the rest. 4. **Read truncation flags before paginating delivery.** If `by_geo_truncated: false`, you already have everything — no need for follow-up calls. 5. **Use wholesale only for feed sync.** If your use case is catalog mirroring, wholesale + conditional versioning is the right tool. For everything else, `brief` is cheaper. Full request/response schema including `fields`, `buying_mode`, and pagination. Breakdown arrays, truncation flags, and sort semantics. MCP vs A2A transport and how responses are structured. Product structure, `product_card` vs `product_card_detailed`, and rendering. # Protocol Comparison Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/concepts/protocol-comparison MCP vs A2A for AdCP: side-by-side comparison of transport formats, async handling, status systems, and when to use each protocol for advertising agent integration. Both MCP and A2A provide identical AdCP capabilities using the same unified status system. They differ only in transport format and async handling. ## Quick Comparison | Aspect | MCP | A2A | | ------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------- | | **Request Style** | Tool calls | Task messages | | **Response Style** | Direct JSON | Artifacts | | **Status System** | Unified status field | Unified status field | | **Async Handling** | AdCP status payloads over tool calls; optional MCP Tasks wrapper | AdCP status payloads in A2A artifacts; optional SSE streaming | | **Webhooks** | `push_notification_config` in tool args | Native PushNotificationConfig | | **Task Management** | AdCP `task_id` via webhooks or AdCP polling (`get_task_status`, legacy `tasks/get`) | AdCP `task_id` via webhooks or AdCP polling | | **Context** | Manual (pass context\_id) | Automatic (protocol-managed) | | **Best For** | Claude, AI assistants | Agent workflows | ## Unified Status System Both protocols use the same status field with consistent values. ### Status Handling (Both Protocols) Every response includes a status field that tells you exactly what to do: ```json theme={null} { "status": "input-required", // Same values for both protocols "message": "Need your budget", // Same human explanation // ... protocol-specific formatting below } ``` | Status | What It Means | Your Action | | ---------------- | ---------------------------- | --------------------------------------------------- | | `completed` | Task finished | Process data, show success | | `input-required` | Need user input | Read message, prompt user, follow up | | `working` | Processing (\< 120s) | Wait for the open call/stream result; show progress | | `submitted` | Long-running (hours to days) | Provide webhook or poll less frequently | | `failed` | Error occurred | Show error, handle gracefully | | `auth-required` | Need auth | Prompt for credentials | See [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for complete status handling guide. ## Transport Format Differences Same status and data, different packaging: ### MCP Response Format ```json theme={null} { "status": "input-required", "message": "I need your budget and target audience", "context_id": "ctx-123", "products": [], "suggestions": ["budget", "audience"] } ``` ### A2A Response Format ```json theme={null} { "status": "input-required", "contextId": "ctx-123", "artifacts": [{ "artifactId": "artifact-product-discovery-xyz", "name": "product_discovery", "parts": [ { "kind": "text", "text": "I need your budget and target audience" }, { "kind": "data", "data": { "products": [], "suggestions": ["budget", "audience"] } } ] }] } ``` ## Async Operation Differences Both protocols carry the same AdCP status progression: `submitted` → `working` → `completed`/`failed` The progression above is AdCP application state. MCP Tasks and A2A task state are transport mechanics that can deliver an AdCP payload, but they are not a replacement for AdCP task polling or reconciliation. ### MCP Task-Augmented Call (Transport Wrapper) ```javascript theme={null} // Task-augmented tool call — returns CreateTaskResult immediately const createResult = await mcp.callTool({ name: "create_media_buy", arguments: { buyer_ref: "acme_q1", packages: [...], push_notification_config: { // Optional: webhook for session-outliving ops url: "https://buyer.com/webhooks/adcp/create_media_buy/op_123", authentication: { schemes: ["HMAC-SHA256"], credentials: "secret" } } }, task: { ttl: 86400000 } // Request task-augmented execution }); // createResult.task = { taskId: "task-456", status: "working", pollInterval: 5000 } // Client polls via MCP Tasks protocol (outside the LLM loop) const status = await mcp.getTask({ taskId: "task-456" }); // status = { taskId: "task-456", status: "completed", ... } // Retrieve the actual CallToolResult, then inspect the AdCP payload inside it const result = await mcp.getTaskResult({ taskId: "task-456" }); // result = { content: [...], isError: false } const adcpResponse = result.structuredContent; if (adcpResponse.status === "submitted") { // The MCP task delivered the AdCP response; the media-buy workflow is still open. await adcp.call("get_task_status", { task_id: adcpResponse.task_id, include_result: true }); } ``` ### A2A Async Pattern ```javascript theme={null} // Initial response carries an AdCP payload inside A2A task/artifact transport { "status": "submitted", "task_id": "adcp-task-456", "contextId": "ctx-123", "estimatedCompletionTime": "2025-01-23T10:00:00Z" } // Real-time updates via SSE const events = new EventSource(`/tasks/${response.taskId}/events`); events.onmessage = (event) => { const update = JSON.parse(event.data); console.log(`Status: ${update.status}, Message: ${update.message}`); }; // Native webhook support await a2a.send({ message: { /* skill invocation */ }, push_notification_config: { webhook_url: "https://buyer.com/webhooks", authentication: { schemes: ["Bearer"], credentials: "secret_token_min_32_chars" } } }); ``` ## Context Management ### MCP: Manual Context ```javascript theme={null} let contextId = null; async function callAdcp(request) { if (contextId) { request.context_id = contextId; } const response = await mcp.call('get_products', request); contextId = response.context_id; // Save for next call return response; } ``` ### A2A: Automatic Context ```javascript theme={null} // A2A manages context automatically const response1 = await a2a.send({ message: "Find video products" }); const response2 = await a2a.send({ contextId: response1.contextId, // Optional - A2A tracks this message: "Focus on premium inventory" }); ``` ## Clarification Handling Both protocols use the same `status: "input-required"` pattern: ```javascript theme={null} // Works for both MCP and A2A function handleResponse(response) { if (response.status === 'input-required') { const info = promptUser(response.message); return sendFollowUp(response.context_id, info); } if (response.status === 'completed') { return processResults(response); } } ``` ## Error Handling Both use `status: "failed"` with same error structure: ```json theme={null} { "status": "failed", "message": "Insufficient inventory for your targeting criteria", "context_id": "ctx-123", "error_code": "insufficient_inventory", "suggestions": ["Expand targeting", "Increase CPM"] } ``` ## Choosing a Protocol ### Choose MCP if you're using: * Claude Desktop or Claude Code * MCP-compatible AI assistants * Simple tool-based integrations * Direct JSON responses ### Choose A2A if you're using: * Google AI agents or Agent Engine * Multi-modal workflows (text + files) * Real-time streaming updates * Artifact-based data handling ### Both protocols provide: * Same AdCP tasks and capabilities * Unified status system for clear client logic * Context management for conversations * Async operation support * Human-in-the-loop workflows * Error handling and recovery ## Next Steps * **MCP Guide**: See [MCP Guide](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide) for tool calls and context management * **A2A Guide**: See [A2A Guide](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide) for artifacts and streaming * **Both protocols**: Provide the same capabilities with unified status handling # Security Model Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/concepts/security-model Why agentic advertising raises the stakes for security, the threats AdCP is designed to defend against, and a checklist for security and IT leaders evaluating an AdCP deployment. For CISOs, security architects, and third-party risk reviewers evaluating an AdCP deployment — on either side of the transaction (brands, agencies, publishers, platforms, data providers). The [implementation reference](/dist/docs/3.1.20/building/by-layer/L1/security) has the normative rules; this page explains the model behind them. ## Why security is foundational, not an add-on In traditional advertising, a human reviews an insertion order before money moves. In agentic advertising, the agent is the human. It evaluates briefs, negotiates terms, places buys, handles reporting, and decides whether to retry a failed transaction — often without a person in the loop until something already happened. That shift concentrates risk in three ways: * **Authority is portable.** A credential that can spend \$10M/year fits in a token. A stolen token with the right scope can create real media buys against real budget, and the buy will look legitimate to every downstream system because it *is* legitimate — from the protocol's point of view. * **Decisions are fast.** An agent can run a full plan-to-purchase loop in seconds. A compromised loop can burn through a day's budget in minutes. There is no ad ops team watching line items populate. * **The attacker uses the same tools you do.** AI can red-team an API as fast as it can use one. If your agent has a documented surface (and it should — that's how other agents discover it), an adversary's agent can enumerate it, probe it, and fuzz it at machine speed. Security-by-obscurity is not a control. These are the conditions AdCP was built to withstand. The rest of this page is how. A breach surface in agentic ad tech is not "data exposure." It is **unauthorized financial commitments**, **bypassed governance**, **cross-tenant data leakage between advertisers on the same platform**, and **tampering with audit trails that regulators will later ask to see**. Each of those has a named threat model in the implementation reference. This page steps back and explains why. ## What changes in the threat model **Everything from traditional API security still applies** — authentication, authorization, rate limiting, input validation, transport security, data-at-rest encryption, endpoint hardening, logging. An agent is an HTTP service on the public internet; every control you would apply to a REST API you still apply here. AdCP does not replace that baseline, and this page does not re-teach it. What agentic advertising *adds* is a second layer of concerns, on top of the traditional ones: | Traditional API security already covers... | Agentic advertising additionally requires... | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Authenticating the human user | Authenticating the [agent](/dist/docs/3.1.20/reference/glossary#a) *on behalf of* a brand or agency, and proving that brand authorized this specific spend | | Preventing data exfiltration | Preventing *unauthorized state changes* — agents retry, loop, and fan out; a single successful injection can execute many times | | Rate limiting abusive callers | Preventing **replay attacks**: an agent retrying a \$1M media buy on a network timeout must never create two | | Input validation | **Counterparty URL validation**: agents fetch from URLs other agents supply (webhooks, registries, JWKS, reporting buckets) — each is an [SSRF](/dist/docs/3.1.20/reference/glossary#s) vector into your internal network | | Audit logging | **Cryptographically signed governance attestation** that survives the transaction and is verifiable by a regulator years later, without trusting either party | | Single-tenant isolation | **Multi-agent, multi-account isolation on shared infrastructure** — one compromised agent must not see another agent's buys, creatives, or targeting | None of this is novel cryptography. What's new is the combination — well-understood primitives operating autonomously, at machine speed, across party boundaries — with the controls on the left now backstopping decisions humans used to make. ### Threats specific to agentic advertising Three attack classes don't appear in a traditional API threat model but belong in this one: * **Credential reuse across accounts under one agent.** An agency agent typically holds credentials that work across every brand in its authorized-account set. A stolen agent token is therefore a multi-brand breach, not a single-brand one. AdCP's per-`(agent, account)` cache scoping (see [Agent and Account Isolation](/dist/docs/3.1.20/building/by-layer/L1/security#agent-and-account-isolation)) and signed governance tokens (bound to a specific plan and seller) limit *what* can be done with a stolen credential, but don't prevent the theft. Credential hygiene in agentic systems is proportionally more critical than in single-tenant APIs. * **Shared-governance-agent supply chain.** A governance agent often signs for many brands from a single origin. Its compromise is a multi-tenant breach. The JWKS / revocation-list requirements in the [governance profile](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context) limit the blast radius and make rotation observable, but the buyer's due-diligence posture toward its governance agent is a real-world security dependency — treat the governance agent as a processor with multi-customer blast radius and assess it accordingly. * **Cross-principal key reuse on multi-tenant operators.** Any operator hosting agents on behalf of more than one principal — a governance agent serving multiple brands, a buyer agent serving multiple advertisers, a sales agent serving multiple publishers — MUST scope signing keys per principal rather than reuse one key across the fleet. Concretely, each `keyid` MUST bind to a single principal so that a single compromised key reduces to a single-principal breach and revocation is granular. A convention such as `{operator}:{principal}:{key_version}` is a useful operator-side bookkeeping aid, but the `kid` value itself is opaque to verifiers per RFC 7517 — verifiers MUST NOT parse `kid` structure to derive principal identity or make authorization decisions, and MUST resolve the owning principal via the authenticated signature → JWKS → agent entry chain, using the `kid` only as an index into the JWKS. Operators that invent a structured convention thus create an internal bookkeeping tool, not an on-wire authorization input. Operators SHOULD advertise the isolation property in their capability surface as `identity.per_principal_key_isolation: true` so counterparties can verify the property without reading out the JWKS by hand. * **Prompt injection exfiltrating agent-side credentials.** Planners, creative-review agents, brief-interpretation pipelines all process untrusted text (briefs, creative metadata, product descriptions, campaign names) while holding credentials. A successful injection can cause the agent to issue unauthorized tool calls or leak tokens into logs, external URLs, or downstream agent messages. AdCP cannot prevent this at the protocol layer, but every operator running an LLM-powered agent needs input sandboxing, egress controls on tool calls (which URLs / which tools can the agent reach from within a given prompt context), and monitoring for anomalous credential use. The protocol-layer slice that *is* addressable — keeping buyer-principal credentials off the LLM-visible task payload entirely — is the [Credential placement](/dist/docs/3.1.20/building/by-layer/L2/authentication#credential-placement) rule: credentials MUST arrive on the transport's authentication channel and never inside request args. This is the most likely near-term breach vector in the space and is not solved by protocol compliance alone. * **Cross-principal tool-call confusion.** A buyer agent typically holds active credentials for *multiple* principals at once — several sellers (one set of credentials per seller) and several brand accounts (inside a single agency agent's authority set). LLM-driven agents often expose every one of those tool surfaces to the same planning loop. A prompt injected via text returned from seller X (a product description, a campaign name, a rejection reason) can cause the agent to call a tool on seller Y's endpoint, or to call `create_media_buy` for brand A using a budget authorized for brand B. This is the classical [confused deputy](https://en.wikipedia.org/wiki/Confused_deputy_problem) problem at LLM-tool-call granularity. The protocol-layer defense is in Layer 2 (account scoping on every tool call, refusing any cross-account action the caller does not hold authority for); the operator-layer defense is to tag each inbound string with its principal of origin, refuse tool calls whose target principal differs from the principal that supplied the string unless a human approves, and forbid a single LLM context from holding credentials for principals whose interests can conflict. This threat is distinct from ordinary prompt injection: the attacker does not need to escape the sandbox to use the *victim principal's* credentials — the victim's own agent does it for them. ### Structural privacy separation AdCP is designed so that parties learn only what they need to act. This is enforced by protocol structure, not just policy. Examples: * **[Trusted Match Protocol](/dist/docs/3.1.20/trusted-match)** splits impression-time decisions into two independent calls: *Context Match* carries content signals (topic, sentiment, embeddings) with no user identity; *Identity Match* carries an opaque user token with no page context. Neither call alone reveals which user visited which page — the decomposition is the privacy property. * **Signals Protocol** returns `activation_key` values to authenticated callers with deployment access only, and structurally separates marketplace catalog access (public) from private-signal disclosure (account-scoped). * **Governance tokens** use `policy_decision_hash` instead of inline `policy_decisions` when the buyer's compliance posture is sensitive — the full decision log remains available to auditors via the signed `audit_log_pointer`, under the governance agent's access control. * **Audience members** in `sync_audiences` use `hashed_email` and `hashed_phone` fields whose schemas require SHA-256 hashing on the buyer side and structurally reject cleartext. Note that an unsalted SHA-256 of an email or phone is pseudonymous PII, not anonymous — it is recoverable via precomputed dictionaries, so operators MUST treat hashed identifiers as PII for retention and consent. See [Privacy Considerations](/dist/docs/3.1.20/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous). "Structural" here means an attacker who compromises one leg of a split workflow gains no information that was designed to live only in the other leg. It's a weaker guarantee than cryptographic confidentiality but a stronger one than policy alone. ## AdCP's layered defense model AdCP defends against these threats with five layers. Each one is a separate control; a failure in one does not collapse the others. This is the same defense-in-depth pattern used in payment systems — the five layers below describe *what* every compliant implementation must get right. *How* you build them is yours. ```mermaid theme={null} flowchart TB A[Request arrives] --> B["Layer 1: Identity
mTLS / signed requests / API key"] B --> C["Layer 2: Isolation
Per-agent, per-account scope"] C --> D["Layer 3: Idempotency
At-most-once execution"] D --> E["Layer 4: Signed Governance
JWS from governance agent"] E --> F["Layer 5: Auditability
Replay-proof audit trail"] F --> G[Execute side effects] style B fill:#e0f2fe,stroke:#0369a1 style C fill:#e0f2fe,stroke:#0369a1 style D fill:#e0f2fe,stroke:#0369a1 style E fill:#e0f2fe,stroke:#0369a1 style F fill:#e0f2fe,stroke:#0369a1 ``` ### Layer 1: Identity — who is actually calling? Before any other check, the seller must establish *which authenticated agent* is making the request. AdCP defines three mechanisms; the version-gating determines which are permitted for which operation class: * **RFC 9421 signed HTTP requests** — the buyer signs each request with a key declared in its public agent registry. *Recommended in 3.0 for all authenticated operations; REQUIRED for mutating / financial operations in 3.1+.* * **mTLS** — the buyer presents a client certificate resolving to a registered domain. *Permitted for any operation in 3.0 and 3.1+.* * **Bearer tokens** (pre-provisioned API key or JWT) — issued by the seller at onboarding, mapped to the buyer. *Permitted in 3.0 as the effective baseline; **PROHIBITED for mutating / financial operations in 3.1+**, read-only thereafter.* The normative matrix and verifier rules live in [Authentication](/dist/docs/3.1.20/building/by-layer/L2/authentication#authentication-method); the 3.0 → 3.1 sunset for bearer on mutating operations is logged under [known limitations](/dist/docs/3.1.20/reference/known-limitations#authentication-and-identity). **What this defends against.** An attacker cannot claim to be Acme by setting an `iss` field or a `caller` header. Identity is bound to something the attacker cannot forge (a private key, a certificate, or a pre-shared secret). Every subsequent layer uses the authenticated agent as its scope — get this wrong and the rest of the stack is decorative. ### Layer 2: Isolation — one agent cannot see another Every piece of state — media buys, creatives, idempotency cache entries, session IDs, governance tokens — is scoped to the agent that created it and the [account](/dist/docs/3.1.20/reference/glossary#a) that authorized the work. Queries that forget the scope leak data across tenants; AdCP requires sellers to scope every read by the authenticated agent and its authorized accounts, and to return a generic "not found" rather than leak existence across the boundary. Implementations typically enforce this at the database layer (Postgres row-level security is the canonical pattern) so a bug in one handler cannot punch through the wall. Within the tenant boundary, not every caller gets the same grant. A seller may issue one agent a full media-buy scope and another agent a narrow read + webhook-attach scope (e.g., the [`attestation_verifier`](/dist/docs/3.1.20/accounts/overview#standard-named-scope-attestation_verifier) scope for AAO Verified compliance engines). Callers discover their own grant via the `authorization` object attached to per-account entries in [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) responses; sellers enforce locally and reject out-of-scope requests with `SCOPE_INSUFFICIENT`, `READ_ONLY_SCOPE`, or `FIELD_NOT_PERMITTED`. **What this defends against.** Competitive intelligence leaks. An attacker authenticated as Agent A cannot probe Agent B's media buys, creatives, or idempotency keys — not by ID guessing, not by timing side-channels, not by error-message differencing. A legitimately-authenticated agent with a narrow scope cannot escalate into tasks or fields it was not granted. ### Layer 3: Idempotency — at-most-once execution Every mutating AdCP request carries a required [`idempotency_key`](/dist/docs/3.1.20/reference/glossary#i). The seller stores the first successful response under that key, scoped to the authenticated agent, with a declared replay TTL (minimum 1h, recommended 24h, maximum 7d). A retry with the same key and the same payload returns the cached response and marks it `replayed: true`. A retry with a *different* payload under the same key is rejected with `IDEMPOTENCY_CONFLICT`. This is the control that makes retries safe. Without it, a network timeout on `create_media_buy` forces the buyer to choose between double-booking (retry) and abandoning a legitimate buy (don't retry). With it, the same bytes always produce the same outcome — exactly once. **What this defends against.** Double-booking from retries. Replay attacks from a stolen-then-reused request. Duplicate webhooks from agent side effects ("Campaign created!" notifications, downstream tool calls, LLM memory writes). `replayed: true` lets every downstream system know whether a response represents a new event or a cached one. The full normative rules, including payload canonicalization and the oracle-resistance properties of the error taxonomy, are in [Request Safety](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency). ### Layer 4: Signed governance — cryptographic proof of approval When a plan is approved for spend, the governance agent issues a signed JWS token — not a shared secret, not an opaque cookie, but a public-key-verifiable attestation bound to: * **`sub`** — the specific plan being authorized * **`aud`** — the specific seller allowed to act on it * **`phase`** — whether this is intent, purchase, modification, or delivery * **`exp`** — when the authorization expires (15 min for intent, ≤30 days for execution) * **`jti`** — a unique token ID used for replay dedup The seller fetches the governance agent's public keys via JWKS, verifies the signature, runs the 15-step verification checklist, and only then treats the request as approved. Auditors and regulators can verify the same token years later using the same public keys — neither buyer nor seller can retroactively forge an approval. **What this defends against.** Unauthorized spend. A compromised buyer credential alone cannot create a media buy — the attacker also needs a valid, unrevoked governance token signed by the buyer's governance agent, bound to this specific seller, for this specific plan, for this specific operation, within its validity window (±60s clock-skew tolerance on `iat`/`nbf`/`exp`; see the [implementation reference](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context) for exact bounds), whose `jti` has not been seen before. ### Layer 5: Auditability — the trail survives the transaction Every protocol event produces structured, correlated records: the signed governance token, the `idempotency_key` and its `replayed` flag, the request ID chain, and — for governance-controlled events — a revocable audit log pointer. These are *queryable by auditors* via `get_plan_audit_logs`, not private to either buyer or seller. Key properties: * **Revocation.** Governance agents publish a signed revocation list at a well-known path. Compromised keys and rescinded plans can be invalidated without trusting the CDN serving the list. * **Retention.** Revoked public keys remain discoverable for 7+ years so historical tokens remain verifiable after rotation. * **Approval provenance.** Because the governance attestation is signed and public-key-verifiable, any party holding the artifact can verify it was approved by the holder of the signing key at the stated time. This approaches non-repudiation — but only conditionally. The buyer cannot later claim the plan was *never approved* so long as (a) the signing key was not compromised at time-of-signing (revocation lists bound this — a post-hoc claim of "the key was already stolen when I signed" is falsifiable against the revocation timeline) and (b) the signer retains ordinary custody of its signing key. The seller side is *weaker*: an attestation proves the plan existed, not that it was delivered or acknowledged. For full bilateral non-repudiation, the seller should emit a signed `plan_receipt` binding `{plan_id, received_at, plan_sha256}` as a signed webhook using an `adcp_use: "request-signing"` key — durable seller-side acknowledgement flows through the same at-rest webhook path as every other webhook artifact (see [What gets signed](#what-gets-signed--and-what-doesnt) below). Absent a signed receipt, "never received" remains deniable. **What this defends against.** After-the-fact tampering. Claim drift between parties in a dispute. Regulatory inquiries that arrive long after the credentials have rotated. ## What gets signed — and what doesn't Five application-layer signing surfaces exist in 3.x — four sharing the JWKS publication pattern, plus TMP's own envelope: * **Inbound request signing.** Buyers (and sellers acting as buyer-side clients) sign their outbound tool calls with [RFC 9421](/dist/docs/3.1.20/building/by-layer/L1/security#request-signing). Key purpose `adcp_use: "request-signing"`. * **Outbound webhook signing.** Sellers sign asynchronous [webhook deliveries](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-callbacks) — task completion, status changes, downstream events, and any specialism-scoped durable artifact (brand-rights, AAO Verified compliance, sales-intelligence relay, governance receipts). RFC 9421. Key purpose `adcp_use: "request-signing"`; deprecated `adcp_use: "webhook-signing"` keys remain accepted on the webhook path for backward compatibility. * **Governance attestation signing.** Governance agents sign the JWS tokens that authorize spend (Layer 4 above). Distinct profile from RFC 9421, with its own key purpose (`adcp_use: "governance-signing"`), JWKS, and revocation list. * **Designated-task response payload signing.** A closed list of tasks — currently `verify_brand_claim` and its bulk variant `verify_brand_claims` — sign their response *payload* as a [JWS envelope](/dist/docs/3.1.20/building/by-layer/L1/security#request-signing) carried inside the response body. Key purpose `adcp_use: "response-signing"`. The signature is load-bearing for the brand-protocol direction-asymmetric trust model; receivers parse the response body and verify the JWS against the responding agent's published key. This is payload-envelope JWS, not RFC 9421 §2.2.9 transport response signing — the latter is not defined in 3.x for any task, including the designated ones. See [Brand Protocol: trust model](/dist/docs/3.1.20/brand-protocol/tasks/verify_brand_claim#trust-model). * **Trusted Match Protocol envelope.** TMP signs match-time requests with its own Ed25519 envelope, scaled to TMP's per-request budget (sample-verify at \~5%); it shares JWKS publication with the four surfaces above but is its own profile. See [TMP signing model](/dist/docs/3.1.20/trusted-match/specification#signing-model). **Synchronous AdCP responses are NOT signed at the transport layer.** Outside the designated-task list (`verify_brand_claim` family — payload-envelope JWS, not RFC 9421 transport), buyers MUST NOT rely on response signatures; integrity guarantees for synchronous replies are delivered by TLS within the authenticated session. Artifacts requiring at-rest attestation MUST be delivered via signed webhooks. This applies symmetrically to MCP `tools/call` replies and to A2A non-streaming responses (and their streaming `artifactUpdate` frames); A2A push-notification delivery already rides the signed-webhook path covered by the second surface above. ### Why the split is deliberate This is two delivery surfaces with one RFC 9421 signing purpose, not a synchronous-response coverage gap. * **TLS-scoped synchronous + signed-webhook async is the design.** The synchronous reply is consumed inside the authenticated session that carried the request — the buyer holds TLS-bound proof that the seller's authenticated edge answered, with the same edge-termination caveats that govern request-side body integrity at body-modifying CDNs (see [Transport security: edge-termination](/dist/docs/3.1.20/building/by-layer/L1/security#what-this-section-does-not-replace)). A signature on the body would protect against post-edge tampering but does not protect against anything the authenticated TLS session hasn't already established between the parties' authenticated edges. At-rest integrity, by contrast, is what webhooks are for: the artifact survives past the original transport and verifies long after the session has closed, against keys that long-outlive the TCP connection. * **Webhook-only is a forcing function for sellers.** Making "this artifact needs at-rest integrity" an *explicit modeling decision* — emit a webhook — rather than a free rider on every reply pushes operators toward intentional design. Sellers who would otherwise reach for a generic response-signing primitive "for completeness," without asking *which* artifacts actually warrant attestation, are pushed to make the call up front. * **Doubling the signing surface is operationally fragile.** Every additional signing purpose or profile is another key declaration in the JWKS, another rotation cycle, another verifier code path, another conformance grader, another revocation entry to monitor. The cost is borne by every adopter for every deployment; the benefit accrues to a narrow set of audit and forwarding flows that have a cleaner path through webhooks. Net negative across the ecosystem. ### Cases that look like response signing but aren't * **Audit and forensics on tool-call replies.** A buyer that needs to attest "the seller said X at time T" requests the artifact via a webhook-emitting tool path, not via the synchronous reply. The asymmetry forces the right design question: which replies actually need at-rest integrity? In practice, fewer than instinct suggests. * **Cross-agent forwarding** (sales-intelligence relay, brand-rights handoff, AAO Verified compliance attestations). The durable artifact in each of these flows rides the standard signed-webhook path using `adcp_use: "request-signing"` — there is no per-specialism `adcp_use` value in 3.x, and no general-purpose response-signing primitive (the closed designated-task list above is the only response-payload-signing surface, and these flows aren't on it). The specialism delivers its attestable payload as a signed webhook from its own agent; that's already the answer. * **Bilateral non-repudiation receipts** (e.g., a seller's signed `plan_receipt` binding `{plan_id, received_at, plan_sha256}`). Where the spec recommends the seller emit such a receipt, it is delivered as a signed webhook using the same `adcp_use: "request-signing"` key purpose — not on the synchronous reply that acknowledged the inbound governance attestation. ### The request-the-webhook pattern If you genuinely need an attestable artifact from a tool that today returns it synchronously, the spec-supported path is to structure the tool to emit a signed webhook carrying the canonical version, and treat the synchronous reply as transport-only acknowledgement. The buyer registers a webhook on the request; the seller delivers the durable artifact via that webhook with `adcp_use: "request-signing"` (deprecated `webhook-signing` keys are still accepted during the compatibility window). Verification is uniform with every other at-rest seller-to-buyer message, no new specialism, no new grader. Some 3.x tools that today return durable artifacts synchronously (e.g., `acquire_rights` returning `rights_constraint` and `generation_credentials` on the synchronous reply, or any tool whose seller-side receipt is currently delivered inline) are candidates to either restructure under this pattern or accept that their durable-integrity path is the webhook variant — not a future synchronous-response signature. This decision is the resolution of [#3737](https://github.com/adcontextprotocol/adcp/issues/3737); revisitable in 4.0 if the threat model evolves (e.g., a transport pattern emerges where a synchronous reply carries durable state that does not also flow through a webhook). ## What to verify before going live If you are approving an AdCP deployment — as a brand CISO, a security architect at a publisher, or the IT lead at an agency — these are the questions to ask your team (or your vendor). Each maps to one of the layers above. ### Identity * [ ] How is the calling agent authenticated? (RFC 9421 signed requests, mTLS, or Bearer/API key — not a header field, not `iss`. For mutating / financial operations, plan the migration off Bearer before the 3.1 sunset — see [Authentication](/dist/docs/3.1.20/building/by-layer/L2/authentication#authentication-method).) * [ ] Where are tokens stored? (KMS / secret manager — not files, not env vars at rest) * [ ] Is the rotation cadence right-sized to blast radius and documented? (≤24h is a reasonable default for write-capable tokens; tighter windows are appropriate for tokens that commit spend at scale or cross organizational boundaries.) * [ ] What is the revocation path, and who can execute it in under an hour? ### Isolation * [ ] Is agent/account isolation enforced at the database layer (row-level security), not just in application code? * [ ] Do error messages leak existence across agents or accounts? ("Not found" for both "doesn't exist" and "exists but not yours") * [ ] Are idempotency keys, session IDs, and governance tokens scoped per authenticated agent, never shared across the tenant boundary? ### Idempotency * [ ] Is `capabilities.idempotency.replay_ttl_seconds` declared, and does the declared value match the implementation's actual cache retention? * [ ] Does the implementation reject missing or malformed keys with `INVALID_REQUEST` before touching business logic? * [ ] Is the idempotency cache shared across instances (so a restart doesn't allow a silent double-execution)? * [ ] Are successful responses cached? (Errors must not be cached, or the system locks buyers out for their TTL.) ### SSRF discipline * [ ] Does every outbound fetch to a counterparty URL (webhooks, JWKS, adagents.json, reporting buckets) run the full 6-point check: HTTPS-only, reserved-IP deny list, IP pinning, no redirects, size and timeout caps, suppressed error detail? * [ ] Is the reserved-IP deny list enumerated from an authoritative source (IANA, cloud-provider documentation) and reviewed each time you add a new cloud provider or region? See the [implementation reference](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-url-validation-ssrf) for the current enumeration. ### Governance verification * [ ] If this agent accepts `governance_context`, does it run all 15 verification steps or reject? * [ ] Is the revocation list polled on the declared cadence with a documented fetch-failure safe default? * [ ] Are JWKS caches bounded above by the revocation polling interval? ### Auditability * [ ] Is the full governance token persisted verbatim (including the envelope it arrived in) for the retention period? * [ ] Can an auditor query by `jti`, `plan_id`, or authenticated agent identifier and reconstruct the full chain of custody? * [ ] Are logs append-only and tamper-evident (e.g., object storage with legal hold, not a mutable table)? ### Operational readiness * [ ] Is there a runbook for: compromised credential revocation, webhook secret rotation, governance key rotation, incident communication to counterparties? * [ ] Is there monitoring for: `IDEMPOTENCY_CONFLICT` rate spikes (probing attacks), failed governance verifications (spoofing attempts), SSRF rejections from a single counterparty, unusual cross-agent or cross-account access patterns, 401/403 spikes from a single peer? * [ ] Has the team tabletopped at least one of: credential theft, governance key compromise, cross-tenant data leak, prompt-injection-driven credential exfiltration? * [ ] Is there a documented DR/RPO target for the idempotency cache specifically (not just the application database)? The cache is correctness-critical, not just performance-critical. * [ ] What is the penetration-test cadence, and does the scope include the MCP and A2A surfaces (not only REST)? ### Data handling and subprocessors * [ ] Is there a documented subprocessor list for the agent's data flow, and does it include the LLM providers the agent uses? * [ ] Is the DPA with each LLM provider explicit about whether prompts, brand assets, first-party signals, or creative metadata may be retained or used for model training? * [ ] Is data residency configurable to meet EU / UK / other regional requirements, and is the configuration visible in the agent's capabilities or contract? * [ ] Is log retention aligned with both forensics needs (90 days minimum for security logs) and privacy obligations (limits on PII retention)? The two can conflict; the runbook should name the decision. * [ ] If the agent is an LLM-powered planner, is there a sandbox model for tool calls arising from prompts authored from untrusted text (briefs, user chat, creative metadata)? What egress controls limit which URLs / which tools the agent can reach from within a given prompt context? **On using this checklist.** Internal use or under NDA is fine. Publishing a fully-answered copy externally — especially one with specific "no" answers — gives adversaries a map of which controls a vendor hasn't invested in. Treat a completed checklist as reconnaissance-sensitive. ## Where humans stay in the loop Security in agentic advertising is not an argument for removing humans — it's an argument for placing them where they have the most leverage and the least latency cost. AdCP's [Embedded Human Judgment](/dist/docs/3.1.20/governance/embedded-human-judgment) principles specify five load-bearing places: 1. **Intent setting** — humans define campaign goals, audiences, and budget envelopes before any agent acts. 2. **Boundary setting** — humans define the policies, constraints, and thresholds the agent must operate within. Plan-level `audience_constraints` and governance policies are machine-enforceable expressions of human judgment. 3. **Exception handling** — when governance returns `conditions` or `denied`, or when a `TERMS_REJECTED` lands, the decision escalates to a human by design. 4. **Override authority** — humans can pause, cancel, or modify an active buy at any time. The protocol's lifecycle tasks (`pause`, `resume`, `cancel`, `update_media_buy`) are explicit about which states accept which interventions. 5. **Audit and accountability** — every spend commitment produces a signed, replay-proof trail a human can inspect after the fact. A useful reading: the security controls on this page defend the *boundary* the humans set. They do not replace the humans. ## What AdCP does not do in 3.0 Knowing what a protocol doesn't do is part of evaluating it. The canonical, maintained list lives at [**Known Limitations**](/dist/docs/3.1.20/reference/known-limitations) and spans security, privacy, commerce, authentication, governance, and conformance. The security-relevant items it covers include: no end-user authentication, no protocol-level breach-notification SLA or CVD policy, no protocol-level PII transport, no LLM prompt-injection guarantee, no data-residency mechanism at the protocol layer, no OAuth 2.1 normative requirement, no general-purpose synchronous RPC response signing — designated-task payload envelopes (`verify_brand_claim` family) excepted (see [What gets signed](#what-gets-signed--and-what-doesnt) above), no cross-currency buy support, no protocol-level delivery-dispute flow, and no in-protocol payment or settlement. None of these are hidden. Each is a visible edge of the specification and a candidate for future work. ## Trust anchors and the key-discovery gap The identity, governance, and pointer-file layers above all rest on the same hidden assumption: that the public keys verifying signatures can be discovered honestly. In 3.0, that discovery path is counterparty-rooted in every case: * **RFC 9421 buyer keys** — JWKS fetched from the buyer agent's own domain or `.well-known` path. * **Governance JWS keys** — JWKS fetched from the governance agent's own domain. * **Agent signing keys** — operator-attested through `brand.json` `agents[].jwks_uri`, with publisher `adagents.json` `authorized_agents[].signing_keys[]` pins for mutating seller authorizations. * **`adagents.json` authoritative pointers** — fetched from the publisher's own `/.well-known`, with the pointer-swap threat documented in [managed-networks security](/dist/docs/3.1.20/governance/property/managed-networks#security-considerations). Every one of those steps trusts the counterparty's own infrastructure as the root of trust. TLS does not close this — the certificate is issued to the hostname the attacker has compromised, so it verifies clean. An attacker who controls a counterparty's CDN, DNS, or `/.well-known` path can therefore serve attacker-controlled keys, and every signature made with those keys will verify against them. What 3.0 actually delivers is **trust-on-first-use with continuity**: verifiers cache the first-seen keys, pin rotations against the prior key set, and alert on unexpected changes. This raises the bar — an attacker must either control the counterparty origin for long enough to look routine, or swap keys at onboarding before the victim has cached anything — but it does not close the gap. It is an honest description of the 3.x posture, not a claimed cryptographic root of trust. ### What raises the bar in 3.x Implementers SHOULD layer independent attestation sources rather than rely on any single origin. Each control below converts a silent key-swap into a detectable event within a bounded window: * **Multi-source cross-check.** When a signing key appears in `brand.json`, verify it matches the key used on signed agent responses *and* a DNS-based attestation (a TXT record at the publisher's apex binding the key fingerprint to the domain, rotated in lock-step with the key material). Compromise of the HTTPS origin alone does not also forge DNS; an attacker must break both surfaces simultaneously. * **Publication-delay / continuity windows.** Treat a never-before-seen key as provisional for a declared period (24–72 h) during which high-value operations continue to be verified against the previously cached key, and alerts fire on the rotation. A legitimate rotation survives this with operator acknowledgement; an attacker-injected key surfaces before any spend moves. * **Out-of-band key-change signalling.** Publishers, governance agents, and buyer agents SHOULD announce key rotations through channels the counterparty origin cannot forge — vendor status pages, ads.txt cross-references, partner announcement lists, direct operator notification. The protocol does not prescribe the channel; the requirement is that a channel exists and the verifier watches it. * **Rotation-validity discipline.** Keys past their declared rotation window are an attack surface, not a preference signal. Verifiers SHOULD reject signatures made with a key past its declared validity rather than silently falling back to older cached material, and SHOULD refuse to accept a rotation that sets `not_after` in the past as a legitimate rollover. These controls do not substitute for a root of trust. They make a key-swap attack detectable and costly rather than silent and cheap — which is the security posture 3.x can honestly deliver. ### What AdCP 4.0 needs: a centralized publisher-key registry The permanent fix is a centralized registry analogous in spirit to Certificate Transparency for TLS or `sellers.json` for the ad-tech identity layer. The minimal protocol-relevant properties: 1. **Publisher enrollment.** Each publisher, governance agent, and sales-agent domain registers a root verification key under its domain identity. The registry binds `{domain, root_key_fingerprint, enrolled_at}` and attests domain control through a documented challenge (DNS, HTTPS, or equivalent). 2. **Append-only rotation log.** Rotations are appended, not overwritten. The registry publishes a transparency log so a key rotation cannot be backdated, withdrawn, or selectively served to different verifiers. 3. **Public queryability.** Buyers, sellers, and validators query the registry by domain and receive the current root-key set plus the rotation history. The registry is a discovery index, not a signing authority — it never holds private keys and cannot issue signatures on any party's behalf. 4. **Governance-neutral operation.** The registry is operated by an industry body with published governance, documented key-ceremony transparency for the registry's own signing keys, and a succession plan independent of any single vendor. 5. **Backwards-compatible wire format.** Keys in the registry surface through the same JWKS format that verifiers already consume. A 3.x verifier's switch to registry-anchored trust is a configuration change (point JWKS discovery at the registry-index URL), not a new protocol surface. This is **explicitly not a 3.x requirement.** It is logged as a 4.0 track so implementers who build the in-protocol attestation surfaces today — `brand.json` `agents[].jwks_uri`, publisher `adagents.json` `authorized_agents[].signing_keys[]`, `authoritative_location`, signed governance JWS — can shape their data so a later registry lookup can anchor it without protocol breakage. Specifically, implementers SHOULD keep key declarations at stable single-purpose URIs, SHOULD carry key fingerprints alongside full key material (the registry can only anchor what it can unambiguously identify), and SHOULD NOT conflate signing keys with transport keys. Until the registry exists, the multi-source controls above are the 3.x normative baseline. They are the difference between "an attacker who compromises one counterparty origin gets silent authority" and "the compromise produces a detectable signal within a bounded window." 3.x promises the second; it does not promise the first. ## What is outside the protocol AdCP specifies the wire. It does not specify — and cannot substitute for — any of the following: * **Secret storage.** Use KMS, Vault, Secrets Manager, or equivalent. Protocol compliance does not magically protect a token sitting in a committed `.env` file. * **Endpoint hardening.** Your agent is a service on the public internet. WAF, rate limiting, DDoS protection, TLS configuration, OS patching, dependency scanning — all on you. * **Monitoring and incident response.** The protocol emits the signals worth watching (idempotency conflicts, governance failures, SSRF rejections). Detecting and responding to them is your operations team's job. * **Human controls.** Approval thresholds, spend caps, pause authority — these are policy configurations inside your agent or your governance platform, not the protocol. * **Physical and personnel security.** The usual controls over who can touch production, who holds break-glass credentials, and who can push to main. Think of AdCP as specifying the locks on the doors. You still own the building. ## Further reading * **[Security (implementation reference)](/dist/docs/3.1.20/building/by-layer/L1/security)** — Normative rules for HMAC, idempotency, SSRF, agent/account isolation, and governance verification * **[Embedded Human Judgment](/dist/docs/3.1.20/governance/embedded-human-judgment)** — The five principles that keep humans in the loop on decisions with real consequences * **[Trusted Match Protocol](/dist/docs/3.1.20/trusted-match)** — The two-call decomposition (Context Match / Identity Match) that delivers structural privacy separation at serve time * **[Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks)** — Signature format, replay windows, rotation * **[Signed Governance Context](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context)** — The 15-step verification checklist * **[Operating an Agent](/dist/docs/3.1.20/building/operating/operating-an-agent)** — Credential management, monitoring, and incident response as operating concerns * **[How Agents Communicate](/dist/docs/3.1.20/building/concepts/how-agents-communicate)** — `adagents.json`, `brand.json`, and the discovery trust chain # Known spec ambiguities Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/cross-cutting/known-ambiguities Open AdCP spec gaps that affect compliance testing — with workarounds and issue links. Entries are removed as the underlying issues close. This page enumerates spec gaps that currently affect storyboard conformance. Each entry covers one of three patterns: a spec `MAY` branch where vectors assert one outcome, a response field storyboards assert that the schema does not yet require, or a testing-infrastructure quirk that prevents a vector from probing through the reference SDK. **Entries persist until the fix ships in a tagged `@adcp/sdk` or spec release** — closing the GitHub issue is not the removal trigger, because an implementer on an SDK version that predates the fix still hits the symptom. Each entry's Workaround names the release gate when relevant. If an entry here doesn't match what you're seeing, check the GitHub issue for the latest state — the fix may have landed in a release you haven't pulled yet. ## How to use this page If a storyboard fails on a behavior you believe is spec-conformant, search this page for the storyboard name or the assertion text. Each entry describes the gap, the workaround that gets you past the blocker, and the issue that tracks the fix. Entries are removed when the fix ships in a tagged release, not when the issue closes — pair this page with the linked issue and the SDK / spec release notes for the authoritative state. For the opposite direction — failures that are **not** in this list and do have clean fixes — see the [storyboard troubleshooting guide](/dist/docs/3.1.20/building/operating/storyboard-troubleshooting). ## Current ambiguities ### `check_governance` `conditions` field shape * **Schema**: `check-governance-response.json` defines `conditions[]` items as `{ field, required_value?, reason }` with `field` and `reason` required. The `status: conditions` status now requires `conditions` with `minItems: 1`. * **Resolution**: [#2603](https://github.com/adcontextprotocol/adcp/issues/2603). The schema tightening lands in the protocol patch release following this entry's removal. * **Workaround (until you pull the fix)**: emit `conditions[]` with the canonical `{ field, reason }` shape on every `status: conditions` response. Agents following the prose description already do this; the schema tightening just makes enforcement mechanical. ### PRM required for non-OAuth agents * **Storyboard**: `universal/security.yaml` phases `oauth_discovery` + `mechanism_required`. * **Gap**: the RFC 9728 / RFC 8414 probes run for every agent by default. API-key-only sandboxes were standing up fake issuer URLs to "pass" the probes, which is worse than skipping them. * **Resolution**: [#2606](https://github.com/adcontextprotocol/adcp/issues/2606) and [#5042](https://github.com/adcontextprotocol/adcp/issues/5042) — the storyboard narrative now explicitly directs static-credential-only agents to declare `auth.api_key` or `auth.basic` in the test kit and omit PRM entirely. Optional-phase semantics make `oauth_discovery` failures non-fatal; `mechanism_required` passes via the matching static-credential path. * **Workaround**: if your agent has no OAuth issuer, do not serve `/.well-known/oauth-protected-resource/...`. Configure your agent to accept the test kit's probe credential as valid. For Bearer agents, the default test kit (`acme-outdoor`) probes with `demo-acme-outdoor-v1`, and your agent must accept that value alongside your production key. For Basic agents, provide a test kit with `auth.basic.username`/`auth.basic.password` or `auth.basic.credentials`. This satisfies either `test_kit.auth.api_key` or `test_kit.auth.basic` and lets the matching static-credential phase run; without it the phase is skipped and `assert_mechanism` fails with `actual: []`. See the [storyboard troubleshooting guide — static-credential agent: assert\_mechanism](/dist/docs/3.1.20/building/operating/storyboard-troubleshooting#static-credential-agent-no-auth-mechanism-contributed-assert_mechanism) for the concrete fix. ### Idempotency missing-key probe via SDK * **Storyboard**: `universal/idempotency.yaml` step `missing_key/create_media_buy_missing_key`. * **Gap**: the reference `@adcp/sdk` SDK auto-injects `idempotency_key` on mutating tasks, so a vector that tries to probe "missing key rejection" never reaches the agent with a missing key — the runner would inject one before dispatch. * **Resolution**: [#2607](https://github.com/adcontextprotocol/adcp/issues/2607) — the step declares `omit_idempotency_key: true`, which signals the runner to skip both its own `applyIdempotencyInvariant` and the SDK's auto-inject. The request arrives at your agent without a key, letting the vector probe the rejection path honestly. * **Workaround**: nothing required — honor the existing spec requirement (reject missing `idempotency_key` on mutating tasks with `INVALID_REQUEST` or `VALIDATION_ERROR`). ### Response schema fields asserted by storyboards * **Storyboards**: `sales_catalog_driven` (catalog counts), `creative_ad_server` (pricing\_options), `media_buy_seller/inventory_list_targeting` (property\_list echo), `creative_ad_server` (vendor\_cost required). * **Gap**: historical drift between what storyboard vectors assert and what the response schemas require. * **Resolution**: [#2604](https://github.com/adcontextprotocol/adcp/issues/2604). Audit complete: * `sync-catalogs-response.json` now requires `item_count` on catalog entries when `action` is `created`/`updated`/`unchanged`. * `property_list` / `collection_list` echo: already canonical via `packages[].targeting_overlay`. * `list-creatives-response.json` `pricing_options`: already canonical (array, `minItems: 1`, items require `pricing_option_id`). * `report-usage-request.json` `vendor_cost`: already required. * **Workaround**: emit `item_count` on every non-failed/non-deleted catalog entry. Conformant agents already do this; the schema tightening catches gaps at `response_schema` validation. ### Rights-holder vs advertiser `brand_id` in brand protocol * **Storyboard**: `specialisms/brand-rights/index.yaml` phases `identity_discovery` + `rights_search`. * **Gap**: `get_brand_identity.brand_id` identifies the advertiser (e.g., `acme_outdoor`); `get_rights.brand_id` scopes the search to a specific rights-holder brand (e.g., talent like `daan_janssen`). Same field name, different entities — before the #2627 fix the storyboard threaded the advertiser id through to the rights-holder filter, and a conformant agent either returned empty rights (fail) or added a "return all when no match" fallback (masking bugs). * **Resolution**: [#2627](https://github.com/adcontextprotocol/adcp/issues/2627) — the storyboard now sends `buyer_brand` (advertiser for compatibility filtering) and omits the rights-holder `brand_id` filter so the agent returns its full catalog. * **Workaround**: treat `get_rights.brand_id` as a rights-holder filter only; populate `buyer_brand` for compatibility filtering against the buyer's `brand.json`. ### Re-cancel error code — `NOT_CANCELLABLE` vs `INVALID_STATE` * **Storyboards**: `protocols/media-buy/state-machine.yaml > recancel_buy` and `scenarios/invalid_transitions.yaml > double_cancel/second_cancel`. * **Gap**: `specification.mdx` §128 (MAY `NOT_CANCELLABLE`) and §129 (MUST `INVALID_STATE` on terminal-state updates) both applied to re-cancel of a `canceled` buy. State-machine-first implementations returned `INVALID_STATE` per §129; cancellation-first implementations returned `NOT_CANCELLABLE`. Vectors historically pinned one. * **Resolution**: [#2617](https://github.com/adcontextprotocol/adcp/issues/2617) / [#2619](https://github.com/adcontextprotocol/adcp/pull/2619) + [#2628](https://github.com/adcontextprotocol/adcp/issues/2628) — §129 now carves out the cancellation case: when the terminal-state update IS a cancellation attempt, agents MUST return `NOT_CANCELLABLE`. Other illegal transitions (pause/resume on canceled) still return `INVALID_STATE`. Both storyboards now assert `NOT_CANCELLABLE` on re-cancel. * **Workaround**: return `NOT_CANCELLABLE` on `canceled: true` updates to buys already in `canceled`. Return `INVALID_STATE` on pause/resume of terminal-state buys. The cancellation-specific code wins on re-cancel; the generic code wins on everything else. ### Branch-set step grading (`peer_branch_taken`) * **Storyboards**: any with parallel `optional: true` phases sharing a `contributes_to:` flag. * **Gap**: a conformant agent picks one branch (for example, immediate success); the other branch's assertion (for example, `status: pending_review`) fails because the agent took the opposite behavior. Runners before the #2629 fix surfaced this as `× (unknown step)` in the summary even though the `any_of` aggregate passed — implementers debugged a branch they weren't on. * **Resolution**: [#2629](https://github.com/adcontextprotocol/adcp/issues/2629) — runners now grade non-chosen branch steps with skip reason `peer_branch_taken` (distinct from `not_applicable`, which is reserved for protocol/specialism coverage gaps). See `storyboard-schema.yaml` § "Per-step grading in any\_of branch patterns" for the authoring rules and `runner-output-contract.yaml > skip_result.reasons.peer_branch_taken` for the canonical `detail` shape. * **Workaround**: if your runner reports an unexpected branch failure, check whether a peer optional phase contributed the same `contributes_to` flag. If so, you're conformant on the branch you chose — the runner needs the #2629 update. ### SDK request-builder overriding spec-conformant `sample_request` * **Storyboard**: `sales_catalog_driven` `optimization_loop/provide_feedback`, exposed via the `@adcp/sdk` compliance runner. * **Gap**: the storyboard's `sample_request` correctly declares `performance_index`, `metric_type`, `feedback_source` per the `provide-performance-feedback-request.json` schema. But `@adcp/sdk`'s internal `request-builder.js` had a hardcoded override for `provide_performance_feedback` that replaced the payload with a non-spec `feedback: { satisfaction, notes }` shape, so conformant agents rejected with `INVALID_REQUEST` and failed the vector. * **Resolution**: upstream [adcontextprotocol/adcp-client#689](https://github.com/adcontextprotocol/adcp-client/issues/689) + [#2626](https://github.com/adcontextprotocol/adcp/issues/2626) — remove the override and let the storyboard's `sample_request` drive the payload. * **Workaround**: bump to an `@adcp/sdk` release that includes the adcp-client#689 fix. Until then, the `provide_performance_feedback` vector will fail on any agent that validates its requests against the spec schema. ## When an ambiguity isn't listed If you're blocked on a behavior you believe the spec leaves ambiguous but it's not on this list, open an issue at [adcontextprotocol/adcp](https://github.com/adcontextprotocol/adcp/issues/new). Include the storyboard, the vector's assertion text, the conformant branch you picked, and why you believe the spec allows it. The fastest resolutions come from issues that cite the specific spec paragraph and the specific vector assertion — that's enough for a maintainer to either point you at an existing fix or confirm the gap and schedule it. # The AdCP stack Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/cross-cutting/sdk-stack Layered reference for AdCP implementers. The five layers (L0 wire, L1 signing, L2 auth, L3 protocol semantics, L4 business logic), what each layer contains, what an SDK at each layer should provide, how SDKs absorb version drift, and what 'from scratch' actually signs you up for. **AdCP spans the transaction/control plane and its own serve-time execution layer.** Planning, deal creation, creative submission, and reporting use AdCP's MCP/A2A task surfaces. Impression-time activation of pre-negotiated packages uses [Trusted Match Protocol](/dist/docs/3.1.20/trusted-match), while auctions and rendering still sit in adjacent protocols such as RTB and VAST. Most AdCP task latency budgets are seconds and often async-by-design; TMP is the AdCP surface built for millisecond serve-time decisions. The first question when you sit down to build an AdCP agent is **where you want to spend your engineering time**. By the time a buyer's `create_media_buy` reaches your business logic, it has crossed five distinct layers — wire format, signing, auth, protocol semantics, and finally what you actually want to build. The lower you start, the more of the stack you own. This page lays out those layers, what an SDK provides at each one, and what's left for you to write either way. Use it to pick the entry point that fits your team — whether that's letting an SDK absorb the protocol surface so you can focus on the L4 logic that differentiates your agent, or going lower because you have a specific reason to. The cost decompositions further down ([per-component L3 breakdown](#why-sdks-matter-more-in-adcp-than-in-eg-http), [version-adaptation](#version-adaptation)) are there to make either choice deliberate. Two notes on framing before the layers: * **The protocol surface has grown.** AdCP 3.0 added [a substantial L3 floor](/dist/docs/3.1.20/building/cross-cutting/version-adaptation#what-changed-at-l3-in-3-0) — mandatory idempotency, published lifecycle state machines, the conformance test surface, RFC 9421 signatures as a baseline, expanded error catalog. If you last evaluated SDKs against an earlier version, the line between "what the SDK does" and "what I'd write myself" has moved. * **AdCP looks like a thin protocol from the outside.** From the inside it has more L3 (state machines, idempotency, async-task contract, error semantics, conformance) than implementers tend to expect on a first read. The decompositions on this page exist so the L3 estimate is visible up front. Audience: AdCP implementers in any language — whether you're building an agent, authoring an SDK, or evaluating one. ## The five layers The same five layers exist on both sides of an AdCP conversation — **agent (server)** and **caller (client)**. The work is asymmetric, though: an agent **enforces** the protocol (state machines, idempotency, error semantics, conformance surface, webhook emission), while a caller **consumes** it (reads state, supplies idempotency keys, handles errors, receives webhooks). L0 (wire) and L1 (signing) are mostly symmetric; L2 (auth) and especially L3 (protocol semantics) are where the surface diverges. L4 exists on both sides, but it's a different shape — the agent's L4 is its inventory and decisioning, the caller's L4 is its planning and buying logic. When this page describes a layer in agent-shaped terms, look for the *Client side* note at the end — it names the (typically smaller) caller-side surface. Most of the per-page cost commentary, the L3 person-month estimates, and the conformance discussion all describe the server side; building a caller is meaningfully lighter at L2–L3 because most of the work is consuming the protocol, not enforcing it. **Caller-only?** Skim the *Client side* notes on each layer below, then jump to [Server vs client at each layer](#server-vs-client-at-each-layer) for the cost comparison. ```mermaid theme={null} %%{init: {"flowchart": {"htmlLabels": true, "wrappingWidth": 9999}, "themeVariables": {"fontSize": "14px"}}}%% flowchart TB subgraph yours["yours, always"] L4["L4 — Business logic
Inventory forecasting · pricing · creative review · upstream ad-server calls
(GAM / FreeWheel / Kevel / your decisioning engine)
The agent's competitive surface — what makes your agent yours"] end subgraph sdk["what an AdCP SDK provides"] direction TB L3["L3 — Protocol semantics
Lifecycle state machines · idempotency · error catalog · transition validation
Async-task contract · webhook emission · conformance surface · response envelope"] L2["L2 — Auth & registry
Agent identity verification · brand resolution · AAO bridge
Multi-tenant account resolution · principal scoping · sandbox-vs-live routing"] L1["L1 — Identity & signing
RFC 9421 HTTP message signatures · public-key registries
Signature verification · replay-window enforcement · key rotation"] L0["L0 — Wire & transport
JSON-over-HTTP framing · MCP message envelopes · A2A SSE streams
JSON schema validation · language-native type generation"] L3 ~~~ L2 L2 ~~~ L1 L1 ~~~ L0 end L4 ~~~ L3 ``` ### L0 — Wire & transport What it does: takes protocol bytes off the wire and turns them into typed in-memory values. Schema validation catches malformed payloads at the door. What's in it: * HTTP routing (or stdio for MCP-over-stdio). * MCP message framing (`tools/call` envelope, JSON-RPC 2.0). * A2A SSE event streams. * JSON schema validation against the spec's `*.json` files (see [Schemas](/dist/docs/3.1.20/building/by-layer/L0/schemas)). * Type generation: producing language-native types from the spec's schemas, so application code is statically checked. If you only have L0, you have a parser. The buyer's `create_media_buy` is a typed object on your stack — and you have to do everything else yourself. *Client side:* same primitives, mirror direction. The client serializes outbound requests against the same schemas and consumes responses through the same type-generation pipeline. L0 is essentially symmetric. ### L1 — Identity & signing What it does: cryptographically verifies that the request came from who the headers claim it did, and that the body wasn't modified in transit. See [Security model](/dist/docs/3.1.20/building/concepts/security-model) and the [implementation profile](/dist/docs/3.1.20/building/by-layer/L1/security). What's in it: * RFC 9421 HTTP message signatures (`Signature-Input`, `Signature` headers). * Public-key resolution from agent registries (or operator-published JWKS). * Signature verification against the canonicalized request. * Replay-window enforcement (`created` / `expires` parameters). * Key rotation: handling `keyid` changes without dropping in-flight requests. If you have L0+L1, you know who's calling you. You still don't know *what* they're allowed to do. *Client side:* signs outbound requests with its own key; verifies webhook callbacks from the agent. Same RFC 9421 + replay-window + key-rotation primitives, just one inbound path (webhooks) instead of every request. ### L2 — Auth & registry What it does: turns a verified identity into a scoped principal — which buyer, which brand, which advertiser account, which sandbox-vs-live tier. See [Accounts](/dist/docs/3.1.20/accounts/overview) and [Calling an agent](/dist/docs/3.1.20/protocol/calling-an-agent). What's in it: * Agent registry lookup (resolving agent metadata from a published [agent card](/dist/docs/3.1.20/protocol/calling-an-agent)). * Brand resolution: mapping the requesting agent to a buyer brand / advertiser identity via [Brand Protocol](/dist/docs/3.1.20/brand-protocol). * AAO ([AgenticAdvertising.org](https://agenticadvertising.org)) bridge: resolving an agent's member org, AAO Verified badges, and registry visibility — see [Registering an agent](/dist/docs/3.1.20/registry/registering-an-agent) and [AAO Verified](/dist/docs/3.1.20/building/verification/aao-verified). * Multi-tenant account resolution: the same wire request maps to different accounts depending on the principal. * Sandbox-vs-live account flagging — see [Sandbox](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox). * Permission scoping: which AdCP tools this principal is allowed to call. If you have L0+L1+L2, you have a verified, scoped principal asking to do something. You still don't know if the *something* is legal in the current state. *Client side:* a small subset. The client publishes its own identity (agent card, brand domain), looks up the agent it's calling via the registry, and presents its credentials. There's no multi-tenant routing, no principal scoping, no sandbox/live boundary to enforce — the client *is* the principal, and chooses which agent to talk to. ### L3 — Protocol semantics What it does: enforces what AdCP *means*. The wire shape is well-formed (L0); the caller is authentic (L1) and authorized (L2); now: is the request legal given the current state of the world? What's in it: * **Lifecycle state machines** — `MediaBuy` ([reference](/dist/docs/3.1.20/media-buy/media-buys/lifecycle)), `Creative`, `Account`, `SISession`, `CatalogItem`, `Proposal`, `Audience`. Each with legal edges defined by the spec. * **Transition validation** — enforce the legal edges per resource; emit `NOT_CANCELLABLE` for cancel-attempts against a state that forbids it; `INVALID_STATE` for other illegal moves. The cancellation-specific code takes precedence over the generic one whenever the attempted action is a cancel. * **Idempotency** — `idempotency_key` required on every mutating tool; same key replays the cached response within TTL; cross-payload reuse fails with `IDEMPOTENCY_CONFLICT` (with no payload echo, per the stolen-key read-oracle threat model). See the [idempotency profile](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency). * **Error code catalog** — codes with recovery semantics (`transient` / `correctable` / `terminal`). Choosing the right code is part of the spec contract. See [Error handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling). * **Async-task contract** — tools that don't complete synchronously return a `task_id`; clients poll or receive webhook callbacks; the task's terminal artifact carries the original tool's response shape. See [Task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle). * **Webhook emission** — state changes notify subscribed buyers, with retry, idempotency, and signature. See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks). * **Conformance test surface** — `comply_test_controller` (sandbox-only) exposes `seed_*` / `force_*` / `simulate_*` so storyboards can drive state deterministically. See [comply\_test\_controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) and [Conformance](/dist/docs/3.1.20/building/verification/conformance). * **Response envelope** — `context`, `task_id`, `status` field, error envelope shape, `adcp_version` echo, capability advertisement. If you have L0+L1+L2+L3, you have a complete AdCP protocol implementation. You still haven't done any business logic. *Client side:* the consumer-side mirror, which is much smaller. The client *reads* state machines (handles each terminal status correctly) rather than enforcing transitions. It *supplies* `idempotency_key` on retries rather than maintaining the cache. It *classifies* error codes by recovery semantics (`transient` → retry, `correctable` → fix and resubmit, `terminal` → don't retry) rather than choosing the right one to emit. It *polls or receives* async-task results and webhook callbacks rather than emitting them. There's no `comply_test_controller` surface to expose, and no conformance bar to certify against on the consumer side. The L3 person-month estimate later on this page is server-side; client L3 is weeks of handler glue, not months. ### L4 — Business logic This is what makes your agent yours. What's in it: * Inventory forecasting against your real ad server. * Pricing logic, deal terms, contract semantics. * Creative review policy (brand safety, format compliance). * Upstream calls to GAM / FreeWheel / Kevel / Yahoo / your in-house decisioning engine. * Optimization, pacing, fraud detection — anything that differentiates your inventory from a competitor's. This is the layer an AdCP SDK leaves to you, **and only this layer**. *Client side:* L4 is also yours, just a different shape. The caller's L4 is media planning, budget allocation, target-audience selection, deal evaluation, reporting ingest — whatever your buy-side application does with the agents it calls. See [Calling an agent](/dist/docs/3.1.20/protocol/calling-an-agent) for the spec-side reference. The asymmetry runs through the whole stack: agent L4 differentiates *inventory*, caller L4 differentiates *demand*. ## Server vs client at each layer The same five layers; very different cost. Use this when sizing the work for a caller-only build vs. an agent build. | Layer | Agent (server) | Caller (client) | | ------ | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **L4** | Inventory, pricing, creative review, ad-server integration. What differentiates you as a seller. | Planning, budgeting, agent selection, reporting consumption. What differentiates you as a buyer. | | **L3** | **Enforces** state machines, idempotency, error semantics, conformance test surface, webhook emission. \~3–4 person-months. | **Consumes** the same. Reads state, supplies idempotency keys, classifies errors, polls/receives async + webhooks. Weeks of handler glue. | | **L2** | Multi-tenant principal resolution, sandbox/live boundary, brand resolution, permission scoping. | Publishes own identity; looks up the agent it's calling. Much smaller surface. | | **L1** | Verifies inbound on every request; signs outbound webhooks. | Signs outbound on every request; verifies inbound webhooks. Same crypto, mirrored path. | | **L0** | Receives + parses + validates against schemas. | Serializes + sends + validates against schemas. Symmetric. | A from-scratch caller is a weeks-long job across L0–L3 — handler glue, signing, registry lookup, response parsing — not the [3–4 person-month L3 build](#why-sdks-matter-more-in-adcp-than-in-eg-http) the agent side requires. The rest of this page concentrates on the agent side because that's where the cost lives, but the layer model and the SDK coverage matrix apply equally to a caller-only build. ## What an SDK at each layer should provide Implementer-facing checklist. An SDK that claims coverage of layer L*n* should expose, at minimum, the primitives below. Adopters use this as a self-evaluation tool when picking an SDK; SDK authors use it as a build target. The checklist describes **server-side coverage** — the agent surface is where the bulk of an SDK's value lives. **Client-side coverage** at each layer is a subset: typed request builders + response parsers (L0), outbound signing + webhook verification (L1), agent-card publication + registry lookup (L2), state-machine *handlers* + idempotency-key generation + error-recovery classification + async-result polling (L3). A full-stack SDK ships both. ### L0 coverage * Generated language-native types from the published JSON schemas (one type per request/response pair, plus shared resource types). * A schema validator wired against the bundled schemas — so adopters can validate inbound and outbound payloads without hand-rolling the schema-loading dance. * Transport adapters for at least one of \{MCP, A2A}; ideally both. These typically wrap upstream protocol SDKs rather than reimplementing them. * A schema-bundle accessor that finds the right schema files for the active AdCP version without forcing the adopter to hardcode paths. ### L1 coverage * RFC 9421 message-signature signing for outbound requests. * RFC 9421 verification for inbound requests, including replay-window enforcement on `created` / `expires` and `keyid`-based key lookup. * A pluggable signing-provider abstraction: in-process keys for development, KMS / HSM providers for production. * Test fixtures or a verifier-test harness so adopters can assert their signing wiring is correct without booting a full agent. ### L2 coverage * An account-store abstraction that resolves an authenticated principal to a scoped account, with hooks for multi-tenant routing. * Authentication primitives for at least API-key and bearer-token shapes, plus a way to compose them. * Brand-resolution / agent-registry lookup (or a documented extension point if the SDK doesn't ship it natively). * The sandbox-vs-live account flag, enforced at the SDK boundary so the conformance-test surface refuses to dispatch on production accounts. ### L3 coverage * Lifecycle state-machine graphs for all spec-defined resources, with a transition-assertion primitive that emits the spec-correct error code (`NOT_CANCELLABLE` / `INVALID_STATE` / etc.). * Idempotency cache with cross-payload conflict detection and the no-payload-echo invariant on `IDEMPOTENCY_CONFLICT` envelopes. * Async-task store + dispatcher: tools opt into async; the SDK returns `task_id`, accepts polling, and emits the terminal artifact. * Webhook emitter: signed, retried, idempotent. * The conformance test surface (`comply_test_controller`), wired to drive state deterministically when the resolved account is in sandbox or mock mode (and rejected otherwise). * Per-resource persistence primitives that handle the spec's echo contracts. * Server-construction entry point that ties all of the above together with sane defaults. ### L4 coverage Out of scope for any SDK. The adopter writes this. ## SDK coverage varies Different language SDKs cover different subsets of L0–L3. There is no single SDK every implementer must use; what matters is that an implementation reaches the [conformance bar](/dist/docs/3.1.20/building/verification/conformance) at L3, regardless of how much hand-rolling it took to get there. Within a given language, the full-stack SDK is the default starting point. The layered model in this doc exists to explain what you'd be reimplementing if you went lower (special-purpose proxies, custom-stack integrations) or ported the SDK to a new language — not to suggest there's a meaningful win in starting lower for a typical agent build. ### Current SDK coverage **Python and TypeScript are the first-class languages.** Both are committed to full L0–L4 coverage — TypeScript is GA across L0–L3 today; Python is finishing its 4.x cycle to the same bar. **Go** is moving in the same direction, with L0 and partial L1 in active development. **Other languages** are not on the official roadmap today, but we're open to community-maintained ports — if you want to help, see the [Builders Working Group](/dist/docs/3.1.20/community/working-group) and the [Slack community](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg). Snapshot of what each official SDK ships today. Refresh this table on SDK majors and on AdCP spec revs. *Last updated: 2026-05-03.* | SDK | Production GA | Beta / dev | L0 | L1 | L2 | L3 | | -------------------- | ------------- | ---------- | :-: | :-: | :-: | :-: | | **`@adcp/sdk`** (TS) | `6.9.0` | — | ✅ | ✅ | ✅ | ✅ | | **`adcp`** (Python) | `3.x` | `4.x` | ✅ | ⚠️ | ⚠️ | ⚠️ | | **`adcp-go`** | — | `v1.x` | ⚠️ | ❌ | ❌ | ❌ | Legend: ✅ shipped · ⚠️ partial / in flight · ❌ not yet covered. **Production GA** is the line you should pin to today. **Beta / dev** is what's in flight on the next major. `@adcp/sdk` 6.x carries full L0–L3 — adopters write L4 only. Python 3.x is the production line with full L0; the 4.x rewrite (in beta) closes L1–L3. Go ships types + transport in dev; L1–L3 in scope. See [Choose your SDK](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk) for install commands, package exports, and per-language gap detail. What "shipped" means at each layer is the L0–L3 checklist above — these rows should not claim ✅ until every checklist item is satisfied in the published SDK build. For coverage detail beyond this snapshot, see each SDK's repo. For shape comparison purposes, here are the three coverage archetypes an SDK can land in regardless of language: | Archetype | L0 | L1 | L2 | L3 | Adopter writes | | ------------------------------- | -- | -- | -- | -- | ----------------- | | Full-stack SDK | ✅ | ✅ | ✅ | ✅ | L4 only | | Transport + signing only | ✅ | ✅ | ⚠️ | ❌ | L2 + L3 + L4 | | Types-only / generated bindings | ✅ | ❌ | ❌ | ❌ | L1 + L2 + L3 + L4 | ### Hosted implementations Different shape from an SDK: a **deployable agent** you run rather than a library you import. Adopters configure rather than code. Useful when you want an AdCP surface in front of an existing system without writing handler code yourself. | Implementation | Maintainer | Stack | Notes | | --------------------- | -------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **AdCP mock-server** | spec maintainers | Reference | The black-box AdCP agent storyboards run against. All language SDKs forward mock-mode traffic to it; shared infrastructure for [spec compliance](/dist/docs/3.1.20/building/verification/conformance). | | **Prebid SalesAgent** | [Prebid community](https://github.com/prebid/salesagent) | Python | Open-source seller-side AdCP agent. Publishers run it as their AdCP-facing implementation; hand-rolled at L0–L3 today, evolving alongside the official SDKs. | Hosted implementations satisfy the same L3 conformance bar that SDKs do — the spec is implementation-agnostic. The difference is operational shape: a hosted implementation is a service you deploy and configure, an SDK is code you compile into your own service. The choice is a tradeoff between leverage and control. A full-stack SDK ships you the most code for free but couples you to its choices. A transport-only SDK gives you maximum control but signs you up for months of L1–L3 work before you can certify. Most production adopters want the full stack with the option to swap individual layers (custom signing provider, custom account store, custom idempotency backend) — which a well-architected full-stack SDK exposes as configuration, not as a fork. ## Where can you start? You can implement at any layer. The lower you start, the more you build. | Starting layer | What you write | What's done for you | | ------------------------------------------- | --------------- | ------------------------------ | | L0 (from scratch) | All five layers | Nothing | | L1 (you have a JSON-over-HTTP toolkit) | L1+L2+L3+L4 | L0 (parser, schema validation) | | L2 (you have HTTP signatures via a library) | L2+L3+L4 | L0+L1 | | L3 (you have an auth/registry library) | L3+L4 | L0+L1+L2 | | L4 (you use a full-stack AdCP SDK) | L4 only | L0+L1+L2+L3 | A full-stack AdCP SDK lifts you to L4. You implement upstream calls. The SDK threads the protocol envelope around them. Pick one if your team's value-add is L4 differentiation; build lower if you have a specific reason — and budget for the L1–L3 scope honestly. See [Where to start](/dist/docs/3.1.20/building) for a short decision page that picks an entry point based on what you're building. ## Why SDKs matter more in AdCP than in (e.g.) HTTP A common comparison: *"HTTP is a protocol. People build HTTP servers from scratch all the time. Why would AdCP be different?"* The answer is layer L3. HTTP's protocol semantics are minimal — methods, status codes, headers. A from-scratch HTTP server can ship in a weekend with an off-the-shelf parser. AdCP's L3 is large: * **State machines** — 7 resource types with published lifecycle graphs. * **Async tasks** — every mutating tool can be sync or async; the contract for which terminal artifact closes the task is non-trivial. * **Idempotency** — cache, replay, conflict, TTL — all wired correctly. * **Error catalog** — codes with recovery classification. Picking the wrong one fails [conformance](/dist/docs/3.1.20/building/verification/conformance). * **Conformance test surface** — storyboards drive your state via the [`comply_test_controller`](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) tool. You ship a non-trivial controller surface. * **Webhook emission** — signed, retried, idempotent. A from-scratch AdCP agent is **\~3–4 person-months of L3 work alone**, before any L4 differentiation. The breakdown, for one senior engineer to a mock-mode conformance bar, is roughly: | L3 component | Honest estimate | | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | 7 lifecycle state machines (define edges, validate transitions, emit the right `NOT_CANCELLABLE` / `INVALID_STATE` codes) | \~1 week each = **6–7 weeks** | | Idempotency cache (cross-payload conflict detection + no-payload-echo invariant) | **1 week** | | Async-task store + dispatcher (correct terminal-artifact contract per tool) | **1–2 weeks** | | Error-code catalog wiring (recovery classification, code precedence) | **1–2 weeks** | | `comply_test_controller` conformance surface (`seed_*` / `force_*` / `simulate_*`) | **1–2 weeks** | | Webhook emission (signed, retried, idempotent, dedup-keyed) | **1 week** | | RFC 9421 signing + verification + replay-window + key rotation (counted separately as L1, but commonly bundled in the same scope) | **2–3 weeks** | | Integration, conformance debugging, spec re-reading | **2–3 weeks** | That's **\~14–18 weeks**, depending on team familiarity with HTTP message-signatures and lifecycle modeling. The estimate **excludes version-adaptation work** — every spec rev that adds a tool, an edge, or an error code adds rows to a translation matrix you carry forever. SDK adopters get those for free; from-scratch implementers pay them every release. This is a single-engineer-to-mock-conformance estimate. **At publisher / large-platform scale, multiply by \~2× to \~3×** for SRE, security review, KMS / HSM integration with existing key infrastructure, load testing, and on-call burden — none of which is L3 spec work, all of which is real cost before the surface is production-grade. "From scratch" reads cheap when L0 (the wire shape) is the only layer in view. L3 is where the actual scope hides — the table above is what we'd point a team at before they commit either way. ## Version adaptation Three "version" axes move at the same time, and an SDK's job is to keep them from colliding inside your business logic: | Axis | Example | What changes when it moves | | --------------------------- | ----------------------------- | ----------------------------------------------------------------- | | **Spec version** | AdCP `2.5 → 3.0.5 → 3.1` | Wire shapes, error codes, lifecycle states, new tools | | **SDK version** | SDK `5.x → 6.x` | API surface, ergonomics, compile-time guarantees | | **Peer version (per call)** | Buyer at v3.0, seller at v2.5 | A single conversation crosses versions; payloads need translation | A from-scratch agent has to handle all three by hand. SDKs ship three concrete mechanisms so adopters don't: 1. **Per-call spec-version pinning.** Set `adcpVersion` (or the language equivalent) on an agent; the SDK runs requests and responses through adapter modules so handler code stays on the canonical (current) shape regardless of what the peer speaks. 2. **SDK-major migration via co-existence imports.** Bumping an SDK major doesn't force a same-day rewrite — the prior major's surface remains available alongside the new entry point. Migrate one specialism at a time. 3. **Wire-level negotiation.** Every request carries `adcp_major_version`; servers declare what they support and return `VERSION_UNSUPPORTED` (a recovery-classified error) if the caller is out of range. Code-level recipes per mechanism live in [Version Adaptation](/dist/docs/3.1.20/building/cross-cutting/version-adaptation). For the spec-side rules, see [Versioning](/dist/docs/3.1.20/reference/versioning). ### Why this matters Versioning in AdCP is **continuous, not episodic**. Once 3.1 ships, you'll be talking to 3.0 and 3.1 callers simultaneously, indefinitely. Without translation adapters this is a fork in your codebase. With them it's a constructor flag. The spec itself has already done one of these crossings. **2.5 → 3.0** added a substantial L3 floor — see [What changed at L3 in 3.0](/dist/docs/3.1.20/building/cross-cutting/version-adaptation#what-changed-at-l3-in-3-0) for the canonical list. A from-scratch 2.5 agent was tractable; a from-scratch 3.0 agent is the [\~3–4 person-month L3 build](#why-sdks-matter-more-in-adcp-than-in-eg-http) decomposed above. The from-scratch path that worked for 2.5 doesn't scale to 3.0, and 3.0 isn't where the spec stops. SDKs exist because L3 grew faster than implementers could hand-roll, and the version-adaptation surface keeps growing each release. ## Where the work actually lives Five places L3 cost concentrates, in rough order of magnitude. Useful as a self-check whether you're scoping a from-scratch build or re-evaluating a hand-rolled one: 1. **L3 is most of the work.** State machines, idempotency, error catalog, async tasks — \~3–4 person-months before any L4 differentiation. See the [decomposition](#why-sdks-matter-more-in-adcp-than-in-eg-http) for per-component weeks. 2. **Conformance is L3-driven.** Storyboards probe state transitions and error shapes (see [Conformance](/dist/docs/3.1.20/building/verification/conformance)). Without transition validators, the spec gets re-derived from test failures. 3. **Versioning compounds.** Each spec rev that adds a tool, a lifecycle edge, or an error code is a new translation row the adapter layer carries. Owning the adapter layer means owning that matrix every release. 4. **RFC 9421 + key rotation is its own project.** Signing providers, KMS integration, replay windows — real engineering, none of it L4 differentiation. 5. **The mock-server is shared infrastructure.** SDKs wire mock-mode dispatch to it for free. Hand-rolled implementations either skip mock-mode (and lose spec-compliance certification) or rebuild it. If you built before the SDKs covered much, this list is the input to a re-evaluation — not a verdict either way. The [migration guide](/dist/docs/3.1.20/building/by-layer/L4/migrate-from-hand-rolled) walks the swap-one-layer-at-a-time path for teams who decide a partial swap is worth it: which layer to swap first, conflict modes to watch for, which intermediate states still pass conformance. ## What this means for compliance Two kinds of compliance, both shaped by the layered model: * **Spec compliance (L3 protocol test)** — does the implementation satisfy the AdCP wire contract? Storyboards walk the state machines, exercise the error codes, test the async-task contract. The adopter's upstream is irrelevant. Runs against **mock-mode** accounts: the agent forwards every tool call to the reference mock-server. This certifies the SDK's (or hand-rolled implementation's) L3 layer. * **Live compliance (full-stack test, planned)** — does the actually deployed agent (adopter's L4 code against their test infra) behave correctly under storyboards end-to-end? Runs against **sandbox-mode** accounts: the adopter's code path is exercised, not the mock. This certifies that L0–L3 plus the adopter's upstream combined produce the right wire behavior. The reference mock-server is the **spec-compliance oracle** — a black-box AdCP agent that storyboards run against. All language SDKs forward mock-mode traffic to it over HTTP, so the reference path is shared across the ecosystem. The mock-server is SDK-independent: a hand-rolled L0–L3 implementation can pass spec compliance by routing its own mock-flavored accounts to the same mock-server and verifying storyboard pass/fail against its own L3 wire behavior. For the authority chain and triage order when a failure implicates an SDK or the mock itself, see [Mock-server authority and failure triage](/dist/docs/3.1.20/building/verification/conformance#mock-server-authority-and-failure-triage). ## TL;DR * AdCP has five layers; the spec lives at L0–L3, your agent lives at L4. * "From scratch" means implementing L0–L3 yourself. That's a lot — see the [per-component breakdown](#why-sdks-matter-more-in-adcp-than-in-eg-http). * A full-stack AdCP SDK lifts you to L4. You write business logic; the SDK handles the protocol. Different language SDKs cover different subsets of L0–L3; pick one that matches how much of the protocol you want to inherit — see the [coverage matrix](#current-sdk-coverage). * **Version adaptation is an SDK feature, not an adopter project.** Per-call spec-version adapters, co-existence imports across SDK majors, and on-wire `adcp_major_version` negotiation let you talk to peers on any supported version without forking your handlers. Hand-rolled agents inherit the entire translation matrix forever. * Compliance comes in two flavors: **spec compliance** (mock-mode, protocol-only, L3 reference test) and **live compliance** (sandbox-mode, full-stack, L0–L4 end-to-end; planned). * If you last evaluated SDKs before 3.0, the comparison has moved — see [the L3 floor 3.0 added](/dist/docs/3.1.20/building/cross-cutting/version-adaptation#what-changed-at-l3-in-3-0). Re-evaluate against today's coverage, not the one you remember. # Version Adaptation Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/cross-cutting/version-adaptation Three version axes move at the same time when you ship an AdCP agent — spec version, SDK version, and per-peer version. SDKs ship three concrete mechanisms (per-call pinning, co-existence imports, on-wire negotiation) so adopters don't carry the translation matrix in handler code. Three versions move at the same time when you ship an AdCP agent or client: | Axis | Example | What changes | | --------------------------- | ----------------------------- | ----------------------------------------------------- | | **Spec version** | AdCP `2.5 → 3.0 → 3.1` | Wire shapes, error codes, lifecycle states, new tools | | **SDK version** | SDK `5.x → 6.x` | API surface, ergonomics, compile-time guarantees | | **Peer version** (per call) | Buyer at v3.0, seller at v2.5 | A single conversation crosses versions | Official SDKs ship three concrete mechanisms so adopters don't carry the translation matrix in handler code. This page is the recipe per mechanism. For the conceptual background see the [SDK stack — Version adaptation section](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#version-adaptation). For the spec-side rules see [Versioning](/dist/docs/3.1.20/reference/versioning). ## Mechanism 1 — Pin the spec version per call Use this when you're a **client** talking to a peer that's pinned to an older (or newer beta) spec version. The SDK runs your request and the peer's response through adapter modules so your handler code stays on the canonical (current) shape. ### Pin the version on a single agent JavaScript / TypeScript (`@adcp/sdk`): ```ts theme={null} import { ADCPMultiAgentClient } from '@adcp/sdk'; const client = ADCPMultiAgentClient.simple( 'https://legacy-agent.example.com/mcp/', { auth_token: process.env.AGENT_TOKEN, adcpVersion: 'v2.5', // ← pin here }, ); const agent = client.agent('default-agent'); const result = await agent.getProducts({ brief: 'CTV inventory' }); ``` Python and Go SDKs expose the same mechanism under their idiomatic call sites — see each SDK's repo. The shape is consistent: per-agent or per-call version pin, validated at construction time, with adapter modules translating to/from the canonical shape transparently. ### Validate the version up front `adcpVersion` (or the language equivalent) is validated at construction time. The SDK only accepts versions whose **schema bundle ships with the build** — if the bundle isn't present (e.g., you pinned a beta channel that hasn't been synced into your installed SDK), construction throws a typed configuration error with a pointer to the schema-sync tooling. To see what your installed SDK actually has bundled, query the SDK's compatibility-list export — every official SDK exposes one. For the spec-side authoritative list of what each AdCP version means on the wire, see [`schemas/`](https://github.com/adcontextprotocol/adcp/tree/main/dist/schemas). ### What the adapters actually do Each SDK ships per-tool adapter modules — pure shape translations (field renames, default population, structural reshaping). The SDK applies them transparently when the version pin is set; your handler sees the current shape regardless of which version the peer speaks. When AdCP 3.1 ships and you bump the SDK, a new adapter folder appears for the now-legacy 3.0. Your handlers don't move. ## Mechanism 2 — Migrate SDK majors via co-existence Use this when you bump your SDK from one major to the next and don't want to rewrite every handler the day you upgrade. Each SDK keeps the prior major's surface available alongside the new entry point. ### Example: `@adcp/sdk` 5.x → 6.x In v6.0, the v5 entry point was hard-removed from the top-level export. Existing v5 code keeps working by swapping one import path: ```ts theme={null} // v5 code — change only the import path import { createAdcpServer } from '@adcp/sdk/server/legacy/v5'; serve(() => createAdcpServer({ name: 'My Agent', version: '1.0.0', // …existing v5 handler bag — unchanged })); ``` Greenfield code in the same project uses the v6 entry point side by side: ```ts theme={null} import { createAdcpServerFromPlatform } from '@adcp/sdk/server'; const platform = new MyPlatform(); // implements DecisioningPlatform const server = createAdcpServerFromPlatform(platform, { name: 'my-agent', version: '1.0.0', }); ``` Both compile, both run, both pass conformance. You migrate one handler — or one specialism — at a time. The legacy subpath is a documented co-existence path, not a deprecation warning. Other-language SDKs follow the same pattern: prior-major surfaces remain importable from a versioned subpath alongside the current entry point. Check each SDK's release notes for the specific import paths. ### When to actually migrate Stay on the legacy surface as long as it keeps compiling and passing [conformance](/dist/docs/3.1.20/building/verification/conformance). Migrate a specialism when you want the new features (compile-time specialism enforcement, capability projection, idempotency / signing / async-task / status-normalization pre-wiring on greenfield code). There's no rush. ## Mechanism 3 — Wire-level negotiation Use this when you're a **server** and you want to be explicit about which spec versions you accept. ### Declare what you support `supported_versions` (release-precision strings) and/or `major_versions` go on your agent's capability declaration. Use release-precision strings — `'3.0'`, `'3.1'` — not the legacy aliases (`'v2.5'`, `'v3'`) used for client-side pinning. A 3.x server with no v2.5 handler logic should not declare `'v2.5'` here — its 2.5 callers go through *client-side* adapters at the buyer end, not the server's accepted-version set. If you want to surface your deployment's patch build for incident triage, use the `build_version` capability field — not `supported_versions`. Example with `@adcp/sdk` (lower-level handler-bag API): ```ts theme={null} import { createAdcpServer } from '@adcp/sdk/server'; const server = createAdcpServer({ name: 'My Agent', version: '1.0.0', capabilities: { major_versions: [3], supported_versions: ['3.0', '3.1'], // …other capability fields }, // …handlers }); ``` The union of `supported_versions` (parsed to majors) and `major_versions` defines the seller's accepted set on inbound `adcp_major_version` / `adcp_version` claims. See [Versioning — version negotiation](/dist/docs/3.1.20/reference/versioning#version-negotiation) for the spec rules and the bidirectional negotiation flow introduced in 3.1. ### What happens on a mismatch If a buyer's request carries an `adcp_major_version` (or `adcp_version`) that isn't in the accepted set, the SDK returns a `VERSION_UNSUPPORTED` error envelope. The envelope echoes the seller's `supported_versions` so the buyer can downgrade their pin without an out-of-band lookup. See [VERSION\_UNSUPPORTED error data](/dist/docs/3.1.20/reference/versioning#version-unsupported-error-data) for the envelope shape. ### Buyer side: two surfaces There are two places version mismatch can surface on the client, and they fire in different conditions: **1. Pre-flight typed exception.** When the client already has the peer's capabilities cached and knows up front that the call won't go through, the SDK throws a typed `VersionUnsupportedError` (or language equivalent) *before* sending the request. Catch it from the call site: ```ts theme={null} import { VersionUnsupportedError } from '@adcp/sdk'; try { const result = await agent.getProducts({ brief: '…' }); } catch (err) { if (err instanceof VersionUnsupportedError) { // peer doesn't support this call at the pinned version — // re-pin adcpVersion or switch agents } throw err; } ``` **2. `VERSION_UNSUPPORTED` envelope from the wire.** When the mismatch is only detected on the server side (e.g., the buyer's `adcp_major_version` parses different than the buyer's `adcp_version` string), the response carries a typed `VERSION_UNSUPPORTED` error envelope that echoes the seller's `supported_versions`: ```ts theme={null} const result = await agent.getProducts({ brief: '…' }); if (!result.success && result.adcpError?.code === 'VERSION_UNSUPPORTED') { const supported = result.adcpError.details?.supported_versions ?? []; // pick a version you also support, then re-issue with adcpVersion pinned } ``` `VERSION_UNSUPPORTED` is recovery-classified `correctable` — clients that handle it programmatically retry against a supported version. This is the third mechanism rather than a fallback to the first: negotiation tells you *what's possible*; per-call pinning tells the SDK *which one to use*. ## Putting it together A typical multi-version production setup: 1. **Server**: declare `supported_versions: ['3.0', '3.1']` in capabilities. The SDK accepts both on the wire and returns `VERSION_UNSUPPORTED` to anyone outside the set. (Only declare a version your handlers actually satisfy.) 2. **Client (per peer)**: pin `adcpVersion` (e.g., `'v2.5'`) based on what the registry or peer's capabilities advertise. The client-side adapters translate the wire shape so your application code stays on the current spec. 3. **SDK upgrades**: bump the SDK on your schedule; switch to the new entry point per specialism over time; keep the rest on the legacy import until you're ready. The combined effect: **one handler codebase, three version axes, no fork.** ## What this saves you from building A from-scratch agent has to: * Maintain a translation matrix between every spec version it claims to support, and update it every time a release ships. * Hand-roll API stability across its own internal refactors. * Implement the negotiation handshake (`adcp_major_version` parsing, `adcp_version` cross-checks, `VERSION_UNSUPPORTED` envelope shaping with the supported-versions echo). * Keep its conformance test surface in sync as new versions ship. Each of these compounds at every spec revision. SDKs absorb them so your team's effort goes into L4 differentiation, not into versioning plumbing. ## What changed at L3 in 3.0 If you're scoping a hand-rolled agent against today's spec, the L3 surface added with AdCP 3.0 is the largest delta from 2.5. Most of what an SDK does at L3 didn't exist as a published primitive before 3.0: * **Mandatory idempotency** — `idempotency_key` required on every mutating tool, with the `replayed: true` / `IDEMPOTENCY_CONFLICT` / `IDEMPOTENCY_EXPIRED` semantics declared on `get_adcp_capabilities`. See [Idempotency on Calling an agent](/dist/docs/3.1.20/protocol/calling-an-agent#idempotency-replay-vs-new-operation). * **Published lifecycle state machines** — seven resource types (`MediaBuy`, `Creative`, `Account`, `SISession`, `CatalogItem`, `Proposal`, `Audience`) with legal-edge enforcement and the `NOT_CANCELLABLE` / `INVALID_STATE` precedence. * **Conformance test surface** — [`comply_test_controller`](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) (sandbox-only) so storyboards drive state deterministically. Replaces ad-hoc per-seller test endpoints. * **RFC 9421 signatures as a baseline** — optional in 3.0, mandatory under AAO Verified. Replaces the loose-bearer-token posture of 2.5. * **Expanded error catalog with recovery classification** — 18 standard error codes with `transient` / `correctable` / `terminal` recovery semantics. Hand-rolled 2.5 agents typically returned unstructured error strings. * **Async-task contract** — every mutating tool can be sync or async; the contract for which terminal artifact closes the task is specified. * **Webhook signing** — push notifications signed with the same RFC 9421 profile as outbound requests; replay-window + retry semantics specified. For the full 3.0 changelog (protocol-wide, not just L3), see [What's new in v3](/dist/docs/3.1.20/reference/whats-new-in-v3). For the migration path, see [Migrate from a hand-rolled agent](/dist/docs/3.1.20/building/by-layer/L4/migrate-from-hand-rolled). A from-scratch 2.5 agent was tractable; a from-scratch 3.0 agent is the [3–4 person-month L3 build](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#why-sdks-matter-more-in-adcp-than-in-eg-http) decomposed in the SDK stack reference. SDKs exist because L3 grew faster than implementers could hand-roll. ## See also * [The AdCP stack](/dist/docs/3.1.20/building/cross-cutting/sdk-stack) — layered architecture reference * [Where to start](/dist/docs/3.1.20/building) — decision page * [Versioning](/dist/docs/3.1.20/reference/versioning) — spec-side version rules * [What's new in v3](/dist/docs/3.1.20/reference/whats-new-in-v3) — protocol-wide 3.0 changelog * [Migrate from a hand-rolled agent](/dist/docs/3.1.20/building/by-layer/L4/migrate-from-hand-rolled) — when adopting a stack with mid-flight buyers on different spec versions # Building with AdCP Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/index Single decision page for AdCP implementers. Pick the right entry point — caller, agent, or migration — based on what you're building and how much of the protocol you want to inherit. You're about to write AdCP code. The first decision is **where you want to spend your engineering time** — letting an AdCP SDK handle the protocol surface so you focus on what makes your agent or caller yours, or going lower. This page picks an entry point. The [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack) is the reasoning. ## The five layers Every AdCP request crosses five layers between the wire and your business logic. SDKs absorb L0–L3; **L4 is the part you write either way.** ```mermaid theme={null} %%{init: {"flowchart": {"htmlLabels": true, "wrappingWidth": 9999}, "themeVariables": {"fontSize": "14px"}}}%% flowchart TB subgraph yours["yours, always"] L4["L4 — Business logic
Inventory · pricing · creative review · planning · buying · reporting
What makes your agent or caller yours"] end subgraph sdk["what an AdCP SDK provides"] direction TB L3["L3 — Protocol semantics
Lifecycle state machines · idempotency · error catalog · async tasks · webhooks · conformance"] L2["L2 — Auth & registry
Principal resolution · brand resolution · multi-tenant account routing"] L1["L1 — Identity & signing
RFC 9421 message signatures · JWKS · replay-window · key rotation"] L0["L0 — Wire & transport
JSON-over-HTTP · MCP envelopes · A2A SSE · schema validation · typed bindings"] L3 ~~~ L2 L2 ~~~ L1 L1 ~~~ L0 end L4 ~~~ L3 ``` Where you start determines how much of L0–L3 you own. The work is asymmetric across the two sides of an AdCP conversation: an agent **enforces** the protocol (\~3–4 person-months at L0–L3); a caller **consumes** it (weeks of handler glue). The [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack) decomposes each layer and the [server-vs-client comparison](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#server-vs-client-at-each-layer) makes the cost asymmetry concrete. ## Recommended path For \~95% of adopters: **start at L4 with the full-stack SDK in your language.** Server side, with engineering capacity. Point a coding agent at a skill file, get a storyboard-compliant AdCP agent in 2–8 minutes (protocol layer only — see [Operating an agent](/dist/docs/3.1.20/building/operating/operating-an-agent) for the full going-live scope). Client side. Install the SDK, write the calls, handle async responses and errors, ingest reporting. Weeks-of-handler-glue scope, not months. Small team or no engineering capacity. Self-host [Prebid SalesAgent](https://github.com/prebid/salesagent), or partner with a managed platform that runs an AdCP agent on your behalf. Configuration over code. Already running an AdCP agent built before the SDKs were mature? Swap one layer at a time, with conflict modes, per-step rollback, and intermediate states that pass conformance. Porting an SDK to a new language, building a special-purpose proxy, or integrating into a stack that already owns L0–L2. Read the SDK stack reference for the per-layer contract. After your first build: run [Validate your agent](/dist/docs/3.1.20/building/verification/validate-your-agent) to confirm conformance against the storyboards, then enroll for the [AAO Verified](/dist/docs/3.1.20/building/verification/aao-verified) trust mark. ## Two checks before you start **1. What's your team's value-add?** * **Inventory, pricing, creative review, decisioning** (agent L4) or **planning, buying, reporting** (caller L4) → start at L4. * **You're an SDK author / language porter, building a proxy, or integrating into a stack that already owns L0–L2** → go lower. Read the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack) for what each layer must satisfy. * **Anything else** → default to L4. See [what you write at each layer](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#where-can-you-start) before going lower. **2. Do you already have a working agent built before the SDKs were mature?** If yes: re-evaluate against [today's SDK coverage](/dist/docs/3.1.20/building/cross-cutting/sdk-stack#current-sdk-coverage), not the SDK you remember. The [L3 changelog from 2.5](/dist/docs/3.1.20/building/cross-cutting/version-adaptation#what-changed-at-l3-in-3-0) names the new floor; the [migration guide](/dist/docs/3.1.20/building/by-layer/L4/migrate-from-hand-rolled) walks the swap-one-layer-at-a-time path. ## Cross-cutting concerns Things every AdCP implementation needs, regardless of starting layer: * **[Schemas](/dist/docs/3.1.20/building/by-layer/L0/schemas)** — schema bundles, type generation, version pinning, supply-chain verification. * **[Choose your SDK](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk)** — language coverage matrix, install commands, package exports, CLI tools. * **[Version adaptation](/dist/docs/3.1.20/building/cross-cutting/version-adaptation)** — per-call spec-version pinning, co-existence imports across SDK majors, on-wire `VERSION_UNSUPPORTED` negotiation. Lets you talk to peers on any supported version without forking handler code. * **[Conformance](/dist/docs/3.1.20/building/verification/conformance)** — the L3 protocol bar every agent must reach. Storyboards probe state transitions, error shapes, and the async-task contract. * **[AAO Verified](/dist/docs/3.1.20/building/verification/aao-verified)** — the public trust mark. Storyboard pass earns `(Spec)`; observed live traffic earns `(Live)`. * **[Operating an agent](/dist/docs/3.1.20/building/operating/operating-an-agent)** — runtime concerns once you're past first launch: monitoring, on-call, version drift, multi-tenant scaling. ## Going deeper * **[Concepts](/dist/docs/3.1.20/building/concepts)** — why AdCP exists, protocol comparison, security model, industry context. Start here if you're new and want context before code. * **[L0 — Wire & transport](/dist/docs/3.1.20/building/by-layer/L0)** — MCP and A2A integration, schema validation, type generation. * **[L2 — Auth & registry](/dist/docs/3.1.20/building/by-layer/L2)** — authentication, multi-tenant account state, principal scoping. * **[L3 — Protocol semantics](/dist/docs/3.1.20/building/by-layer/L3)** — task lifecycle, async operations, webhooks, error handling, conformance test surface. * **[Talk to Addie](/dist/docs/3.1.20/learning/overview)** — stuck on a concept? Addie is the AdCP teaching agent. She walks the protocol with you in conversation, and runs the certification path when you're ready to make it stick. ## Audience AdCP implementers in any language — building an agent, building a caller, authoring an SDK, or evaluating one. **Python and TypeScript are the first-class languages** (TypeScript GA on `@adcp/sdk` 6.x; Python GA on `adcp` 3.x with 4.x in beta); **Go** has L0 and partial L1 in active development; community-maintained ports for other languages are welcome — see the [Builders Working Group](/dist/docs/3.1.20/community/working-group). # Operating an Agent Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/operating/operating-an-agent What sits behind a protocol-compliant agent — products, activation, hosting, and whether to partner, self-host, or build. The 2–8 minute [Build an Agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent) path gets you a protocol-compliant agent. This page is what comes after — the business infrastructure behind each tool call, and how to decide who runs it. The worked example here is a **sales agent**, since that's the most infrastructure-heavy case. Short callouts mark where the operational concerns diverge for signals, creative, and retail-media agents. ## What the SDK already handles Before listing what's missing, it helps to be explicit about what a storyboard-validated agent already gives you: * AdCP tool schemas and typed registration * Request/response shapes that pass compliance * Error formats and version negotiation * A starting point with example products you can swap for real ones Everything below is what sits behind those tool handlers. ## Partner, self-host, or build There are three paths to a live agent. They differ in how much you own, not whether you own anything at all — in every case you still own products, pricing, and the activation into your ad server. **Partner with a managed sales agent platform.** The platform runs the agent endpoint, holds state, and exposes admin UIs for your ad ops team to manage products, pricing, and approvals. You connect your ad server; the platform handles the protocol and the operations around it. Fastest to live, least control. **Self-host a prebuilt agent.** Deploy an existing open-source agent — today this usually means the [Prebid Sales Agent](https://github.com/prebid/salesagent), a community full-stack seller agent with GAM integration — on your own infrastructure, and connect it to your systems. You skip writing the protocol layer and admin UI, but you own hosting, upgrades, database, and the ad-server wiring. Middle ground: more control than partnering, less work than building. **Build your own.** Use the SDK and skill files to write a custom agent. You get full control over business logic, pricing models, and activation paths, at the cost of owning the code alongside everything else. Right answer when no prebuilt agent fits your stack or pricing model. The SDKs and storyboards cover protocol compliance across all three paths. Everything in the table below is what's outside that line, regardless of which path you take. ## What you'll still need to build or provision | Component | What it does | Example approaches | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Product & pricing management** | Lets ad ops define products, rate cards, packaging, and availability without code changes. The skill files hardcode example products; a real agent needs these dynamic and editable. | A partner platform's product catalog, a prebuilt agent's admin UI (e.g. Prebid Sales Agent), or a custom admin UI backed by a database. | | **Persistent storage** | Stores products, pricing, media buys, creative assignments, and delivery state across requests. | PostgreSQL, MySQL, or any datastore your team is comfortable operating. | | **Creative review & policy** | Applies brand-safety, legal, and format checks to creatives before they go live. The protocol carries the creative; your policy decides whether to accept it. | Human review queue, automated policy engine (IAB categories, brand lists), or a hybrid. Often the first thing ad ops teams underestimate. | | **Trafficking & fulfillment** | Turns a sold media buy into a live campaign in your ad server. This sits entirely outside AdCP and is usually the hardest piece. | *Manual:* email or Slack alerts that prompt ad ops to set up campaigns in GAM. *Semi-automated:* Google Ad Manager API to create line items and assign creatives. *Full automation:* end-to-end pipeline from buy confirmation to live delivery. | | **Delivery reporting & performance** | Powers `get_media_buy_delivery` and `provide_performance_feedback` with real numbers. The protocol tools exist; the log ingest, aggregation, and attribution pipeline behind them is yours to build. | Ad server reporting API pulls, log-level ingest into a warehouse, or a managed analytics tool feeding the agent. | | **Order management** | Tracks buy status, approval workflows, creative deadlines, and pacing beyond what the protocol's task lifecycle covers. | Status fields in your database to start; a dashboard as volume grows. | | **Hosting** | Keeps your agent live at the URL you declare in operator `brand.json` and publisher `adagents.json`. Downtime or URL drift breaks buyer verification. | Cloud VM, container service (Cloud Run, ECS, Fly.io), or a managed hosting platform. | | **Discovery and authorization records** | Tells buyer agents who operates the endpoint, where its signing keys live, and which publisher authorizes it for which inventory. Protocol support is declared by `get_adcp_capabilities`. | Publish operator `brand.json` and publisher `adagents.json`. See [Seller setup](/dist/docs/3.1.20/brand-protocol/seller-setup) and [Authorized Properties](/dist/docs/3.1.20/governance/property/authorized-properties). | ## Where the protocol ends and your business begins AdCP defines the shape of the conversation between agents. It does not define: * **Pricing strategy** — how you price inventory, how rate cards flex, when discounts apply * **Approval policy** — which campaigns you accept or reject, and on what grounds * **Billing and invoicing** — no spec-level billing; you reconcile with buyers out-of-band * **Identity and consent** — user-level identity, consent capture, and data-subject rights are regulated and implementation-specific * **SLA monitoring** — uptime, latency, and error budgets for your agent endpoint * **Ad ops workflow** — how your team monitors pacing, makegoods, and escalations A partner platform makes default choices for most of these; a self-hosted prebuilt agent gives you its defaults to override; a self-built agent makes you make every call. ## Where this differs by agent type The components table above assumes a sales agent. Other agent types share most of it but have specific additions: * **Signals agents** — consent and provenance are load-bearing. You need a defensible data lineage (where a segment came from, what consent covers it) and the ability to honor opt-outs. Activation is less about ad-server trafficking and more about delivering segments to platforms that already ingest them. * **Creative agents** — asset storage, transcoding, and rendering SLAs replace ad-server trafficking. Creative review becomes policy on what you'll render, not what you'll traffic. * **Retail-media agents** — catalog freshness is the operational constraint; your products change as SKUs change. Activation often runs through retail-specific ad platforms rather than GAM. ## Operating the agent in production A protocol-compliant agent is not the same as a well-operated agent. That's two different concerns: **ongoing ad-ops health** (the protocol stuff actually doing its job) and **security** (the protocol stuff staying trustworthy when it does). Treat both with the same seriousness you give your ad-server integration. ### Ad-ops health monitoring — what you actually watch This is the day-to-day job. Most of it is protocol-aware extensions of the monitoring any ad ops team already runs. The point is not "new concepts to learn" — it's "here are the specific protocol signals you need dashboards for." Addie can help you set these up. | What to watch | Why it matters | Signals to alert on | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Open task queue depth** | Async tasks (`create_media_buy` proposals, `sync_creatives` approvals, `si_initiate_session`) stack up when approvers fall behind. Deep queues mean buyers are waiting. | Tasks open > SLA (e.g. creative approvals > 4h), oldest task age trending up, queue growing faster than it drains. | | **Creative approval throughput** | Creatives need to actually get approved or rejected. A queue that silently stops moving looks identical to "everything's fine" — except buys aren't launching. | Approval/rejection rate vs. submission rate, creatives older than expected review window still `pending`, rejection reason distribution. | | **Lifecycle transitions firing on time** | The seller MUST transition `pending_start` → `active` when the flight date arrives. Missed transitions leave campaigns never-delivered even though they "created" fine. | Buys still in `pending_start` past flight start, buys in `pending_creatives` past creative arrival, `paused` buys that should have auto-resumed. | | **Webhook delivery health** | Webhooks are how buyers learn about async state changes. Silent delivery failures mean buyers poll instead — or don't, and miss the event entirely. | Failed delivery rate, retry backlog, dead-letter queue size, time from state change to successful push. | | **Status correctness for non-launchable buys** | Campaigns that *can't* go live (creative rejected, account suspended, policy denial) MUST be reflected in the status — not silently stuck. Governance depends on this. | Buys that are neither delivering nor showing a terminal/blocked status; mismatch between ad-server state and AdCP state. | | **Delivery reporting freshness and accuracy** | `get_media_buy_delivery` should return current numbers. Stale reporting means buyers optimize on lies. | Last-updated timestamp trailing, spend deltas that don't reconcile with ad-server logs. | | **Idempotency-cache behavior** | A retried `create_media_buy` must return the cached response, not create a duplicate. Compliance tests this in a sandbox — production drift is its own signal. | `IDEMPOTENCY_CONFLICT` rate (bug in buyer or attacker probing), `IDEMPOTENCY_EXPIRED` for ongoing work (TTL too short), duplicate media buys created in the ad server (cache failure). | | **Error-code distribution** | A spike in a specific error code from a specific buyer is usually the first signal of a real problem. | Top-N error codes per buyer per hour, new error codes you haven't seen before. | This is the monitoring a sales agent needs regardless of who wrote the code. A partner platform should expose dashboards for most of this; a self-hosted prebuilt agent requires you to wire them up; a self-built agent puts every row on your team. ### Security monitoring — what compliance covers vs. what stays with you **Most AdCP security mechanics are enforced by the [compliance suite](/dist/docs/3.1.20/building/verification/validate-your-agent) — you don't need to hand-verify them.** The storyboard runner verifies authentication, idempotency, schema conformance, error handling, and governance behavior against your agent. If the suite passes, the wire-level behavior is correct. You don't need to learn agent/account scoping internals, JWS verification steps, or canonical JSON — the tests do. What the compliance suite checks (so you don't have to teach it): | Storyboard | What it verifies | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `security_baseline` (universal) | Auth is required on protected operations; invalid keys rejected; at least one of API key or OAuth 2.0 is correctly advertised. | | `idempotency` (universal) | Mutating requests honor `idempotency_key`: replay returns cached, conflict returns `IDEMPOTENCY_CONFLICT`, missing key returns `INVALID_REQUEST`. | | `schema-validation`, `error-compliance` | Responses match schemas; errors use the standard taxonomy. | | Protocol + specialism storyboards | Per-protocol behavior (media buy lifecycle, creative workflow, signals activation) is correct end-to-end. | | Request-signing test vectors | RFC 9421 signed requests validate correctly across 25+ positive and negative cases. | **What you still own**, regardless of who wrote the code: | Concern | What you decide and run | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Credential storage and rotation** | Store tokens in a KMS/secret manager. Use short-lived tokens (≤24h for write-capable; consider ≤1h for tokens that can commit spend). Never log in full, never commit. The right rotation cadence depends on the blast radius of a leaked token — justify yours, don't copy a number. | | **Key ceremony and break-glass** | Have a documented process for generating, transporting, and destroying high-value keys (webhook secrets, governance signing keys). Maintain sealed break-glass credentials for incident recovery — and a procedure for using them that leaves an audit trail. | | **Idempotency replay TTL** | Declare `capabilities.idempotency.replay_ttl_seconds` — floor 1h, recommended 24h, max 7d — and verify the declared value matches actual cache retention. Prebuilt agents expose this as config; self-built bake it in. | | **Cross-instance and multi-region failover** | The idempotency cache, session store, and webhook dedup state must survive an instance restart and be shared across horizontally scaled instances. In a multi-region deployment, the cache must be consistent enough that a retry routed to region B replays a buy originally processed in region A. Memory-only state breaks at-most-once guarantees on the first pod restart. | | **Security signal monitoring** | Watch for `IDEMPOTENCY_CONFLICT` spikes (probing), failed governance verifications (spoofing), SSRF rejections from a single counterparty, 401/403 spikes from one peer. Same dashboard as ops monitoring — just different alerts. | | **Vendor risk on your counterparties** | Your governance agent, your signals providers, your creative vendors all hold credentials or issue signed tokens that affect your campaigns. Assess them the way you assess any processor: disclosure policy, breach notification commitment, compliance attestations, uptime SLA. | | **Incident response runbook** | Know how to revoke a compromised credential in under an hour, rotate webhook secrets, notify counterparties, publish a `revoked_kids` entry if you issue governance tokens. Tabletop it before you need it. | If you're **partnering**, ask the vendor: "Do you pass the AdCP compliance suite on every release? Which version? Can I see the latest run?" That single question covers most of the code-controls surface. Also ask whether they hold SOC 2 Type II, ISO 27001, or the equivalent attestation for your industry — and what their breach-notification commitment is. If you're **self-hosting a prebuilt agent**, run the compliance suite yourself against your deployment. If you're **self-building**, the compliance suite is your regression harness. **For security and IT leaders:** The [Security Model](/dist/docs/3.1.20/building/concepts/security-model) page is written for you — CISOs, security architects, and third-party risk reviewers at brands, agencies, publishers, and platforms alike. It explains the threat landscape and what AdCP defends against by design, and includes a checklist of questions to ask your engineering team (or your vendor) before going live. ## What's next * **[Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** — The compliance suite referenced above * **[Security](/dist/docs/3.1.20/building/by-layer/L1/security)** — The normative reference for HMAC, idempotency, SSRF, and governance verification * **[How Agents Communicate](/dist/docs/3.1.20/building/concepts/how-agents-communicate)** — Discovery via `adagents.json` and `brand.json` * **[Seller Integration](/dist/docs/3.1.20/building/operating/seller-integration)** — Patterns for connecting an agent to an ad server * **[Authorized Properties](/dist/docs/3.1.20/governance/property/authorized-properties)** — Who can sell what, and how that's declared # Orchestrator Design Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/operating/orchestrator-design AdCP orchestrator design: state machine patterns, persistent operation tracking, async-first architecture, and reconciliation for multi-vendor campaign workflows. This guide covers best practices for building AdCP orchestrators that handle asynchronous operations, pending states, and human-in-the-loop workflows. ## Core Design Principles ### 1. Asynchronous First The AdCP protocol is inherently asynchronous. Operations may take seconds, hours, or even days to complete. **DO:** * Design all operations as async/await * Store operation state persistently * Handle orchestrator restarts gracefully * Implement proper timeout handling **DON'T:** * Assume immediate completion * Use synchronous blocking calls * Store state only in memory * Retry indefinitely without backoff ### 2. Status-Driven Logic Operations progress through standardized status values: ```python theme={null} TASK_STATUSES = { "submitted", # Long-running (hours to days) - provide webhook or poll "working", # Processing (< 120 seconds) - poll frequently "input-required", # Need user input/approval - continue conversation "completed", # Success - process results "failed", # Error - handle appropriately "canceled", # User canceled "auth-required" # Need authentication } ``` ### 3. State Machine Design Implement proper state machines aligned with AdCP task statuses: ```python theme={null} class OperationState(Enum): # Local orchestrator states REQUESTED = "requested" CALLING_ADCP = "calling_adcp" # AdCP task states (match server responses) SUBMITTED = "submitted" WORKING = "working" INPUT_REQUIRED = "input_required" COMPLETED = "completed" FAILED = "failed" CANCELED = "canceled" # Valid state transitions VALID_TRANSITIONS = { "requested": ["calling_adcp"], "calling_adcp": ["submitted", "working", "input_required", "completed", "failed"], "submitted": ["working", "completed", "failed", "canceled"], "working": ["completed", "failed", "input_required"], "input_required": ["submitted", "working", "completed", "failed"] } ``` ## Operation Tracking ### Persistent Storage Store all operations with comprehensive tracking: ```python theme={null} class OperationTracker: def __init__(self, db): self.db = db async def create_operation(self, operation_type, request_data, webhook_config=None): operation = { "id": str(uuid.uuid4()), "type": operation_type, "status": "requested", "request": request_data, "webhook_config": webhook_config, "created_at": datetime.now(), "updated_at": datetime.now(), "task_id": None, "context_id": None, "result": None, "error": None } await self.db.operations.insert_one(operation) return operation["id"] async def update_status(self, operation_id, status, **kwargs): update = { "status": status, "updated_at": datetime.now() } update.update(kwargs) await self.db.operations.update_one( {"id": operation_id}, {"$set": update} ) async def get_pending_operations(self): """Get all operations that need monitoring""" return await self.db.operations.find({ "status": {"$in": ["submitted", "working", "input_required"]} }).to_list(length=None) ``` ### State Reconciliation Sync local state with server on startup: ```python theme={null} async def reconcile_with_server(self, adcp_client): """Sync local state with server using AdCP task reconciliation""" server_tasks = await adcp_client.call('list_tasks', { 'filters': {'statuses': ['submitted', 'working', 'input_required']} }) server_task_ids = {task['task_id'] for task in server_tasks['tasks']} local_operations = await self.get_pending_operations() local_task_ids = {op['task_id'] for op in local_operations if op['task_id']} return { 'orphaned_on_server': server_task_ids - local_task_ids, 'missing_from_server': local_task_ids - server_task_ids, 'total_pending_server': len(server_tasks['tasks']), 'total_pending_local': len(local_operations) } ``` ## Async Operation Handler ### Response Routing Handle responses based on status: ```python theme={null} class AsyncOperationHandler: def __init__(self, adcp_client, tracker, notifier): self.adcp = adcp_client self.tracker = tracker self.notifier = notifier self.polling_tasks = {} async def handle_operation_response(self, operation_id, response): """Handle any AdCP response with proper status routing""" status = response.get("status") # Update operation with response details await self.tracker.update_status( operation_id, status, task_id=response.get("task_id"), context_id=response.get("context_id"), result=response.get("result") if status == "completed" else None, error=response.get("error") if status == "failed" else None ) # Route based on status if status == "completed": await self._handle_completed(operation_id, response) elif status == "failed": await self._handle_failed(operation_id, response) elif status == "submitted": await self._handle_submitted(operation_id, response) elif status == "working": await self._handle_working(operation_id, response) elif status == "input_required": await self._handle_input_required(operation_id, response) ``` ### Submitted Operations Handle long-running operations: ```python theme={null} async def _handle_submitted(self, operation_id, response): """Handle long-running operations""" task_id = response["task_id"] # Check if webhook is configured operation = await self.tracker.get_operation(operation_id) webhook_config = operation.get("webhook_config") if webhook_config: # Webhook will handle completion notification await self.notifier.notify_submitted_with_webhook(operation_id, task_id) else: # Start polling for completion polling_task = asyncio.create_task( self._poll_for_completion(operation_id, task_id, interval=60) ) self.polling_tasks[task_id] = polling_task ``` ### Polling with Backoff Implement efficient polling: ```python theme={null} async def _poll_for_completion(self, operation_id, task_id, interval=60): """Poll task status until completion""" max_polls = 1440 if interval == 60 else 24 # 24 hours or 2 minutes poll_count = 0 while poll_count < max_polls: try: await asyncio.sleep(interval) poll_count += 1 task_response = await self.adcp.call('get_task_status', { 'task_id': task_id, 'include_result': True }) await self.handle_operation_response(operation_id, task_response) if task_response["status"] in ["completed", "failed", "canceled"]: break except Exception as e: await self.tracker.update_status( operation_id, "failed", error=f"Polling error: {str(e)}" ) break self.polling_tasks.pop(task_id, None) if poll_count >= max_polls: await self.tracker.update_status( operation_id, "failed", error="Task polling timeout" ) ``` ## Webhook Support ### Reliable Webhook Handler Implement webhooks with reliability patterns: ```python theme={null} class WebhookHandler: def __init__(self, tracker, notifier, secret_key): self.tracker = tracker self.notifier = notifier self.secret_key = secret_key self.processed_events = {} def verify_webhook_signature(self, payload: bytes, signature: str) -> bool: """Verify webhook authenticity""" expected_signature = hmac.new( self.secret_key.encode(), payload, hashlib.sha256 ).hexdigest() return signature == f"sha256={expected_signature}" async def is_replay_attack(self, timestamp: str, event_id: str) -> bool: """Prevent replay attacks using timestamp and event ID""" event_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) now = datetime.now() if now - event_time > timedelta(minutes=5): return True return event_id in self.processed_events ``` ### Webhook + Polling Backup Never rely solely on webhooks: ```python theme={null} class ReliableWebhookOrchestrator: def __init__(self): self.webhook_timeout = timedelta(minutes=10) self.backup_polling_delay = timedelta(minutes=2) async def _handle_submitted_with_webhook(self, operation_id, task_id): """Handle submitted task with webhook + backup polling""" async def backup_polling(): await asyncio.sleep(self.backup_polling_delay.total_seconds()) operation = await tracker.get_operation(operation_id) if operation["status"] not in ["completed", "failed", "canceled"]: logger.info(f"Starting backup polling for task {task_id}") await self._poll_for_completion(operation_id, task_id, interval=60) asyncio.create_task(backup_polling()) ``` ## Example Orchestrator Complete orchestrator implementation: ```python theme={null} class AdCPOrchestrator: def __init__(self): self.adcp = AdCPClient() self.tracker = OperationTracker(db) self.handler = AsyncOperationHandler(self.adcp, self.tracker, UserNotifier()) self.webhook_base_url = "https://orchestrator.com/webhooks" async def create_campaign(self, user_id, request, enable_webhook=True): """Create a campaign with governance validation and full async handling. Plans must already be synced via sync_plans before calling this method. Plan creation happens during the planning phase, not at campaign creation time. """ # 1. Run intent check (plan must already exist) if request.get("governance_context"): gov_check = await self.adcp.call("check_governance", { "plan_id": request["governance_context"]["plan_id"], "caller": request["governance_context"]["caller"], "tool": "create_media_buy", "payload": request }) if gov_check["status"] == "denied": raise GovernanceDeniedError(gov_check["explanation"]) if gov_check["status"] == "conditions": raise GovernanceConditionsError(gov_check["conditions"]) # If check_governance needs human review internally, it returns # async task status (submitted/working) and resolves to # approved or denied — standard task lifecycle. # 2. Create the media buy await self._create_media_buy(user_id, request, enable_webhook) async def _create_media_buy(self, user_id, request, enable_webhook=True): """Create a media buy with full async handling.""" # 1. Prepare webhook configuration webhook_config = None if enable_webhook: webhook_config = { "webhook_url": f"{self.webhook_base_url}/adcp/{user_id}", "webhook_auth": { "type": "bearer", "credentials": await self.get_webhook_token(user_id) } } # 2. Create operation record operation_id = await self.tracker.create_operation( "create_media_buy", request, webhook_config=webhook_config ) try: # 3. Call AdCP response = await self.adcp.call("create_media_buy", request, webhook_config) # 4. Handle response await self.handler.handle_operation_response(operation_id, response) # 5. Return appropriate response to user return self._format_user_response(operation_id, response) except Exception as e: await self.tracker.update_status(operation_id, "failed", error=str(e)) raise async def reconcile_state_on_startup(self): """Recover from orchestrator restart""" reconciliation = await self.tracker.reconcile_with_server(self.adcp) logger.info(f"State reconciliation: {reconciliation}") for task_id in reconciliation["orphaned_on_server"]: # Resume monitoring orphaned tasks operation_id = await self.tracker.create_operation( "unknown", {}, status="submitted" ) await self.tracker.update_status(operation_id, "submitted", task_id=task_id) asyncio.create_task( self.handler._poll_for_completion(operation_id, task_id) ) ``` ## Governance in the Campaign Lifecycle Plan creation ([`sync_plans`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans)) happens during the planning phase — before any campaigns exist. Governance checks happen during campaign execution. These are separate concerns. **Planning phase** (once per media plan): ``` sync_plans — orchestrator pushes the plan to the governance agent ``` **Campaign execution** (per media buy): ``` check_governance(tool + payload) → create_media_buy → check_governance(media_buy_id + planned_delivery) → delivery → report_plan_outcome ``` | Phase | Who calls | Task | What happens on failure | | --------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Intent check | Orchestrator | [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) (`tool` + `payload`) | Campaign violates buyer's plan — denied or conditioned before any spend. If the governance agent needs human review, the task goes async and resolves to approved or denied. | | Execution check | Seller | [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) (`media_buy_id` + `planned_delivery`) | Seller's delivery plan doesn't match buyer's expectations — purchase blocked | | Delivery check | Seller | [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) (`phase: delivery` + `delivery_metrics`) | Drift detected — pacing, geo, or channel distribution deviates from plan | | Plan outcome | Orchestrator | [`report_plan_outcome`](/dist/docs/3.1.20/governance/campaign/tasks/report_plan_outcome) | No feedback loop — governance agent cannot improve future recommendations | See the [media buy governance workflow](/dist/docs/3.1.20/media-buy/index#governance) for the complete sequence with code examples, and the [seller integration guide](/dist/docs/3.1.20/building/operating/seller-integration#execution-checks) for the seller's execution check obligations. ## Best Practices ### 1. Persistent Storage Always use persistent storage for operation state: * Database (PostgreSQL, MongoDB) * Message queue (Redis, RabbitMQ) * Distributed cache (Redis Cluster) ### 2. Idempotency Make all operations idempotent: ```python theme={null} async def create_media_buy_idempotent(self, request): existing = await self.db.operations.find_one({ "type": "create_media_buy", "request.po_number": request["po_number"], "status": {"$in": ["created", "active"]} }) if existing: return existing["result"] return await self.create_media_buy(request) ``` ### 3. Timeout Handling Implement reasonable timeouts: ```python theme={null} OPERATION_TIMEOUTS = { "create_media_buy": timedelta(hours=24), "update_media_buy": timedelta(hours=12), "creative_approval": timedelta(hours=48) } ``` ### 4. Error Recovery Implement retry logic with circuit breakers: ```python theme={null} @retry( stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=60), retry=retry_if_exception_type(TransientError) ) async def call_adcp_api(self, tool, params): try: return await self.adcp.call(tool, params) except RateLimitError: raise TransientError("Rate limited") except NetworkError: raise TransientError("Network error") ``` ### 5. Monitoring and Alerting Track key metrics: * Pending operation count by type * Average approval time * Rejection rate * Task timeout rate * API error rate ## User Communication Keep users informed about pending operations: ```python theme={null} class UserNotifier: async def notify_pending_approval(self, user_id, operation): message = { "type": "pending_approval", "operation_id": operation["id"], "message": "Your media buy requires publisher approval", "estimated_time": "2-4 hours" } await self.send_notification(user_id, message) async def notify_approval(self, user_id, operation): message = { "type": "operation_approved", "operation_id": operation["id"], "message": "Your media buy has been approved", "media_buy_id": operation["result"]["media_buy_id"] } await self.send_notification(user_id, message) ``` ## Summary Building a robust AdCP orchestrator requires: 1. Asynchronous design throughout 2. Proper state management with persistence 3. Graceful handling of pending states 4. User communication for long-running operations 5. Monitoring and observability Remember: Pending states are not errors - they're a normal part of the advertising workflow. ## Next Steps * **Task Lifecycle**: See [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) for status handling * **Webhooks**: See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for push notifications * **Security**: See [Security](/dist/docs/3.1.20/building/by-layer/L1/security) for multi-tenant security # Making your inventory available to AI agents Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/operating/seller-integration Seller integration guide for AdCP. How publishers, SSPs, and ad platforms expose inventory to AI buyer agents through standardized product discovery and media buy tasks. AI agents are starting to buy media. When an agency's AI assistant is evaluating ad inventory across platforms, it needs a way to discover what you sell, understand your pricing and targeting options, and execute a buy — all through a standard interface. AdCP (Ad Context Protocol) provides that interface. If you're a publisher, SSP, or ad platform, implementing AdCP makes your inventory accessible to any compliant buyer agent without requiring custom integrations for each one. ## Why this matters Today, every platform requires buyers to learn a proprietary API. That works when humans are doing the buying. But AI agents work across many platforms simultaneously, and they need a shared language for common operations. Platforms that implement AdCP are discoverable by buyer agents out of the box. Platforms that don't require each buyer to build a custom integration — which limits the pool of agents that can access your inventory. ## What you need to implement AdCP sell-side integration has four parts: ### Publish publisher authorization Publish an `adagents.json` file on each publisher domain. This file declares the publisher's properties and the agents authorized to sell or enrich that inventory — similar to how `ads.txt` works for supply chain transparency. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "contact": { "name": "Example Publisher", "email": "adops@publisher.example.com", "domain": "publisher.example.com" }, "properties": [ { "property_id": "publisher_home", "property_type": "website", "name": "Example Publisher", "publisher_domain": "publisher.example.com", "identifiers": [{ "type": "domain", "value": "publisher.example.com" }] } ], "authorized_agents": [ { "authorization_type": "property_ids", "url": "https://ads.publisher.example.com/mcp", "authorized_for": "Example Publisher direct inventory", "property_ids": ["publisher_home"], "signing_keys": [ { "kid": "publisher-sales-prod-2026", "kty": "OKP", "alg": "EdDSA", "crv": "Ed25519", "x": "w8zcY1LZqV4n1oKbfyq3n2q3sL2uV3z7kEw1m9Qjv4A", "use": "sig" } ] } ] } ``` Buyer agents check `adagents.json` to verify publisher authorization and, for mutating seller authorizations, publisher-pinned signing keys. They discover the agent's protocol coverage from `get_adcp_capabilities`, not from `adagents.json`. ### Publish operator identity Publish `brand.json` at `https://{your-domain}/.well-known/brand.json` for the organization operating the sales agent. `adagents.json` tells buyers which publisher authorizes a sales agent; `brand.json` tells buyers which operator is claiming the sales path, which properties it represents, and where to find signing keys. For publisher-owned sales teams, list owned or direct properties. For networks and SSPs, list represented properties with `relationship: "delegated"` or `relationship: "ad_network"` and make sure that value matches the publisher's `authorized_agents[].delegation_type` in `adagents.json`. The two files form the bilateral verification chain: the operator declares "I sell this property", and the publisher confirms "this operator is authorized to sell it". A publisher that delegates sales should still publish `adagents.json`; its own `brand.json` is recommended for publisher identity and portfolio context, but the delegated operator's `brand.json` is the required identity record for the agent buyers call. For the full sell-side setup pattern, including direct publisher and delegated network examples, see [seller setup](/dist/docs/3.1.20/brand-protocol/seller-setup). ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/brand.json", "version": "1.0", "id": "example_publisher", "url": "https://publisher.example.com", "names": [{ "en_US": "Example Publisher" }], "properties": [ { "type": "website", "identifier": "publisher.example.com", "relationship": "owned" } ], "agents": [ { "type": "sales", "url": "https://ads.publisher.example.com", "id": "publisher_sales", "jwks_uri": "https://ads.publisher.example.com/.well-known/jwks.json" } ] } ``` The `agents[].jwks_uri` lets buyers resolve your public signing keys. The protocol requires verifiable signatures and discoverable public keys; it does not require a specific KMS vendor. Production agents should back private signing keys with KMS/HSM or a managed secret system. Sellers that send AdCP webhooks should publish an `adcp_use: "request-signing"` JWK in that JWKS so buyers can verify outbound webhook signatures. Deprecated `adcp_use: "webhook-signing"` keys remain accepted on the webhook path for backward compatibility. See [request signing](/dist/docs/3.1.20/building/by-layer/L1/request-signing) for the key-publication and webhook-signing profile. ### Expose your inventory Implement `get_products` to describe what you sell. Each product represents a buyable unit — a display placement, a video slot, a sponsored listing, a newsletter sponsorship. Buyer agents call this with a `buying_mode` and optional `brief`: ```json theme={null} { "buying_mode": "brief", "brief": "Premium display placements for consumer electronics brand" } ``` Your response includes structured product objects with pricing, formats, and delivery types: ```json theme={null} { "products": [ { "product_id": "homepage_leaderboard", "name": "Homepage leaderboard", "channels": ["display"], "format_ids": [ { "agent_url": "https://ads.publisher.example.com", "id": "display_728x90" } ], "pricing_options": [ { "pricing_option_id": "cpm_standard", "pricing_model": "cpm", "floor_price": 8.00, "currency": "USD" } ] } ] } ``` The richer the product metadata, the better buyer agents can match your inventory to campaign requirements. For content-centric inventory like podcasts, CTV, or live events, products reference shows and can offer exclusivity: ```json theme={null} { "products": [ { "product_id": "signal_noise_sponsorship", "name": "Signal & Noise — Category Sponsorship", "description": "Category-exclusive sponsorship of the Signal & Noise podcast, including pre-roll and mid-roll host read placements.", "collections": [{ "publisher_domain": "crestnetwork.example.com", "collection_ids": ["signal_noise"] }], "publisher_properties": ["crestnetwork_podcast"], "channels": ["podcast"], "placements": [ { "kind": "seller_inline", "placement_id": "pre_roll", "name": "Pre-roll (30s)", "mode": "targetable" }, { "kind": "seller_inline", "placement_id": "host_read", "name": "Mid-roll host read (60s)", "mode": "targetable" } ], "delivery_type": "guaranteed", "exclusivity": "category", "format_ids": [ { "agent_url": "https://ads.publisher.example.com", "id": "audio_30s" } ], "pricing_options": [ { "pricing_option_id": "flat_monthly", "pricing_model": "flat_rate", "fixed_price": 15000, "currency": "USD" } ] } ] } ``` See [Collections and installments](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments) for the full content model and [Media products](/dist/docs/3.1.20/media-buy/product-discovery/media-products#exclusivity) for exclusivity patterns. ### Accept and fulfill buys Implement `create_media_buy` to accept campaign instructions from buyer agents. A media buy includes the product, budget, schedule, and any targeting parameters. ```json theme={null} { "account": { "account_id": "acct-56789" }, "brand": { "brand_id": "nova-electronics" }, "proposal_id": "prop-homepage-leaderboard", "total_budget": { "amount": 10000, "currency": "USD" }, "start_time": "2026-04-01T00:00:00Z", "end_time": "2026-04-30T23:59:59Z" } ``` Your platform processes the buy according to your normal workflow — whether that's instant activation, internal review, or an approval queue. AdCP's asynchronous status system (`completed`, `working`, `submitted`, `input-required`) lets you model any workflow. **Status must be persisted, not computed from flight dates.** Store `status` as an explicit database field, updated only by protocol events — not by comparing `start_time`/`end_time` to the current date. Date arithmetic cannot produce `paused`, `canceled`, or `rejected`; those states are driven by explicit commands. See [lifecycle states](/dist/docs/3.1.20/media-buy/media-buys#lifecycle-states) for the full implementation requirement. ## Industry-specific guidance The core integration steps above apply to all sellers. If you're an **ad network aggregating across multiple platforms**, see the [ad networks deep dive](/dist/docs/3.1.20/sponsored-intelligence/networks) for product modeling, account chains, catalog forwarding, and `adagents.json` for networks. If you sell inventory for publishers you don't own (as a network or SSP), declare those properties in your [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json#property-relationships) with the appropriate `relationship` value (`delegated` or `ad_network`). This creates bilateral verification — you declare the relationship, and each publisher confirms by authorizing your agent with the matching `delegation_type` in their adagents.json. For vertical-specific product modeling, pricing patterns, and measurement: * **AI platforms and AI ad networks**: See the [Sponsored Intelligence guide](/dist/docs/3.1.20/sponsored-intelligence/overview) for sponsored responses, AI search products, generative creative from catalogs, and SI Chat Protocol handoffs. * **Retail media networks**: See the [commerce media guide](/dist/docs/3.1.20/media-buy/commerce-media) for sponsored product listings, closed-loop attribution, and in-store measurement. ## Accounts and sandbox Production sales agents should implement the accounts protocol. [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) let buyers establish billing relationships, track spend per advertiser, and manage multiple operators buying on behalf of different brands through a single agent. The account model depends on your platform: * **Walled gardens** (social platforms, AI platforms, retail media networks) typically use account-id namespaces — set `require_operator_auth: true` in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) so each operator authenticates independently, expose `list_accounts` when the upstream platform owns the namespace, and require buyers to pass discovered `account_id` values. * **Open platforms** (publishers, SSPs) can use buyer-declared accounts — the agent is trusted and declares accounts via `sync_accounts`. See [Accounts and Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) for full workflows. **Sandbox support is strongly recommended.** Declare `account.sandbox: true` in your capabilities so buyers can validate the full integration — product discovery, media buy creation, delivery reporting — before committing real spend. Buyer-declared account sellers provision test accounts through `sync_accounts` with `sandbox: true`; account-id namespace sellers expose pre-existing sandbox accounts through `list_accounts` or out-of-band setup. Without sandbox, buyers must test against live inventory, which slows adoption and increases onboarding friction. See [Sandbox mode](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox) for implementation details. ## Delivery reporting All Media Buy Protocol sales agents MUST implement [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) and include `reporting_capabilities` on every product. Buyer agents pull performance data — impressions, clicks, spend, conversions — in a standardized format. This is how agents monitor campaigns across platforms without logging into each dashboard individually. See [Required tasks by protocol](/dist/docs/3.1.20/protocol/required-tasks) for the full list of required seller tasks. Sellers building AdCP into their production stack should consider enrolling in the **[AAO Verified (Live)](/dist/docs/3.1.20/building/verification/aao-verified)** qualifier once they're serving real campaigns. (Live) adds continuous observability of live delivery data on a designated compliance account, distinct from the (Spec) qualifier earned automatically by storyboard passing. Opt-in, no new infrastructure on the seller side — grant the [`attestation_verifier`](/dist/docs/3.1.20/accounts/overview#standard-named-scope-attestation_verifier) scope on one account with live campaigns. ## Product design patterns Different inventory types use different AdCP features: | Inventory type | Key features | | ---------------------- | ------------------------------------------------------------------------------ | | Standard display/video | `format_ids`, `delivery_type: "non_guaranteed"`, auction pricing | | Podcast sponsorship | `shows`, `placements` (host read), `delivery_type: "guaranteed"`, flat\_rate | | CTV series sponsorship | `shows`, `exclusivity`, `delivery_type: "guaranteed"` | | Live event | `shows` (cadence: event), `episodes` (flexible\_end, tentative), `exclusivity` | | Retail media | `catalog_types`, `catalog_match`, metric optimization | For content-centric inventory, see [Collections and installments](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments). For exclusivity and sponsorship patterns, see [Media products](/dist/docs/3.1.20/media-buy/product-discovery/media-products#exclusivity). ## Governance enforcement Buyer agents increasingly require governance compliance before committing spend. Implementing governance makes your inventory eligible for brand-safe campaigns, reduces post-campaign disputes, and signals to buyer agents that your platform takes brand suitability seriously. Three governance domains are relevant to sellers. ### Property governance via adagents.json Your `adagents.json` file is the foundation of property governance. It declares which properties you sell, which agents are authorized to sell them, and which governance agents have data about your inventory. Buyer agents use this to verify supply path authorization and discover property intelligence — if your `adagents.json` is missing or incomplete, buyer agents cannot verify that you are authorized to sell what you claim. Declare `property_features` entries to point buyers toward governance agents that score your properties for quality, sustainability, or brand safety. See the [property governance specification](/dist/docs/3.1.20/governance/property/specification) for the full schema and the [adagents.json tech spec](/dist/docs/3.1.20/governance/property/adagents) for publisher-side setup. ### Content standards enforcement When a buyer includes a `content_standards_ref` in a `get_products` or `create_media_buy` request, they are asking you to enforce brand suitability rules during delivery. Your responsibilities: fetch the standards from the referenced governance agent, evaluate whether you can enforce them, reject the buy if you cannot, and calibrate your local evaluation model against the governance agent via `calibrate_content`. After delivery, push content artifacts back to the buyer so they can validate compliance independently. If you cannot meaningfully enforce a buyer's content standards, reject the buy rather than accepting it and failing silently. See the [content standards implementation guide](/dist/docs/3.1.20/governance/content-standards/implementation-guide) for the full sales agent workflow. ### Execution checks When a buyer's account has governance agents configured (via [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance)), the seller MUST call [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) with `media_buy_id` and `planned_delivery` before confirming a media buy. This is a binding validation — the governance agent verifies the seller's planned delivery against the buyer's campaign plan. ```javascript theme={null} // Before confirming create_media_buy const check = await governanceAgent.checkGovernance({ plan_id: mediaBuy.plan_id, // from the create_media_buy request caller: "https://seller.example.com", governance_context: mediaBuy.governance_context, // opaque — pass through, do not parse media_buy_id: mediaBuy.media_buy_id, phase: "purchase", planned_delivery: { geo: { countries: ["US"] }, channels: ["olv"], start_time: mediaBuy.start_time, end_time: mediaBuy.end_time, total_budget: mediaBuy.total_budget.amount, currency: mediaBuy.total_budget.currency } }); if (check.status === "denied") { // Committed checks are always binding — do not confirm the media buy return { error: "GOVERNANCE_DENIED", detail: check.explanation }; } if (check.status === "conditions" && retries < 3) { // Conditions restrict what the seller can deliver — e.g., narrower geo, // blocked channels, reduced frequency. The seller adjusts their own // delivery parameters (not the buyer's budget) and re-calls check_governance. // If the seller cannot satisfy the conditions, reject the media buy. const adjusted = applyConditions(plannedDelivery, check.conditions); if (!adjusted) { return { error: "GOVERNANCE_CONDITIONS_UNSATISFIABLE", detail: check.conditions }; } // Re-check with adjusted delivery (governance agents SHOULD deny after 3 re-calls) return await checkGovernanceWithRetry(request, adjusted, retries + 1); } if (check.status === "conditions") { return { error: "GOVERNANCE_CONDITIONS_RETRY_LIMIT", detail: check.conditions }; } // check.status === "approved" — proceed with confirmation ``` Execution checks cover three phases of the media buy lifecycle: | Phase | When to call | What's checked | | -------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------- | | `purchase` | Before confirming `create_media_buy` | Budget, geo, channels, flight dates, policies | | `modification` | Before confirming [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) | Change magnitude, reallocation, new parameters | | `delivery` | Periodically during delivery | Pacing, spend rate, geo drift, channel distribution | Sellers can adopt execution checks incrementally — start with purchase-only (one call per `create_media_buy`), then add modification and delivery checks. See [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) for the full specification. ### Campaign governance context When a buyer includes `governance_context` in the protocol envelope of a `create_media_buy` request, store it alongside the media buy. This is an opaque value — you don't interpret it, you just persist and forward it. Pass `governance_context` back to the buyer's governance agent on every lifecycle event for that media buy: | Lifecycle event | How governance\_context flows | | --------------------- | ---------------------------------------------------------------------------------------------------------- | | **Create** | Received in `create_media_buy` envelope. Store it. If calling `check_governance`, include it. | | **Activate** | Include stored `governance_context` when calling `check_governance` with the seller's `planned_delivery`. | | **Update** | Include on `check_governance` for modifications. The governance agent uses it to track cumulative changes. | | **Pause / Resume** | Include if calling `check_governance`. The governance agent updates pacing state. | | **Cancel / Complete** | Include so the governance agent can close out budget tracking and produce final audit. | | **Delivery webhooks** | Include in webhook payloads so the governance agent can correlate delivery data. | The governance agent uses `governance_context` to reconnect each event to the original plan, campaign, and budget state. Without it, the governance agent has no way to track the media buy across its lifecycle. If `governance_context` is not present in the original request, skip governance calls — the buyer is not using campaign governance for this media buy. ### Creative governance Buyer agents may require creative evaluation before delivery — security scanning, content categorization, or quality scoring. As a seller, you participate by submitting creative manifests to governance agents via `get_creative_features` and honoring the feature requirements the buyer sets (for example, blocking creatives flagged for `auto_redirect` or `credential_harvest`). You do not need to implement the evaluation yourself; specialist governance agents handle that. See the [creative governance overview](/dist/docs/3.1.20/governance/creative/index) for the feature-based evaluation model and multi-agent collaboration pattern. ### For Sponsored Intelligence sellers: generation-time enforcement Traditional sellers apply governance as a post-delivery filter — classify content, then block what fails. Sponsored Intelligence platforms generate creative at serve time, which means governance rules can be enforced during generation rather than after the fact. When a buyer pushes content standards, apply them as constraints on your generation pipeline so unsuitable content is never produced. This gives brands a fundamentally stronger guarantee: suitability is built into the output, not bolted on as a check afterward. See the [Sponsored Intelligence guide](/dist/docs/3.1.20/sponsored-intelligence/overview) for how content standards integrate with catalog-driven creative generation. ## How it connects to your existing stack AdCP sits alongside your existing APIs and dashboards. It doesn't replace your self-serve platform or your internal campaign management system. It adds a standard interface that AI agents can use. | Your existing system | How AdCP relates | | ----------------------- | ----------------------------------------------------------------------- | | Self-serve dashboard | AdCP serves a different audience (AI agents, not humans) | | Management API | AdCP provides a standard subset; your API provides the full feature set | | Ad server (GAM, custom) | AdCP sends campaign instructions; your ad server handles delivery | | OpenRTB integration | AdCP handles campaign setup; OpenRTB handles impression-level auctions | ## Getting started Create and validate your adagents.json file using the interactive builder. Full reference for sell-side task implementations: products, media buys, and delivery reporting. Implementation guides, SDKs, and integration patterns. What sits behind protocol compliance — activation, storage, hosting, and build-vs-buy. Ask questions about implementing AdCP for your platform — no code required. Product modeling and workflows for AI platforms and ad networks. Product modeling and workflows for retail media networks. # Storyboard troubleshooting Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/operating/storyboard-troubleshooting Common failure patterns when running AdCP compliance storyboards — missing fixtures, signature challenges, envelope drift, context echo, capability mismatches, and state-machine error codes. When a compliance storyboard fails against your agent, the runner reports a step name and error text. This page maps the most common error patterns to their root causes and fixes, so you can resolve each class of failure without spelunking through SDK source or runner internals. Each section shows the error you'll see, what it means, and what to change in your agent. ## Unknown fixture errors ``` × (unknown step): PRODUCT_NOT_FOUND: Package 0: Product not found: test-product ``` The storyboard's `sample_request` references a hardcoded ID (`test-product`, `test-pricing`, `campaign_hero_video`, `gov_acme_q2_2027`, etc.). The runner expects the agent to have that ID in its catalog before the mutating step runs. **Fix:** On the dev or staging endpoint registered for Verified (Spec), implement `comply_test_controller` and honor the seed scenarios declared in the storyboard's `fixtures:` block. Do not expose the controller on the production endpoint used for Verified (Sandbox). When `prerequisites.controller_seeding: true` is set, the runner auto-injects a fixtures phase that calls `seed_product`, `seed_pricing_option`, `seed_creative`, `seed_plan`, or `seed_media_buy` in foreign-key order before the main phases execute. Implement only the scenarios your selected specialisms require. For example, `sales-non-guaranteed` needs `seed_product` and `seed_pricing_option`; it does not require the full controller surface unless another selected storyboard declares additional fixtures. See [Compliance test controller — Scenarios](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller#scenarios) for the full seed contract. Agents that return `UNKNOWN_SCENARIO` on a seed call grade the storyboard `not_applicable` — they are not penalized for missing sandbox surface, but they cannot pass storyboards that depend on pre-seeded state. ## Signature challenge missing on 401 ``` × (unknown step): expected error="request_signature_required", got error="(none)" ``` The storyboard sent an unsigned request to an operation declared in `get_adcp_capabilities.request_signing.required_for`. Your agent rejected with 401 but did not include a `WWW-Authenticate: Signature ...` challenge header, so the runner could not resolve the error code from the transport binding. **Fix:** Emit the RFC 9421 challenge header on every 401 caused by missing or invalid signatures. The runner resolves the error code via the transport binding order — if the `WWW-Authenticate` header is absent, the error classification falls back to "(none)" even when the JSON body carries a useful message. The reference SDK constructs these errors via `RequestSignatureError` from `@adcp/sdk/signing` with `.code: RequestSignatureErrorCode`. The full taxonomy (`request_signature_required`, `request_signature_header_malformed`, `request_signature_tag_invalid`, `request_signature_window_invalid`, `request_signature_key_unknown`, etc.) is enumerated in that module. Your agent SHOULD surface the same code on the challenge so SDK-speaking callers can recover automatically. See [Signed Requests (Transport Layer)](/dist/docs/3.1.20/building/by-layer/L1/security#signed-requests-transport-layer) for the challenge-header format and the [transport-error binding order](/dist/docs/3.1.20/building/operating/transport-errors). ## Response envelope drift ``` × (unknown step): Response contains errors array ``` The vector used `check: error_code` but your response surfaces the error on a shape the runner's client-detection order didn't expect. In practice, this means your agent returned `errors[]` when the transport layer already carried `adcp_error`, or vice versa — the storyboard asserted a single error code and the runner resolved it from a different layer than you emitted on. **Fix:** Pick one error surface per response and stick to it per the [envelope vs. payload two-layer model](/dist/docs/3.1.20/building/by-layer/L3/error-handling#envelope-vs-payload-errors-the-two-layer-model). On MCP: `adcp_error` for structured content; `errors[]` for task-payload errors. On A2A: the same layers apply — transport error in the envelope, application error in the task artifact's DataPart. The runner's `check: error_code` is shape-agnostic — it resolves from either layer — but if your agent emits both simultaneously they can disagree, and the runner grades the resolved code against the vector's expectation. Choosing one surface and being consistent avoids the divergence. ## Context echo failures ``` × (unknown step): expected field "context.correlation_id" = "xyz", got (missing) ``` Your agent returned a response that does not include the `context:` object from the request. Every storyboard step that sends `context: { correlation_id: ... }` asserts that `context.correlation_id` echoes unchanged in the response. **Fix:** Preserve the full `context:` object verbatim on every response, including errors. The echo contract is normative — buyers use `correlation_id` to stitch multi-agent flows, and the runner grades every context-carrying step on it. See [Context and sessions — Normative echo contract](/dist/docs/3.1.20/building/by-layer/L2/context-sessions#normative-echo-contract). Captures use the same contract in reverse: storyboards that pass `"$context."` through `context_outputs:` rely on the capture populating after the producer step's validations pass. A downstream step reading `$context.foo` when the producer failed or omitted `context:` grades as `unresolved_substitution`. ## Capability-vector mismatch (runner declared, agent doesn't support) ``` × (unknown step): capability X asserted but not declared in get_adcp_capabilities ``` The storyboard dispatched a step that requires a capability your agent does not advertise in its `get_adcp_capabilities` response. The runner should auto-skip these steps; if you're seeing them graded as failures instead, either the capability is declared at the wrong key or the runner is missing the auto-skip path. **Fix:** Double-check your `get_adcp_capabilities.tools` list and any required-for fields (`request_signing.required_for`, `idempotency.supported_tools`, etc.). For vectors that apply only to specialized agents, the storyboard author can use `skipVectors` to flag the opt-out explicitly; as an implementer, the fix is almost always on the capability declaration rather than the vector. ## Required-for composition ``` × (unknown step): missing auth — step requires authenticated or signed ``` The runner encountered a mutating step that expected either authenticated credentials or a signed request, and the transport carried neither. Typically this means the test kit didn't declare `auth.api_key` or `auth.basic` AND the agent doesn't advertise request-signing support — leaving the runner with no way to authenticate the call. **Fix:** Either (a) declare `auth.api_key` or `auth.basic` in the test kit so the runner uses a static Authorization-header credential, or (b) advertise request-signing via `get_adcp_capabilities.request_signing` so the runner signs the request instead. The runner's `requireAuthenticatedOrSigned` gate accepts either path — it fails only when both are absent. ## Static-credential agent: no auth mechanism contributed (assert\_mechanism) ```json theme={null} { "check": "assertion", "passed": false, "description": "Probe validations failed.", "expected": ["auth_mechanism_verified"], "actual": [] } ``` The `mechanism_required` phase found no contribution to `auth_mechanism_verified` from any optional auth phase. This is the most common failure for static-credential-only agents (Bearer API key or HTTP Basic), and it is not a real auth problem — it is a test-kit configuration gap. **What happened:** The `api_key_path` phase has `skip_if: "!test_kit.auth.api_key"` and the `basic_path` phase has `skip_if: "!test_kit.auth.basic"` — each is skipped unless the test kit declares the matching credential. The `oauth_discovery` phase 404s on `/.well-known/oauth-protected-resource/...` (expected for static-credential-only agents; those failures are silently ignored by the runner). With all optional auth phases contributing nothing, `assert_mechanism` sees `actual: []`. **The `--auth TOKEN` distinction:** The `--auth TOKEN` flag you pass to the runner is the runner's own session credential — it authorizes the runner's own requests to your agent. It is entirely separate from `test_kit.auth.api_key` or `test_kit.auth.basic`, which are the specific credentials the static-credential phases send during positive and invalid-credential probes. These are not the same token and are not interchangeable. **Fix for Bearer API key agents:** Every default AdCP brand test kit declares its probe API key under `auth.api_key` using the `demo--v1` naming convention. The default test kit (`acme-outdoor`) uses `demo-acme-outdoor-v1`. Configure your agent to accept the kit's probe key as a valid compliance-testing credential alongside your production key. The `demo--` prefix is the AdCP conformance handle — accept any Bearer token matching the prefix for the kits you run against (the suffix can rotate across spec versions, the prefix stays stable): ```typescript theme={null} serve({ authenticate: verifyApiKey({ keys: { [PRODUCTION_TOKEN]: { principal: 'my-principal' }, // Accept the default compliance kit's probe key (and any future suffix rotation) 'demo-acme-outdoor-v1': { principal: 'compliance-runner' }, } }) }) ``` **Fix for HTTP Basic agents:** Use a test kit that declares `auth.basic` with either `username`/`password` or a single `credentials` value containing the unencoded `username:password` pair. Configure your agent to accept that Basic credential as a valid compliance-testing credential alongside your production Basic credential. The `basic_path` phase then sends the valid Basic credential and a random-invalid Basic credential, and contributes `auth_mechanism_verified` only when the agent accepts the valid credential and rejects the invalid one. Agents that only accept their own production credential skip the matching static path (no test-kit credential matches), fail `oauth_discovery` (no PRM), and land at `actual: []` in `assert_mechanism`. Adding the test-kit credential to your allowed-credentials set is sufficient — you do not need a PRM endpoint or an OAuth issuer. Do not serve a fake `/.well-known/oauth-protected-resource/...` pointing at a non-existent issuer to "pass" the OAuth phase. That triggers the advertised-but-unserved failure mode the storyboard was designed to catch. See [Known spec ambiguities — PRM required for non-OAuth agents](/dist/docs/3.1.20/building/cross-cutting/known-ambiguities#prm-required-for-non-oauth-agents) for background on why the carve-out exists and how the static-credential / `oauth_discovery` phase semantics were designed. ## `INVALID_STATE` vs `INVALID_TRANSITION` Two codes that are easy to confuse: * **`INVALID_STATE`** — the canonical AdCP media-buy error code for "the resource is in a state that doesn't allow this action." Used on `create_media_buy`/`update_media_buy`/`pause`/`resume`/`cancel` against a media buy that cannot transition as requested. See `media-buy/specification.mdx` and `media-buy/media-buys/index.mdx` for authoritative usage. * **`INVALID_TRANSITION`** — specific to the `comply_test_controller` sandbox primitive. Emitted when a runner requests a state-machine transition the seller rejects (e.g., forcing `approved` → `archived` without going through `active`). See [Compliance test controller — Scenarios](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller#scenarios). A storyboard vector that asserts `INVALID_STATE` on a production task but your agent returns `INVALID_TRANSITION` is an error-code vocabulary mismatch — `INVALID_TRANSITION` is not in the canonical enum at `static/schemas/source/enums/error-code.json` and should not appear outside the compliance test controller. ## When none of the above matches If you hit a failure that doesn't map to anything here, check the [known spec ambiguities](/dist/docs/3.1.20/building/cross-cutting/known-ambiguities) page — some storyboards are blocked on resolved-but-unreleased spec gaps, and the workaround is tracked there. Still stuck? File an issue at [adcontextprotocol/adcp](https://github.com/adcontextprotocol/adcp/issues) with the full runner output and the storyboard name. Maintainers can usually narrow the pattern from the error signature. # Transport Error Mapping Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/operating/transport-errors How AdCP structured errors travel over MCP and A2A transports: extraction paths, JSON-RPC codes, recovery behavior, and client implementation requirements. AdCP errors are **application-layer** errors. They belong in the tool/task response, not in the transport error channel. This page defines how the [`error.json`](https://adcontextprotocol.org/schemas/3.1.20/core/error.json) schema maps to MCP and A2A response envelopes. For the error schema itself, standard codes, and recovery strategies, see [Error Handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling). ## Layer Separation | Layer | Examples | Channel | | ----------- | ------------------------------------------------------ | ------------------------------------- | | Transport | Connection refused, malformed JSON-RPC, internal crash | JSON-RPC `error` / A2A protocol error | | Application | `RATE_LIMITED`, `BUDGET_TOO_LOW`, `CREATIVE_REJECTED` | Tool/task response body | Transport errors are handled by protocol libraries. Application errors are handled by business logic. Mixing them loses the structured recovery data that makes AdCP errors useful. ## MCP Binding ### Tool-Level Errors The standard path for all AdCP error codes. The tool executed, understood the request, and is returning a structured error. **Today's practical path:** Most MCP hosts (Claude Desktop, Cursor, Windsurf) read `content` text on error responses and do not surface `structuredContent` to LLMs or programmatic consumers. Until `structuredContent` adoption is widespread, the text-fallback path is how most errors will be extracted. Servers SHOULD support both paths: ```json theme={null} { "content": [{"type": "text", "text": "{\"adcp_error\":{\"code\":\"RATE_LIMITED\",\"message\":\"Request rate exceeded\",\"retry_after\":5,\"recovery\":\"transient\"}}"}], "isError": true, "structuredContent": { "adcp_error": { "code": "RATE_LIMITED", "message": "Request rate exceeded", "retry_after": 5, "recovery": "transient" } } } ``` **`content` text** carries the AdCP error as a JSON string for text-based extraction. **`structuredContent.adcp_error`** carries the same error for programmatic clients that support it. Servers that include human-readable text SHOULD add it as a second content item, keeping it terse (one sentence): ```json theme={null} { "content": [ {"type": "text", "text": "{\"adcp_error\":{\"code\":\"RATE_LIMITED\",\"message\":\"Request rate exceeded\",\"retry_after\":5,\"recovery\":\"transient\"}}"}, {"type": "text", "text": "Rate limited — retry in 5s."} ], "isError": true, "structuredContent": { "adcp_error": { "code": "RATE_LIMITED", "message": "Request rate exceeded", "retry_after": 5, "recovery": "transient" } } } ``` **Terse text when `structuredContent` is present.** When `structuredContent` carries the full error, the human-readable text content item SHOULD be a single terse sentence (e.g., "Rate limited — retry in 5s."). The error details are already in `structuredContent` and the JSON text fallback. Repeating the full error in prose wastes LLM context tokens — especially for transient errors that accumulate during retries. **`adcp_error` key**: Namespacing avoids collisions with success data that may also appear in `structuredContent` (e.g., `products`). A single key simplifies detection. **`structuredContent`** requires MCP 2025-03-26 or later. Servers on older MCP versions omit `structuredContent` — the JSON string in `content[0].text` is sufficient. Clients parse this via the text-fallback path (see [Client Detection Order](#client-detection-order)). ### Transport-Level Errors When infrastructure rejects a request *before* tool dispatch (API gateway, rate-limit middleware), the tool never executes. Use a reserved JSON-RPC error code with the AdCP error in `data`: ```json theme={null} { "jsonrpc": "2.0", "id": "req-123", "error": { "code": -32029, "message": "Rate limit exceeded", "data": { "adcp_error": { "code": "RATE_LIMITED", "retry_after": 5, "recovery": "transient" } } } } ``` ### Reserved JSON-RPC Codes | Code | AdCP Error Code | When | | -------- | --------------------- | -------------------------------------------------------------------------- | | `-32029` | `RATE_LIMITED` | Infrastructure rate limit before tool dispatch | | `-32028` | `AUTH_MISSING` | No credentials presented; auth rejected by middleware before tool dispatch | | `-32027` | `SERVICE_UNAVAILABLE` | Infra health check fails, upstream down | These codes are in the JSON-RPC server-defined range (`-32000` to `-32099`). All other AdCP error codes use the tool-level path exclusively. **MCP server SDK note:** Throwing `McpError` from inside a tool handler produces a JSON-RPC error response — the SDK does **not** convert it to an `isError: true` tool result. This means `-32029` works the same way whether thrown from middleware or a tool handler. However, application-layer errors (where the tool understood the request and is returning a structured failure) should use the `isError: true` tool-level path above, not JSON-RPC error codes. Reserve `-32029`/`-32028`/`-32027` for infrastructure that rejects requests before tool dispatch. ### MCP Server Implementation ```javascript theme={null} function adcpErrorResponse(error) { const adcpError = { code: error.code, message: error.message, recovery: error.recovery, ...(error.retry_after != null && { retry_after: error.retry_after }), ...(error.field != null && { field: error.field }), ...(error.suggestion != null && { suggestion: error.suggestion }), ...(error.details != null && { details: error.details }), }; return { content: [{ type: "text", text: JSON.stringify({ adcp_error: adcpError }) }], isError: true, structuredContent: { adcp_error: adcpError }, }; } server.tool( "get_products", "Search product catalog", { query: z.string() }, async ({ query }) => { try { const products = await searchProducts(query); return { content: [{ type: "text", text: `Found ${products.length} products` }], structuredContent: { products }, }; } catch (err) { if (err.code && err.recovery) { return adcpErrorResponse(err); } throw err; } } ); ``` ## A2A Binding ### Failed Tasks Use `status: "failed"` with the AdCP error in an artifact `DataPart`, plus a `TextPart` for human/LLM consumption: ```json theme={null} { "id": "task_456", "status": { "state": "failed", "timestamp": "2025-01-22T10:30:00Z" }, "artifacts": [{ "artifactId": "error-result", "parts": [ { "kind": "text", "text": "Rate limit exceeded. Retry in 5 seconds." }, { "kind": "data", "data": { "adcp_error": { "code": "RATE_LIMITED", "message": "Request rate exceeded", "retry_after": 5, "recovery": "transient" } } } ] }] } ``` This follows the [A2A Response Format](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format) conventions: final states use `.artifacts` for data. **Relationship to the "no wrappers" rule.** The `adcp_error` key is an intentional exception for failed tasks. Unlike success responses where `DataPart` contains task-specific data (e.g., `products`), a failed task's `DataPart` contains only the error. The key acts as a type discriminator so clients can distinguish error from success payloads without relying solely on status. ### Error MIME Type (Optional) A2A agents MAY set `metadata.mimeType` on the error `DataPart`: ```json theme={null} { "kind": "data", "data": { "adcp_error": { "code": "RATE_LIMITED", "recovery": "transient" } }, "metadata": { "mimeType": "application/vnd.adcp.error+json" } } ``` Clients MUST NOT require the MIME type. The `adcp_error` key is the authoritative signal. ## Envelope vs. payload errors AdCP exposes errors in two distinct places. This page covers the **transport envelope** (`adcp_error`); the **payload errors array** (`errors[]`) is covered in [Error Handling — Envelope vs. payload errors](/dist/docs/3.1.20/building/by-layer/L3/error-handling#envelope-vs-payload-errors-the-two-layer-model). | Layer | Key | When to populate | Readers | | ---------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------- | | **Transport envelope** (this page) | `adcp_error` (MCP `structuredContent`, A2A `DataPart`, JSON-RPC `error.data`) | The task failed; transport needs a typed, extractable error signal | MCP hosts, A2A clients, `@adcp/sdk` | | **Task payload** | `payload.errors[]` (or top-level `errors[]`) | The task ran; payload reports one or more issues (fatal or non-fatal warnings) | Business-logic consumers | A fatal task failure SHOULD populate **both** layers — see the canonical `protocol-envelope.json` examples and the `error-handling.mdx` reference for the normative SHOULD. ## Client Detection Order Clients MUST check for AdCP errors in this order: 1. **`structuredContent.adcp_error`** (with `isError: true`) — MCP tool-level error 2. **`artifacts[].parts[].data.adcp_error`** — A2A task-level error (artifacts) 3. **`status.message.parts[].data.adcp_error`** — A2A task-level error (status message) 4. **`error.data.adcp_error`** — JSON-RPC transport-level error 5. **JSON-parsed `content[].text` with `adcp_error` key** — Text fallback for older MCP servers (only for `isError` responses) 6. **`payload.errors[0]`** (or top-level `errors[0]`) — payload-layer fallback. Used when the transport envelope does not surface `adcp_error` but the payload carries an `errors[]` array. Reading from the payload is legitimate for non-fatal cases where only the payload layer is populated (e.g., `input-required` tasks reporting warnings), but a fatal task that surfaces errors only via the payload is a conformance gap on the agent side. 7. **No structured error found** — fall back to generic error handling. Clients MUST validate that extracted errors have a `code` field of type `string`. If validation fails, treat as no structured error found. ### Storyboard `check: error_code` contract Storyboard validators use `check: error_code` rather than path-specific assertions because the error may surface on either layer. The runner contract: * `check: error_code` resolves the error code by running the [client detection order](#client-detection-order) above — preference in order: `adcp_error.code` (transport) → `errors[0].code` (payload). * If neither layer carries a `code`, the validation fails with `error_code_not_resolvable`. * Storyboard authors SHOULD NOT pin assertions to a specific path (e.g., `check: field_present, path: "errors"`) — that couples the test to one layer and fails against agents that surface errors on the other. See [Storyboard authoring — Asserting on errors](/dist/docs/3.1.20/contributing/storyboard-authoring#asserting-on-errors). **Extraction vs. action.** The detection order above is the *extraction* layer — it returns the raw `adcp_error` object with field values preserved as-is (including out-of-range `retry_after`). Clamping, retry logic, and other behavioral requirements apply at the *action* layer (see [Recovery Behavior](#recovery-behavior)). In practice, implementations branch on transport type first and only check the relevant paths: ```javascript MCP Client theme={null} function extractAdcpErrorFromMcp(response) { if (!response.isError) return null; // 1. structuredContent (preferred) if (response.structuredContent?.adcp_error) { return validate(response.structuredContent.adcp_error); } // 2. Text fallback if (response.content) { for (const item of response.content) { if (item.type === 'text' && item.text) { try { const parsed = JSON.parse(item.text); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && parsed.adcp_error) { return validate(parsed.adcp_error); } } catch { /* not JSON */ } } } } return null; } // Reject malformed or oversized payloads function validate(error) { if (!error || typeof error !== 'object' || Array.isArray(error)) return null; if (typeof error.code !== 'string') return null; if (error.code.length === 0 || error.code.length > 64) return null; if (JSON.stringify(error).length > 4096) return null; return error; } // For JSON-RPC errors (caught as McpError) function extractAdcpErrorFromMcpError(error) { return validate(error.data?.adcp_error); } ``` ```javascript A2A Client theme={null} function extractAdcpErrorFromA2a(task) { // 1. Artifacts (preferred — final state data) if (task.artifacts) { for (const artifact of task.artifacts) { const dataParts = (artifact.parts || []).filter(p => p.kind === 'data'); for (const part of dataParts) { if (part.data?.adcp_error) { return validate(part.data.adcp_error); } } } } // 2. status.message.parts (some A2A implementations) const parts = task.status?.message?.parts; if (Array.isArray(parts)) { for (const part of parts) { if (part.kind === 'data' && part.data?.adcp_error) { return validate(part.data.adcp_error); } } } return null; } ``` ## Recovery Behavior Once extracted, apply recovery based on the `recovery` field: | Recovery | Client Behavior | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `transient` | Retry after `retry_after` seconds. When `retry_after` is absent or non-finite, use exponential backoff starting at the client's configured initial delay. | | `correctable` | Surface `suggestion` and `field` to caller, do not auto-retry | | `terminal` | Surface error to human operator, do not retry | **`retry_after` bounds:** Sellers MUST return `retry_after` values between 1 and 3600 seconds. Clients MUST clamp values outside this range: values below 1 become 1, values above 3600 become 3600. Non-finite values (`NaN`, `Infinity`) MUST be treated as absent. This prevents both aggressive retry loops and pathologically long stalls from misconfigured servers. **Retry ceiling:** Buyer agents SHOULD enforce a maximum retry count (e.g., 3 attempts) and a maximum cumulative retry duration (e.g., 300 seconds) per operation. Transient errors that persist beyond the retry budget SHOULD be escalated as terminal. Without a ceiling, a malicious or misconfigured seller returning `retry_after: 3600` on every request can stall an agent indefinitely. **When `recovery` is absent:** Fall back to code-based classification using the standard error code table. This allows [Level 1](/dist/docs/3.1.20/building/by-layer/L3/error-handling#compliance-levels) servers (which return `code` + `message` only) to still get correct recovery behavior from capable clients. If the code is also unknown, treat as `terminal`. For unknown `recovery` values (forward compatibility), treat as `terminal`. ```javascript theme={null} // Standard code → recovery mapping for when recovery field is absent const CODE_RECOVERY = { RATE_LIMITED: 'transient', SERVICE_UNAVAILABLE: 'transient', CONFLICT: 'transient', INVALID_REQUEST: 'correctable', AUTH_MISSING: 'correctable', AUTH_INVALID: 'terminal', AUTH_REQUIRED: 'correctable', // deprecated alias for AUTH_MISSING POLICY_VIOLATION: 'correctable', PRODUCT_NOT_FOUND: 'correctable', PRODUCT_UNAVAILABLE: 'correctable', PROPOSAL_EXPIRED: 'correctable', PROPOSAL_NOT_FOUND: 'correctable', MULTI_FINALIZE_UNSUPPORTED: 'correctable', REQUOTE_REQUIRED: 'correctable', BUDGET_TOO_LOW: 'correctable', CREATIVE_REJECTED: 'correctable', UNSUPPORTED_FEATURE: 'correctable', AUDIENCE_TOO_SMALL: 'correctable', ACCOUNT_SETUP_REQUIRED: 'correctable', ACCOUNT_AMBIGUOUS: 'correctable', COMPLIANCE_UNSATISFIED: 'correctable', GOVERNANCE_DENIED: 'correctable', MEDIA_BUY_NOT_FOUND: 'correctable', PACKAGE_NOT_FOUND: 'correctable', CREATIVE_NOT_FOUND: 'correctable', SIGNAL_NOT_FOUND: 'correctable', SESSION_NOT_FOUND: 'correctable', SESSION_TERMINATED: 'correctable', REFERENCE_NOT_FOUND: 'correctable', VALIDATION_ERROR: 'correctable', ACCOUNT_NOT_FOUND: 'terminal', ACCOUNT_PAYMENT_REQUIRED: 'terminal', ACCOUNT_SUSPENDED: 'terminal', BUDGET_EXHAUSTED: 'terminal', CONFIGURATION_ERROR: 'terminal', }; function getRecovery(adcpError) { if (adcpError.recovery) return adcpError.recovery; return CODE_RECOVERY[adcpError.code] || 'terminal'; } function handleAdcpError(adcpError) { switch (getRecovery(adcpError)) { case 'transient': const raw = adcpError.retry_after; const delay = Number.isFinite(raw) ? Math.max(1, Math.min(3600, raw)) : null; return { action: 'retry', delaySeconds: delay }; case 'correctable': return { action: 'fix_request', field: adcpError.field, suggestion: adcpError.suggestion, }; case 'terminal': return { action: 'escalate', message: adcpError.message }; default: // Unknown recovery value: treat as terminal return { action: 'escalate', message: adcpError.message }; } } ``` ## Recommended `details` Shapes The `details` field is an open object. To prevent interoperability divergence, sellers SHOULD use these standard keys when populating `details` for common error codes: ### `RATE_LIMITED` ```json theme={null} { "code": "RATE_LIMITED", "retry_after": 5, "recovery": "transient", "details": { "limit": 100, "remaining": 0, "window_seconds": 60, "scope": "account" } } ``` | Key | Type | Description | | ---------------- | ------ | --------------------------------------------------------- | | `limit` | number | Maximum requests allowed in the window | | `remaining` | number | Requests remaining in the current window | | `window_seconds` | number | Duration of the rate-limit window | | `scope` | string | What the limit applies to: `account`, `tool`, or `global` | ### `BUDGET_TOO_LOW` ```json theme={null} { "code": "BUDGET_TOO_LOW", "recovery": "correctable", "details": { "minimum_budget": 500, "currency": "USD" } } ``` | Key | Type | Description | | ---------------- | ------ | ---------------------------------------- | | `minimum_budget` | number | Seller's minimum budget for this product | | `currency` | string | ISO 4217 currency code | ### `AUDIENCE_TOO_SMALL` ```json theme={null} { "code": "AUDIENCE_TOO_SMALL", "recovery": "correctable", "details": { "minimum_size": 10000, "current_size": 2500 } } ``` | Key | Type | Description | | -------------- | ------ | ------------------------------ | | `minimum_size` | number | Minimum audience size required | | `current_size` | number | Current audience size | ### `ACCOUNT_SETUP_REQUIRED` ```json theme={null} { "code": "ACCOUNT_SETUP_REQUIRED", "recovery": "correctable", "details": { "setup_url": "https://seller.example.com/setup/acct_123", "setup_steps": ["Accept terms of service", "Add payment method"] } } ``` | Key | Type | Description | | ------------- | --------- | ------------------------------------------- | | `setup_url` | string | URL where account setup can be completed | | `setup_steps` | string\[] | Steps remaining before the account is ready | ### `CREATIVE_REJECTED` ```json theme={null} { "code": "CREATIVE_REJECTED", "recovery": "correctable", "suggestion": "Revise creative to comply with alcohol advertising policy", "details": { "policy_id": "alcohol-advertising-v2", "policy_url": "https://seller.example.com/policies/alcohol-advertising", "reasons": ["Contains health claims not permitted for alcohol products"] } } ``` | Key | Type | Description | | ------------ | --------- | ------------------------------------------ | | `policy_id` | string | Identifier for the violated policy | | `policy_url` | string | URL where the full policy can be reviewed | | `reasons` | string\[] | Specific reasons the creative was rejected | ### `POLICY_VIOLATION` ```json theme={null} { "code": "POLICY_VIOLATION", "recovery": "correctable", "details": { "policy_id": "targeting-restrictions-v3", "policy_url": "https://seller.example.com/policies/targeting", "violated_rules": ["No age-based targeting for financial products"] } } ``` | Key | Type | Description | | ---------------- | --------- | ----------------------------------------- | | `policy_id` | string | Identifier for the violated policy | | `policy_url` | string | URL where the full policy can be reviewed | | `violated_rules` | string\[] | Specific rules that were violated | ### `CONFLICT` ```json theme={null} { "code": "CONFLICT", "recovery": "transient", "message": "Resource was modified since last read", "details": { "resource_id": "mb_12345", "expected_version": 3, "current_version": 5 } } ``` | Key | Type | Description | | ------------------ | ---------------- | ------------------------------------------------ | | `resource_id` | string | Identifier of the conflicting resource | | `expected_version` | number \| string | Version or ETag the client was operating against | | `current_version` | number \| string | Current version or ETag on the server | ### Size Guidance Sellers SHOULD keep `details` compact. Error responses flow through LLM context windows where every token has a cost — and transient errors that trigger retries can accumulate multiple error responses in a single conversation. As a guideline, keep `details` under 500 serialized JSON bytes (use `JSON.stringify(details).length` in UTF-8 — this matters for non-ASCII content). ### `details` Schemas JSON Schemas for all recommended `details` shapes are published alongside the error code enum: * [`/schemas/3.1.20/error-details/rate-limited.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/rate-limited.json) * [`/schemas/3.1.20/error-details/budget-too-low.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/budget-too-low.json) * [`/schemas/3.1.20/error-details/audience-too-small.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/audience-too-small.json) * [`/schemas/3.1.20/error-details/account-setup-required.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/account-setup-required.json) * [`/schemas/3.1.20/error-details/creative-rejected.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/creative-rejected.json) * [`/schemas/3.1.20/error-details/policy-violation.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/policy-violation.json) * [`/schemas/3.1.20/error-details/conflict.json`](https://adcontextprotocol.org/schemas/3.1.20/error-details/conflict.json) These schemas are recommended, not required. Sellers that omit `details` entirely are conformant. Agents MUST NOT require specific `details` keys — fall back to `code`, `message`, and `recovery` when `details` is absent or has unexpected shape. ## Seller-Specific Error Codes Sellers MAY use error codes not in the [standard vocabulary](https://adcontextprotocol.org/schemas/3.1.20/enums/error-code.json). To distinguish seller-specific codes from standard codes and avoid collisions between sellers: * Seller-specific codes MUST use the format `X_{VENDOR}_{CODE}` (e.g., `X_STREAMHAUS_FLOOR_NOT_MET`) * `{VENDOR}` MUST be an uppercase alphanumeric identifier (matching `/^[A-Z][A-Z0-9]{1,19}$/`) registered in the vendor error code registry * `{CODE}` MUST be uppercase alphanumeric with underscores (matching `/^[A-Z][A-Z0-9_]{1,39}$/`) * Agents MUST handle unknown codes by falling back to the `recovery` classification * If `recovery` is absent on an unknown code, treat as `terminal` * Sellers SHOULD register their vendor prefix and codes in the [vendor error code registry](https://adcontextprotocol.org/schemas/3.1.20/error-details/vendor-error-codes.json) by submitting a PR ```javascript theme={null} function handleError(error) { if (isStandardErrorCode(error.code)) { // Handle per standard code semantics return handleStandardError(error); } // Unknown/vendor code: fall back to recovery classification return handleByRecovery(error); } ``` ## Client Library Requirements Client libraries (like `@adcp/sdk`) that implement this spec MUST: 1. **Extract structured errors automatically.** Consumers should receive a typed error object with `code`, `recovery`, `retryAfter`, `field`, `suggestion`, and `details` — not a generic error with a message string. 2. **Implement the detection order.** Check all paths in order: `structuredContent`, artifacts, `status.message.parts`, `error.data`, text fallback. 3. **Validate extracted errors.** Verify that `code` is a non-empty string (max 64 characters) and that the total serialized payload does not exceed 4096 bytes. Discard payloads that fail validation. 4. **Guard text fallback with `isError`.** Only attempt JSON-based text extraction on MCP responses where `isError` is `true`. A successful response with JSON content MUST NOT be interpreted as an error. 5. **Preserve recovery metadata.** The extracted error MUST carry `recovery` and `retry_after` so callers can implement retry logic without re-parsing. 6. **Handle unknown recovery values.** Treat unknown `recovery` values as `terminal`. 7. **Clamp `retry_after`.** Values below 1 become 1, values above 3600 become 3600. Non-finite values (`NaN`, `Infinity`) MUST be treated as absent. 8. **Support text fallback.** Attempt `JSON.parse` on `content[].text` for MCP `isError` responses without `structuredContent`. This will be the primary extraction path until `structuredContent` adoption is widespread. Client libraries MAY additionally: * Auto-retry `transient` errors with exponential backoff when `retry_after` is present * Expose a `retryPolicy` option for consumers to configure retry behavior * Map standard error codes to typed error subclasses using the `STANDARD_ERROR_CODES` table ## Test Vectors Machine-readable test vectors are available at [`/static/test-vectors/transport-error-mapping.json`](https://adcontextprotocol.org/test-vectors/transport-error-mapping.json). Each vector contains: * `transport`: `mcp` or `a2a` * `path`: extraction path (`structuredContent`, `jsonrpc_error`, `text_fallback`, `artifact`) * `response`: the transport-specific response envelope * `expected_error`: the AdCP error that should be extracted (or `null` for legacy servers) * `expected_action`: `retry`, `surface_to_caller`, `escalate_to_human`, or `generic_error` Client libraries SHOULD validate their extraction logic against these vectors. ## Error Translation in Agent Chains When a seller agent calls upstream services (APIs, databases, other agents), upstream failures must be translated before returning to the caller. **Rule 1: Translate upstream errors into AdCP error codes.** Do not pass through raw upstream errors. An HTTP 429 from a seller's internal API becomes `RATE_LIMITED`. A database connection timeout becomes `SERVICE_UNAVAILABLE`. The buyer should never see error formats from systems it has no relationship with. **Rule 2: Classify recovery from the caller's perspective.** If the seller can fix the upstream issue without buyer action, the error is `transient` or `terminal` — not `correctable`. A `correctable` error means the *buyer* needs to change something. For example: if the seller's upstream creative review API rejects an ad, that is `correctable` (the buyer can revise the creative). But if the seller's internal billing system is down, that is `transient` (the buyer should retry) even though the upstream error might be a 500. **Rule 3: Intermediaries preserve or translate, never drop.** An orchestrator sitting between buyer and seller (e.g., an agency agent routing to multiple sellers) MUST either: * **Pass through** the AdCP error unchanged if the upstream is already AdCP-conformant, or * **Translate** the error into a valid AdCP error if the upstream uses a different format Intermediaries MUST NOT strip `recovery`, `retry_after`, or `details` from errors they pass through. An intermediary MAY aggregate errors from multiple upstream sellers into an `errors` array, with each error preserving its original `code` and `recovery`. ```javascript theme={null} // Seller-side: translate upstream errors for the buyer function translateUpstreamError(upstreamError) { if (upstreamError.status === 429) { return { code: 'RATE_LIMITED', message: 'Request rate exceeded', recovery: 'transient', retry_after: upstreamError.headers?.['retry-after'] || 10, }; } if (upstreamError.status >= 500) { return { code: 'SERVICE_UNAVAILABLE', message: 'Service temporarily unavailable', recovery: 'transient', }; } // Never expose upstream details to the buyer return { code: 'SERVICE_UNAVAILABLE', message: 'An internal error occurred', recovery: 'transient', }; } ``` ## Security Considerations Error responses flow through LLM context. Every field is client-facing. ### Seller Requirements **Implementations MUST NOT include:** * Internal service names, hostnames, or IP addresses * Database error text, SQL fragments, or query plans * Stack traces or file paths * Upstream API responses from internal services * Credentials, tokens, or session identifiers **`suggestion` boundaries:** Provide generic correction guidance (e.g., "Increase budget to meet minimum") rather than revealing specific thresholds, valid identifiers, or resource existence. **`retry_after` consistency:** Return consistent values reflecting the caller's rate-limit state, not the target resource's properties, to avoid timing side channels. **Transport-level code granularity:** The reserved JSON-RPC codes (`-32029`, `-32028`, `-32027`) enable infrastructure error classification. Implementations that prefer to minimize endpoint fingerprinting MAY collapse these into a single code. ### Buyer Agent Requirements **Prompt injection via error fields.** The `message`, `suggestion`, `field`, `details`, and all string values within them are seller-controlled content that enters buyer agent LLM context. A malicious or compromised seller can craft values containing instructions aimed at manipulating the buyer agent. Buyer agents MUST: * **Route all recovery decisions through `code` and `recovery` only.** Never parse `message`, `suggestion`, or `details` values for actionable instructions. The `handleAdcpError` function above demonstrates this pattern — it switches on `recovery`, not on message content. * **Use data boundaries for seller-provided strings.** When including error field values in LLM context, place them inside explicit data delimiters (e.g., structured tool response fields, XML-style tags) that the system prompt designates as untrusted seller data. Do not interpolate seller-provided strings into prose or instructions. * **Enforce length limits** before including seller strings in LLM context: `message` (256 bytes), `suggestion` (512 bytes). Truncate silently. * **Strip non-printable characters** from all string fields: control characters (U+0000–U+001F), zero-width characters (U+200B–U+200F), and bidirectional override characters (U+202A–U+202E). * **Enforce a maximum payload size.** Clients MUST discard extracted `adcp_error` objects where `JSON.stringify(error).length` exceeds 4096 bytes. This prevents context window exhaustion from oversized `details` objects. * **Never use `field` as a dynamic property path** in object mutation operations (e.g., `lodash.set`, bracket notation chains). The `field` value is for display and field-level UI highlighting only. * **Never merge extracted error objects into application state** via `Object.assign`, spread operators, or shallow copy without filtering keys. Seller-controlled keys like `__proto__` or `constructor` can trigger prototype pollution in some runtimes. * **Never include raw `details` objects** in system prompts or tool descriptions. **URL validation.** `details.setup_url` (in `ACCOUNT_SETUP_REQUIRED` errors) is a seller-provided URL that users or agents may follow to complete account setup. Clients MUST validate that `setup_url` uses the `https` scheme, contains no userinfo component (e.g., `https://user:pass@evil.com`), and that the domain matches the seller's known domain. Clients MUST reject URLs that fail any of these checks. `details.policy_url` (in `CREATIVE_REJECTED` and `POLICY_VIOLATION` errors) is informational. Clients SHOULD apply the same validation. All seller-provided URLs MUST be rejected if they use non-`https` schemes (`http`, `javascript`, `data`, `file`). ## See Also * [Error Handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling) — error schema, standard codes, recovery strategies * [MCP Guide](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide) — MCP transport integration * [A2A Guide](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide) — A2A transport integration * [A2A Response Format](/dist/docs/3.1.20/building/by-layer/L0/a2a-response-format) — canonical A2A response structure # Schemas & SDKs Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/schemas-and-sdks Quick-start reference for AdCP schemas and client SDKs: installation, schema URLs, version discovery, and where to go next. This page orients you to the two foundational resources for building with AdCP: the JSON schemas that define every request and response, and the SDKs that turn those schemas into typed code. ## SDKs AdCP ships official SDKs for TypeScript, Python, and Go. Each SDK absorbs the protocol layers (wire format, signing, auth, lifecycle semantics) so you write business logic only. `npm install @adcp/sdk` `pip install adcp` `go get github.com/adcontextprotocol/adcp-go` For the full coverage matrix (which layers each SDK ships today), version pinning guidance, and package export details, see [Choose your SDK](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk). For the layered model behind the SDKs — what L0 through L4 each contain and why SDKs matter more in AdCP than in simpler protocols — see the [SDK stack reference](/dist/docs/3.1.20/building/cross-cutting/sdk-stack). ## Schema URLs AdCP schemas are published at `https://adcontextprotocol.org/schemas/` under the current major-version alias: | Pattern | Example | Use | | ----------------- | -------------------------------------------------------------------------------------- | -------------------------------------------- | | **Current major** | `https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-request.json` | Pin validators to the AdCP 3.x schema family | Schemas cover every task's request and response, shared enums, governance objects, creative formats, and signal definitions. Use the current major-version URL in production and CI so links stay stable across major releases. For how wire-level `adcp_protocol_version` relates to schema bundle versions, and why those are different things, see [Versioning & governance](/dist/docs/3.1.20/reference/versioning). ## Authorization in adagents.json Publishers declare their agent endpoints and authorization scopes in `adagents.json`. The schema supports six authorization types — `property_ids`, `property_tags`, `inline_properties`, `publisher_properties`, `signal_ids`, and `signal_tags` — each scoping which inventory the agent is authorized to act on. For the full authorization type reference and worked examples, see [Authorization Patterns](/dist/docs/3.1.20/governance/property/adagents#authorization-patterns) in the governance docs. ## Where to go next Coverage matrix, install commands, and version pinning for each language. Skill files and step-by-step guide for building a seller agent. Buyer-side reference for discovering capabilities and handling responses. Run the compliance suite to verify your implementation. # AAO Verified Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/aao-verified The public trust mark for AdCP agents. Two qualifiers — Verified (Spec) for wire-format conformance, Verified (Sandbox) for production-surface sandbox tolerance. Earn either or both. **Status**: Request for Comments **Last Updated**: May 11, 2026 **AAO Verified** is the public trust mark for AdCP agents. It carries one of two qualifiers in parens — **(Spec)** or **(Sandbox)** — and may carry both. The qualifier names *which axis* of verification an agent has earned. It is two axes, not two tiers. The qualifiers answer different questions: * **Verified (Spec)** — your AdCP protocol implementation matches the spec. Storyboards pass somewhere — could be a test deployment, could be local dev. Wire format, task shape, error semantics, state-machine transitions all check out. Attests *wire-format conformance*, not production tolerance. * **Verified (Sandbox)** — your **real production endpoint** correctly honors `account.sandbox: true`. AAO runs the full storyboard suite against your registered `agent_url` with sandbox-flagged traffic; your prod stack processes it with schema-valid responses, correct lifecycle transitions, proper error envelopes, and **no real-world side effects** (no real spend, no real persistence, no real platform calls). Attests *the production code path tolerates test traffic correctly*. An agent can earn either axis or both. A pure protocol wrapper around a stub ad server is honestly **Verified (Spec)** — that's what test agents and dev environments *are*. A real production seller whose prod URL handles sandbox traffic across the full storyboard suite earns **Verified (Spec + Sandbox)**, the strongest claim available. The two axes are **orthogonal** — neither is a prerequisite for the other. A seller without a separate test deployment (production-only platforms that have no test-mode surface) can earn **(Sandbox)** directly by exposing their prod URL to AAO's runner with sandbox flagging — no separate test endpoint needed. Conversely, a test agent that can never serve real impressions earns **(Spec)** as a complete claim. The badge surfaces whichever qualifiers are earned. **TL;DR for sellers.** Both qualifiers run the same storyboards through the same AAO compliance heartbeat. The difference is *where* the runner targets and *what* the seller's stack does with sandbox-flagged traffic: * **(Spec)** runs storyboards against a test deployment / local dev / a sandbox endpoint. The agent's prod surface is not exercised. * **(Sandbox)** runs storyboards against the seller's registered production `agent_url` with `account.sandbox: true` on every request. The seller's prod stack MUST honor the flag — return schema-valid responses, transition state correctly, surface errors properly, and have **zero real-world side effects** (no billing, no persistence beyond the sandbox account, no third-party platform calls). The seller-side sandbox gate is normative: every sandbox-flagged production request must resolve to a persisted sandbox account, not a live account wearing a `sandbox: true` claim. See [comply\_test\_controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) for the dev-side deterministic-testing affordance; Sandbox grading itself does not require or use the controller. ## What each axis certifies ### Verified (Spec) | | | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Tested against** | Any endpoint the agent owner registers — test deployment, local dev, sandbox-only stack. The runner does not distinguish. | | **What it proves** | AdCP wire format, task shape, error semantics, state-machine transitions, declared specialisms map to working tools, schema conformance, filter behavior, idempotency semantics — exercised in isolation from real-world production state. | | **How** | Storyboards from the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) run against the registered agent URL on AAO's compliance heartbeat. | | **Cadence** | \~1h heartbeat | | **Eligibility** | Any agent that passes the storyboards for its declared specialisms + holds an active AAO membership with API-access tier | | **Status** | **Available now** | ### Verified (Sandbox) | | | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Tested against** | Your registered production `agent_url`, with `account.sandbox: true` on every request. | | **What it proves** | Your production code path correctly honors sandbox flagging — same storyboards as (Spec), but exercised against the real prod stack buyers actually hit. Schema-valid responses, correct lifecycle transitions, proper error envelopes, **zero real-world side effects**. | | **How** | Same storyboard suite as (Spec), driven against the seller's registered URL with sandbox-flagged traffic. No separate canonical-campaign infrastructure. | | **Cadence** | Same \~1h heartbeat as (Spec) | | **Eligibility** | Same as (Spec), PLUS the seller's prod surface accepts `account.sandbox: true` requests and processes them without persisting real state, calling third-party platforms, or billing | | **Status** | **Foundation shipping** in [#4382](https://github.com/adcontextprotocol/adcp/pull/4382) (account.sandbox schema gate), [#4384](https://github.com/adcontextprotocol/adcp/pull/4384) (live-mode denial storyboard). Full grading framework following. | The (Sandbox) qualifier replaces the earlier draft's `Verified (Live)` framing. The change: instead of attesting "your real-money production code path delivers impressions correctly" (canonical campaigns running through your stack), (Sandbox) attests "your real production code path correctly handles sandbox-flagged traffic across the full storyboard suite." Both are real-prod-surface claims; the difference is what gets tested. (Sandbox) is universally achievable across specialisms with no new AAO operational infrastructure. See [#4379](https://github.com/adcontextprotocol/adcp/issues/4379) for the reframe verdict. **Re: `comply_test_controller`**: the controller is a **dev/staging-only** affordance for adopters' own integration testing. AAO's (Sandbox) grading does not require or use it. Sellers MAY implement controller endpoints in their dev environment to support deterministic local testing, but the production stack does not need to expose `comply_test_controller` to earn (Sandbox). The seller-side sandbox gate is what (Sandbox) attests — schema and lifecycle correctness under flagged traffic, on real prod. How the dev-time test surface itself is stood up — DB-backed `seed_*` for state-local sellers vs the SDK's `TestControllerBridge` for upstream-proxy sellers — is covered in [Test surfaces and the storyboard loop](/dist/docs/3.1.20/building/verification/conformance#test-surfaces-and-the-storyboard-loop). ## Naming history Earlier drafts (#3001) proposed "AdCP Conformant" and "AAO Verified" as two distinct mark names — one per axis. This page uses a **single brand mark with axis qualifiers in parens** instead. Same shape, different naming convention: | Earlier draft | Current | | ------------------------------ | -------------------------- | | AdCP Conformant | AAO Verified (Spec) | | AAO Verified | AAO Verified (Live) | | AdCP Conformant + AAO Verified | AAO Verified (Spec + Live) | The reasoning behind the rename: a single brand word ("Verified") with composable qualifiers is cleaner for buyer messaging. Buyers don't have to learn two distinct marks; they read the qualifier inline. Test agents earning **Verified (Spec)** is a complete, dignified claim — they're test agents, that's the whole point — rather than a "junior" Conformant tier. The wire format reflects this: a single `verification_modes: string[]` array in the JWT and registry API, where an agent might have `["spec"]` or `["spec", "sandbox"]`. One badge URL per agent + role; the qualifier evolves as axes are earned, embedded badges automatically reflect the current state. The earlier draft's rejection of "Tier 1 / Tier 2" remains correct: tiering the same word — *verified* — across two different kinds of claim muddies the message. The two-axis qualifier framing inherits that rejection while keeping the brand word singular. ## Coverage gaps are explicit Under the (Sandbox) framing, every applicable storyboard is attempted against the seller's production endpoint with sandbox-flagged traffic. There's no observability carve-out — universal storyboards (`signed_requests`, `pagination_integrity`, etc.) run as part of the standard suite. The registry keeps not-selected items separate from selected-but-skipped items: run-mode exclusions ("this was a sandbox-only run, so live-only probes were not selected"), declined optional capabilities ("the seller did not claim this feature"), and deterministic-test-surface gaps ("the production endpoint correctly omits `comply_test_controller`"). Those are different asks of the seller and MUST NOT be collapsed into one generic "skipped optional things" bucket. The (Sandbox) qualifier is a verification profile over that evidence: it defines which not-selected and skipped classes are acceptable for the Sandbox badge and which remain blockers. See [#4379](https://github.com/adcontextprotocol/adcp/issues/4379) for the framing decision that replaced the earlier (Live) observability model. ## Reading a badge Badges render as a single shields.io-style image with the qualifiers in parens: | Display | Meaning | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `AAO Verified Sales Agent (Spec)` | Storyboards pass for declared media-buy specialisms against a test deployment / dev / sandbox-only endpoint. Wire format and protocol semantics are correct; production-stack sandbox tolerance is not yet attested. Common for test agents and pre-production rollouts. | | `AAO Verified Sales Agent (Spec + Sandbox)` | Both axes earned. The strongest claim. The agent's registered production URL handles the full storyboard suite under sandbox-flagged traffic with no real-world side effects. | | `AAO Verified Sales Agent (Sandbox)` | Storyboards pass against the seller's registered production endpoint under `account.sandbox: true`. The seller's prod stack correctly honors the sandbox gate. Common for production-only sellers without a separate test deployment. | | `AAO Verified — Not Verified` | No badge issued for this agent + role, or the badge has been revoked. | The badge URL is stable per agent + role. As an agent earns or loses an axis, the SVG content updates without changing the URL — embedded badges automatically reflect the current state. ## Declare specialisms ```json theme={null} // agent declares its claims in get_adcp_capabilities { "supported_protocols": ["media_buy", "creative"], "specialisms": [ "sales-broadcast-tv", "sales-guaranteed", "creative-ad-server" ] } ``` `specialisms` is the badge-routing field. Omitting it means the agent still runs the universal and protocol-baseline storyboards implied by `supported_protocols`, but no specialism badge can be issued because the verification engine has no narrow claim to evaluate. ## How agents earn each axis The axes are verification profiles over storyboard evidence, not separate runner implementations. The runner records what passed, failed, or skipped and why; the Spec and Sandbox profiles decide whether that evidence is sufficient for the public qualifier. Buyer-specific requirements are separate. A buyer can require a public qualifier, a declared protocol or specialism, one or more optional capabilities, and a stricter position on skips. For example, a buyer that needs proposal workflows can reject an otherwise clean run where `media_buy.supports_proposals: false` caused proposal storyboards to skip. A buyer doing deterministic integration testing can ask for a dev/staging endpoint with `comply_test_controller` even though the production Sandbox qualifier does not require exposing that controller. ### To earn Verified (Spec): 1. Implement AdCP for your declared specialisms. 2. Pass the storyboards on your test-mode endpoint. (Run them locally first via `@adcp/sdk/testing` to see what's failing.) 3. Hold an active AAO membership at an API-access tier. The compliance heartbeat picks it up automatically — no manual enrollment needed beyond [registering your agent](/dist/docs/3.1.20/building/index). ### To earn Verified (Sandbox): A seller earns (Sandbox) by: 1. Registering their **production `agent_url`** with AAO. This is the same registration that earns (Spec) — no separate "compliance account" or "test deployment" needed. 2. Implementing the **sandbox-account gate** in their production stack: when a request arrives with `account.sandbox: true`, the seller verifies the targeted account is a sandbox account in the persisted record (not trusting the field), and processes the request with full schema/lifecycle correctness while producing **zero real-world side effects** — no real spend, no real ad-server orders, no third-party platform calls, no production persistence beyond the sandbox account's bounded state. 3. Holding an active AAO membership at an API-access tier. That's it. The compliance heartbeat runs the same storyboards as (Spec), but targets the registered production URL with `account.sandbox: true` on every request. Pass → (Sandbox) qualifier issues. **Key requirement: sandbox-account isolation.** Sellers MUST persist a clear sandbox/live distinction at the account level. A request asserting `sandbox: true` against a live account MUST be refused with a structured error — see [#4028](https://github.com/adcontextprotocol/adcp/issues/4028) and the `comply-controller-mode-gate` storyboard for the canonical denial check. Cross-mode leakage is the failure mode (Sandbox) attests against. ## Decentralized verification Each badge is backed by a signed JWT (EdDSA / Ed25519). AAO publishes its public key set at `/.well-known/jwks.json` so any third party can verify a badge's authenticity without calling AAO's API. The token claims: ```json theme={null} { "iss": "https://aao.org", "sub": "https://your-agent.example.com/mcp", "aud": "aao-verification", "jti": "", "iat": 1745510400, "exp": 1748102400, "role": "media-buy", "adcp_version": "3.0", "verified_specialisms": ["sales-broadcast-tv", "sales-guaranteed"], "verification_modes": ["spec"], "protocol_version": "3.0.0" } ``` `adcp_version` is the AdCP release this badge was issued against (`MAJOR.MINOR`). Pairs with the `(agent_url, role, adcp_version)` identity used by the badge URL routes. **Verifiers MUST check `adcp_version` against the AdCP version they care about** — a 3.0 token presented as proof of 3.1 conformance is not authoritative. The signed claim is shape-validated at signing time (`^[1-9][0-9]*\.[0-9]+$`); verifiers SHOULD apply the same regex defensively. `verification_modes` is the array of axes earned. `["spec"]` for test-deployment storyboard pass only; `["spec", "sandbox"]` for agents whose production endpoint also passes under sandbox-flagged traffic. `protocol_version` is the full semver of the spec build the badge was tested against — informational metadata for support and audits. Runner coverage is reported separately from badge mode. A production-path sandbox run can have zero failed assertions and still be `partial` when selected controller-dependent scenarios skip because the production endpoint correctly omits `comply_test_controller`. That is useful evidence, but badge issuance uses the registry's per-storyboard status, not-selected reasons, and skip reasons, not just the failed-step count. Expected sandbox-mode exclusions, optional-capability skips, and missing-required-surface skips remain visible as separate signals so buyers can tell "not part of this run" from "selected but not executed" from "not implemented by this seller." The registry API is authoritative for real-time status; the JWT is a 30-day cacheable proof. ## Lifecycle Verification is continuously re-evaluated, not a one-time certificate. ### (Spec) * **Issued** — first heartbeat with all declared-specialism storyboards passing + active membership. * **Active** — re-checked every heartbeat; JWT auto-renewed. * **Degraded** — first storyboard regression starts a 48-hour grace; the badge continues to render (Spec) while the operator investigates. * **Revoked** — 48h continuous failure → `(Spec)` qualifier drops from the badge. (Sandbox), if held, is unaffected — the axes are independent. * **Recovery** — passing storyboards reissue (Spec) automatically. ### (Sandbox) * **Issued** — first heartbeat whose Sandbox verification profile passes against the registered production URL under `account.sandbox: true` + active membership. The profile requires all buyer-visible sandbox-path assertions to pass, treats production-forbidden controller phases as not-selected run-mode exclusions, and keeps optional-capability skips visible. * **Active** — re-checked every heartbeat; JWT auto-renewed. * **Degraded** — first Sandbox-profile regression starts a 48-hour grace; the badge continues to render (Sandbox) while the operator investigates. Cross-mode leakage (a sandbox request producing real-world side effects, or a live account accepting sandbox-flagged traffic) MAY skip the grace period and revoke immediately — that's the (Sandbox) attestation's whole point. * **Revoked** — 48h continuous failure → `(Sandbox)` qualifier drops. (Spec), if held, is unaffected. * **Recovery** — passing the Sandbox profile reissues (Sandbox). Membership lapse revokes the entire badge regardless of test results — public trust marks require active membership. ## Mark semantics A seller MAY hold: * **(Spec) only** — storyboards pass on a test-mode endpoint; (Sandbox) not enrolled, or the production endpoint has not yet passed under sandbox-flagged traffic. Common for test agents, sandboxes, and pre-production rollouts. * **(Sandbox) only** — storyboards pass against the registered production endpoint under `account.sandbox: true`. Common for production-only platforms with no separate test-mode surface. * **(Spec + Sandbox)** — the strongest claim. Both axes verified independently. * **Neither** The two axes are evaluated independently. A storyboard regression on the test endpoint revokes (Spec) without affecting (Sandbox); a sandbox-path regression on the production endpoint revokes (Sandbox) without affecting (Spec). Sellers can earn either in either order. ## Per-version badges Each badge is identified by **(agent, role, AdCP version)** — a third axis on top of (Spec) and (Sandbox). An agent can hold parallel badges across AdCP releases. For example, a media-buy agent that ships an upgrade for AdCP 3.1 might hold both: * `AAO Verified Media Buy Agent 3.0 (Spec)` — earned earlier, still valid * `AAO Verified Media Buy Agent 3.1 (Spec + Sandbox)` — earned after upgrading Each version is evaluated independently. A 3.0 storyboard regression revokes the 3.0 badge without touching 3.1, and vice versa. Membership lapse revokes every version of an agent's badges atomically (the trust mark is agent-level, not version-level). The badge label embeds the AdCP version inline between the role and the qualifier: `Media Buy Agent 3.1 (Spec + Sandbox)`. ## Display ### SVG badge Two URL shapes: ``` # Legacy: auto-upgrades to the highest active version https://agenticadvertising.org/api/registry/agents/{url-encoded-agent-url}/badge/{role}.svg # Version-pinned: freezes on a specific AdCP release https://agenticadvertising.org/api/registry/agents/{url-encoded-agent-url}/badge/{role}/{adcp-version}.svg ``` Buyers who want auto-upgrade behavior (the embedded image flips from `Media Buy Agent 3.0 (Spec)` to `Media Buy Agent 3.1 (Spec + Sandbox)` automatically when the agent earns 3.1) embed the legacy URL. Buyers who want to call out "verified for AdCP 3.0" specifically embed the version-pinned URL. Both return a shields.io-style SVG with `Content-Security-Policy: script-src 'none'` and 5-minute caching. Renders teal when verified, grey when not. Unknown agents, unknown roles, and revoked badges all return a grey "Not Verified" variant — the URL never 404s, which makes it safe to embed. Version-pinned URLs at versions the agent never earned also return "Not Verified" (vs. the legacy URL, which shows the current best mark). ### Embed snippet ```bash theme={null} # Legacy (auto-upgrading) curl https://agenticadvertising.org/api/registry/agents/{url-encoded-agent-url}/badge/media-buy/embed # Version-pinned curl https://agenticadvertising.org/api/registry/agents/{url-encoded-agent-url}/badge/media-buy/3.0/embed ``` Returns HTML and Markdown snippets that wrap the SVG in a link back to the agent's AgenticAdvertising.org registry listing. Safe for READMEs, docs, landing pages, and social profiles. As an agent's verification axes or AdCP versions change, the legacy embed automatically reflects the current state — no embed swap needed when (Sandbox) lights up or when the agent ships a new AdCP version. ### Registry filter The agent registry surfaces filters on either axis independently: * **"Show me agents that implement AdCP correctly"** → filter by `verification_modes contains 'spec'` * **"Show me production endpoints that pass under sandbox-flagged traffic"** → filter by `verification_modes contains 'sandbox'` * **"Show me agents with both"** → filter by both Both queries are valid. Buyers comparing options use (Sandbox); orchestrator developers integrating new agents use (Spec). ### brand.json enrichment When AgenticAdvertising.org serves brand.json data for a registered brand, agent entries get an `aao_verification` block with full per-version detail: ```json theme={null} "aao_verification": { "verified": true, "verified_at": "2026-04-29T12:34:56.000Z", "badges": [ { "role": "media-buy", "adcp_version": "3.1", "verification_modes": ["spec", "sandbox"], "verified_at": "..." }, { "role": "media-buy", "adcp_version": "3.0", "verification_modes": ["spec"], "verified_at": "..." } ], "roles": ["media-buy"], "modes_by_role": { "media-buy": ["spec", "sandbox"] }, "deprecation_notice": "roles[] and modes_by_role reflect the highest-version badge per role only. A buyer pinned to a specific AdCP version SHOULD read badges[] and filter by adcp_version. Both fields will be removed in AdCP 4.0." } ``` `badges[]` is the canonical shape — one entry per `(role, adcp_version)`, ordered version-DESC. Buyers pinned to a specific AdCP version MUST filter by `adcp_version` rather than reading `modes_by_role` (which flattens to the highest-version entry per role and could mislead a 3.0 buyer into thinking the agent has Sandbox for them when only the 3.1 badge has Sandbox). `roles[]` and `modes_by_role` are kept as **deprecated aliases** for one release. **Removal target: AdCP 4.0.** ## How to claim each qualifier ### To claim **(Spec)** 1. Hold an active AAO membership with API-access tier. 2. Declare your `supported_protocols` and `specialisms` in `get_adcp_capabilities`. 3. Pass the storyboards your declarations obligate (universal + protocol baselines + specialism baselines) at a specific AdCP major version. 4. The AAO compliance heartbeat issues **AAO Verified (Spec)** automatically and re-verifies on each heartbeat cycle. ### To claim **(Sandbox)** (Sandbox) is **independent of (Spec)** — sellers without a separate test deployment can earn (Sandbox) directly by exposing their production endpoint to AgenticAdvertising.org's runner with sandbox flagging. 1. Hold an active AAO membership with API-access tier. 2. Declare your `supported_protocols` and `specialisms` in `get_adcp_capabilities` (same as (Spec)). 3. Register your **production `agent_url`** with AgenticAdvertising.org. The compliance heartbeat will target it with `account.sandbox: true` on every storyboard request. 4. Implement the sandbox-account gate in your production stack: verify the targeted account is sandbox in your persisted records (not by trusting the field), and process the request with full schema/lifecycle correctness while producing **zero real-world side effects** — no real spend, no real ad-server orders, no third-party platform calls, no production persistence beyond the bounded sandbox account state. 5. Pass the Sandbox verification profile: buyer-visible sandbox-path storyboards must pass, not-selected run-mode exclusions are allowed, and optional-capability skips remain visible as scope choices. The production endpoint is not expected to expose `comply_test_controller`; selected controller-dependent deterministic phases are not a Sandbox-badge blocker when the controller is absent from production as required. 6. If you run a shared production surface that exposes `comply_test_controller` to sandbox principals, pass the [`comply-controller-mode-gate`](https://adcontextprotocol.org/compliance/latest/universal/comply-controller-mode-gate) check for that surface. Canonical two-deployment sellers satisfy the same isolation requirement by not advertising the controller on production at all. 7. The AgenticAdvertising.org compliance heartbeat issues **AAO Verified (Sandbox)** when the Sandbox profile passes against the registered URL with sandbox-flagged traffic. Same storyboards as (Spec). Same heartbeat cadence. Different attestation surface: prod, with sandbox flagging, instead of any-registered-endpoint. ## What AAO Verified is not * **Not a regulatory or financial attestation.** SOC 2, ISO 27001, ISAE 3402 and similar frameworks address operational and financial-control posture — distinct questions, with their own audit paths. AAO Verified is wire-and-delivery correctness for AdCP. * **Not hard ground-truth reconciliation.** (Sandbox) attests the production code path handles sandbox-flagged traffic correctly across the protocol surface. It does not reconcile real-money AdCP-reported numbers against the seller's internal ad-server dashboard under live traffic. Hard reconciliation is a separate kind of attestation tracked outside the (Sandbox) tier. * **Not certification beyond AAO membership.** The [AgenticAdvertising.org certification program](/dist/docs/3.1.20/learning/overview) composes with AAO Verified — verification is necessary input to certification, but verification is not certification itself. * **Not a SLA.** AAO Verified does not guarantee uptime, latency, or commercial outcomes. It attests that the seller's AdCP surface continuously reflects real delivery; commercial reliability is between buyer and seller. * **Not a substitute for due diligence.** Buyers SHOULD still vet sellers' contractual terms, billing posture, governance practices, and incident-response posture independently. AAO Verified is one input, not the whole picture. ## Relationship to supporting specs AAO Verified (Sandbox) rests on a small set of normative AdCP spec elements: * **[`account.sandbox` schema gate (#3755 / #4382)](https://github.com/adcontextprotocol/adcp/issues/3755)** — pins sandbox intent on account references and gives the seller a protocol-level flag to verify against persisted account mode. The seller-side gate is the load-bearing control; the request flag is not trusted by itself. * **[`comply-controller-mode-gate` storyboard (#4028 / #4384)](https://github.com/adcontextprotocol/adcp/issues/4028)** — verifies sellers correctly refuse controller dispatch against live-mode accounts when they choose a shared surface that exposes the controller to sandbox principals. Canonical production deployments satisfy this by not exposing the controller at all. * **[UNKNOWN\_SCENARIO grading (#4226 / #4228)](https://github.com/adcontextprotocol/adcp/issues/4226)** — sellers MAY implement controller scenarios selectively in dev/staging; the runner grades absent operations as coverage gaps rather than failures. Controller is dev-only per the (Sandbox) framing. The earlier (Live) framing's supporting issues (#2963, #2964, #2902 — `attestation_verifier` scope, `get_media_buys` ownership, behavioral filter assertions on real data) are deferred. They remain relevant if AAO ever returns to a canonical-campaign model, but are not load-bearing under (Sandbox). ## Relationship to other surfaces * [Conformance Specification](/dist/docs/3.1.20/building/verification/conformance) — defines what *conformant* means via the storyboards. The (Spec) axis verifies your agent matches that specification. * [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) — indexes the protocols and specialisms an agent can claim. Each declared specialism is what the verification engine tests, on whichever axes are eligible. * [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) — where the agent declares its `supported_protocols` and `specialisms`. The declarations are the input to verification. * AAO membership — required for badge issuance. Membership lapse revokes the badge. # Pair-program with Addie (Socket Mode) Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/addie-socket-mode Connect your dev/staging AdCP agent to Addie via outbound WebSocket so she can run storyboards against it conversationally. No public DNS, no ngrok, no inbound exposure. **Status**: Available in preview behind `CONFORMANCE_SOCKET_ENABLED` **Last updated**: May 4, 2026 When you're building an AdCP agent, the most useful loop is: run a storyboard against your in-progress server, see what fails, fix it, run again. Until now that meant standing up a public sandbox endpoint with TLS, DNS, auth, and firewall config — a real lift before you've written meaningful code, and a non-trivial security review at larger orgs. **Pair-programming with Addie via Socket Mode** collapses that to: install one library, paste a token, connect outbound. Addie sees your dev agent like any other AdCP server — no inbound exposure, no public surface — and can run any compliance storyboard against it in chat. ## When to use Socket Mode (and when not to) | Use Socket Mode when… | Use the public-endpoint path when… | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | You're building or refactoring an agent and want fast feedback | You're ready for AAO's [(Spec) heartbeat](/dist/docs/3.1.20/building/verification/aao-verified) on a stable test endpoint | | Your dev agent runs on `localhost`, a Codespace, or behind a firewall | Your platform exposes a public test endpoint anyway | | You want Addie to run multiple storyboards interactively in chat | You want unattended, scheduled compliance runs | | You're a small team without infra to expose a sandbox endpoint | You operate at scale and prefer batch CI | Socket Mode is **not** a replacement for AAO Verified. Once your agent is stable, expose a real test endpoint and let the AAO heartbeat run continuously — that's what earns the public **AAO Verified (Spec)** badge. Socket Mode is the dev-loop channel before you get there (and after, when you're iterating on changes). **Dev/staging only by design.** Socket Mode is gated to non-production deployments, the same constraint as `comply_test_controller` per [adcp#3986](https://github.com/adcontextprotocol/adcp/issues/3986). Production agents do not expose this channel and AAO never enrolls production deployments via Socket Mode. ## What you need 1. **An AdCP MCP server you're actively developing.** It can be incomplete — that's the point. The simplest case is a JS/TS process running on your laptop with the MCP SDK installed. 2. **An AAO account.** The conformance channel is bound to your WorkOS organization, so you need to be signed in to a member or trial org. Anonymous chat with Addie cannot use Socket Mode. 3. **`@adcp/sdk` ≥ 6.9** in your dev project. The `ConformanceClient` primitive ships from `@adcp/sdk/server`. 4. **Network egress to `addie.agenticadvertising.org` over WebSockets (port 443).** No inbound rules needed. That's it. No public DNS, no firewall changes, no ngrok, no certificate provisioning. ## The five-minute setup ### Step 1 — Ask Addie for a token In your Addie chat session, ask: > Give me a fresh conformance token Addie returns shell exports plus a copy-paste integration snippet: ``` **Conformance token issued.** Bound to your organization, expires in 1h. Paste these into your dev environment and start the conformance client: export ADCP_CONFORMANCE_URL=wss://addie.agenticadvertising.org/conformance/connect export ADCP_CONFORMANCE_TOKEN=eyJ… Three-line integration with @adcp/sdk ≥ 6.9: … ``` Tokens expire in one hour. When yours runs out, just ask Addie for a new one — there's no refresh endpoint by design. ### Step 2 — Wire `ConformanceClient` into your dev server Three lines added to your existing AdCP server bootstrap: ```ts theme={null} import { ConformanceClient } from '@adcp/sdk/server'; import { mcpServer } from './my-mcp-server'; const conformance = new ConformanceClient({ url: process.env.ADCP_CONFORMANCE_URL!, token: process.env.ADCP_CONFORMANCE_TOKEN!, server: mcpServer, }); await conformance.start(); ``` `mcpServer` is the same `Server` instance you'd connect to `StreamableHTTPServerTransport` for normal traffic — no separate setup, no parallel server. `ConformanceClient` exposes it bidirectionally over the outbound WebSocket; Addie sees a normal MCP server on the other end. If you don't have an AdCP server yet, fork the [`hello_seller_adapter_social` example](https://github.com/adcontextprotocol/adcp-client/blob/main/examples/hello_seller_adapter_social.ts) — it's a worked starting point with the SDK's `createAdcpServerFromPlatform` helper. ### Step 3 — Confirm the connection Run your dev server with the token and URL exported. You should see a status line: ``` [conformance] status=connecting [conformance] status=connected ``` Once `status=connected` lands, Addie has a live MCP client pointed at your dev server. The session stays open until you stop the process or your token expires. ### Step 4 — Run a storyboard from chat Back in Addie chat: > Run `media_buy_state_machine` against my agent Addie dispatches the storyboard through the open socket and renders the result as a markdown report in chat: ``` ### Conformance result — Media buy state machine lifecycle (media_buy_state_machine) **Overall:** ✅ PASSED **Steps passed/failed/skipped:** 8 / 0 / 1 **Duration:** 1240 ms #### ✓ Capability discovery - ✓ passed — Check agent capabilities #### ✓ Create a media buy - ✓ passed — Discover products for media buy - ✓ passed — Create the test media buy #### ✓ Valid state transitions - ✓ passed — Pause the media buy - ✓ passed — Resume the media buy - ✓ passed — Cancel the media buy … ``` Failing steps include trimmed error text so you can fix in place and re-run without leaving the chat. Iterate until green. You can run any storyboard in the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) this way — sales, creative, signals, governance, signed requests, etc. Ask Addie what's available if you're not sure: *"What conformance storyboards apply to a sales agent?"* ## What Addie can do once you're connected Beyond storyboard runs, the live MCP channel lets Addie: * **Diagnose failing steps interactively** — when a step fails, ask "why?" and Addie can re-call the same tool with different inputs to narrow the root cause * **Validate capability declarations** — *"Does my `get_adcp_capabilities` claim what I actually implement?"* * **Walk lifecycle states** — if you've wired `comply_test_controller`, Addie can drive deterministic state transitions and observe the results * **Suggest fixes against your real wire output** — *"Your `error_code` field is missing on this rejection — here's the fix"* — based on bytes she just saw, not generic advice ## Privacy & safety The Socket Mode channel is built to keep the surface narrow: * **Dev/staging only.** Production deployments must not expose this channel — same deployment-scoped rule as `comply_test_controller` ([adcp#3986](https://github.com/adcontextprotocol/adcp/issues/3986)). * **Outbound from you.** Your dev box opens the connection to Addie. Addie has no way to reach into your network. * **Session-scoped.** You start the client; it runs until you stop the process. No persistent tunnel, no daemon. * **Org-scoped.** The token's WorkOS org claim is the only tenant boundary. Other orgs cannot reach your agent over the channel. * **Disconnect anytime.** Kill the client process and the socket closes; Addie's session for your org evicts immediately. * **What Addie sees stays in your Addie context.** Same data-handling posture as anything else you tell her in chat. If you'd prefer to inspect the channel yourself, the wire format is plain JSON-RPC 2.0 frames (the same shape MCP already uses) over `wss://`. Run `wscat` against the URL with your token and you'll see exactly what Addie sees. ## Troubleshooting ### Addie says "you're not mapped to an organization" You're chatting with Addie anonymously or your account isn't bound to a WorkOS org yet. Sign in to your member or trial org and try again. ### `status=error` on connect; the server logs `401 Unauthorized` Token expired (1h TTL) or wrong token. Ask Addie for a fresh one. If a new token also 401s, your AAO membership may not have the conformance entitlement enabled — check your org's plan. ### Storyboard report shows step 1 failed with `unknown tool get_adcp_capabilities` Your dev MCP server doesn't yet implement `get_adcp_capabilities`. That's the discovery tool every AdCP agent must expose. Implement it before running any storyboard — see [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) for the response shape. ### Addie says "no conformance connection is live for your org" The socket isn't open. Either you haven't started the client yet, or it disconnected. Restart `ConformanceClient` and confirm `status=connected` before asking Addie to run anything. ### Socket connects but every storyboard step skips Your `get_adcp_capabilities` response declares specialisms you haven't implemented. The runner skips steps that don't match the declared surface. Either implement the tools or trim the declaration. ### How do I know what Addie is doing on the channel? The `onStatus` callback exposes every state transition (`connecting`, `connected`, `disconnected`, `error`). Pipe it to your dev logs: ```ts theme={null} new ConformanceClient({ url, token, server: mcpServer, onStatus: (status, detail) => { console.log(`[conformance] status=${status}`, detail?.attempt ? `attempt=${detail.attempt}` : '', detail?.error ? `error=${detail.error.message}` : ''); }, }); ``` For tool-level visibility, log inside your `setRequestHandler` callbacks — Addie's calls land there exactly like normal MCP traffic. ## Reference * [`@adcp/sdk/server` `ConformanceClient`](https://github.com/adcontextprotocol/adcp-client/blob/main/src/lib/server/socket-mode/conformance-client.ts) — adopter-side primitive * [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) — the discovery tool every storyboard starts with * [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) — full list of available storyboards * [Get Test-Ready](/dist/docs/3.1.20/building/verification/get-test-ready) — what your agent needs in place before any storyboard can pass * [AAO Verified](/dist/docs/3.1.20/building/verification/aao-verified) — the public trust mark you graduate to once your agent is stable * Channel design: [adcp#3991](https://github.com/adcontextprotocol/adcp/issues/3991) * Deployment-scoped controller rule: [adcp#3986](https://github.com/adcontextprotocol/adcp/issues/3986) # Compliance Catalog Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/compliance-catalog Full index of AdCP protocols and specialisms an agent can claim — what each one means, which compliance storyboards run, and where to find the source YAML. Every AdCP agent declares its `supported_protocols` and `specialisms` in `get_adcp_capabilities`. Each declaration maps to a compliance bundle at `/compliance/{version}/` that the storyboard runner executes to verify the claim. **`supported_protocols` is not exhaustive.** The `accounts` surface (`sync_accounts`, `list_accounts`, `sync_governance`) is a foundation implicit in every `media_buy`, `creative`, and `signals` agent and is intentionally not a `supported_protocols` value. See [Accounts tasks](/dist/docs/3.1.20/accounts/tasks/sync_accounts) for the full account surface. This page is the human-readable index of that taxonomy. The machine-readable equivalent is `/compliance/{version}/index.json`. ## Universal storyboards Every agent runs every storyboard in `/compliance/{version}/universal/` regardless of which protocols or specialisms it claims. A few are *capability-gated* — they only run when the agent advertises the relevant capability — but the storyboard is still universal in scope: any agent claiming the capability is graded by it. Failing a universal storyboard fails overall compliance. | Storyboard | Purpose | | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `capability-discovery` | `get_adcp_capabilities` shape, protocol/specialism declarations, version advertising | | `comply-controller-mode-gate` | Sandbox/live isolation for sellers that expose `comply_test_controller` on a shared endpoint — live-mode principals must be refused before scenario dispatch | | `schema-validation` | Request and response schema conformance, ISO 8601 timestamps, temporal invariants | | `schema-validation-signals` | Response schema compliance for signals — required fields on every signal; gated on `get_signals` | | `version-negotiation` | Release-precision `adcp.supported_versions` advertisement and response-envelope `adcp_version` echo; advisory in 3.1, promoted in later cuts | | `v3-envelope-integrity` | v3 protocol envelopes MUST NOT carry the v2 legacy `task_status` or `response_status` fields — `status` is the single canonical lifecycle field in v3. | | `error-compliance` | Structured error shape, published error codes, transport binding, no existence leaks across tenants | | `error-compliance-signals` | Error handling for signals protocol — nonexistent signal IDs, missing fields, VERSION\_UNSUPPORTED, transport binding; discovery phases are gated on `get_signals`; activation phases run only for agents that claim activation support such as `signal-marketplace` | | `stale-response-advisory` | `STALE_RESPONSE` wire placement — advisory rides in `errors[]` on a populated success response with transport success preserved; stale-cache forcing steps gated on `comply_test_controller` with `force_upstream_unavailable` | | `idempotency` | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | | `read-tool-idempotency` | Read-only task wrappers accept the 3.1 every-request `idempotency_key` envelope without strict request-wrapper rejection; includes the 3.1 omitted-key grace probe. | | `canonical-format-validate-input` | Canonical-format `validate_input` result semantics — structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | | `security` | **Authentication baseline — unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding.** See [Authentication](/dist/docs/3.1.20/building/by-layer/L2/authentication). | | `webhook-emission` | Outbound webhook conformance — stable `idempotency_key` across retries; RFC 9421 webhook signing (or HMAC fallback if the buyer opted in). Runs for any agent that accepts `push_notification_config` on any operation. | | `webhook-receiver-envelope` | Buyer receiver replay conformance: accepts full MCP webhook envelopes, rejects bare delivery-result payloads, verifies signatures over raw body bytes, and dedupes retries by `idempotency_key`. | | `notification-config-event-scope` | `sync_accounts.accounts[].notification_configs[]` semantic validation — account-level subscribers reject media-buy-anchored notification types (`scheduled`, `final`, `delayed`, `adjusted`, `impairment`) even though they are valid values in the shared notification-type enum. | | `notification-config-lifecycle` | Account-level `notification_configs[]` lifecycle on `sync_accounts`: paused registration, durable `list_accounts` echo, subscriber-keyed replacement, and clear-all semantics. | | `notification-config-rejections` | Semantic `notification_configs[]` request rejection path for duplicate `subscriber_id` values. | | `wholesale-feed-products` | Product wholesale feed versioning — bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without product rows. | | `wholesale-feed-signals` | Signals wholesale feed versioning — bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without signal rows. | | `wholesale-feed-product-webhooks` | Account-level `notification_configs[]` registration for agents that advertise product wholesale feed webhook events. | | `wholesale-feed-signal-webhooks` | Account-level `notification_configs[]` registration for agents that advertise signal wholesale feed webhook events. | | `wholesale-feed-bulk-webhooks` | Account-level `notification_configs[]` registration for agents that advertise `wholesale_feed.bulk_change`. | | `pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_creatives` response from a continuation page through to terminal. | | `get-products-pagination-integrity` | `get_products` wholesale pagination semantics — walks a seeded product feed from continuation to terminal while preserving brief/refine as capped curated/refined answers, not feed enumeration. | | `get-signals-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `get_signals` response under a broad query — page 1 must be non-terminal against any non-trivial signal set, page 2 follows the cursor. | | `pagination-integrity-list-accounts` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_accounts` response — storyboard bootstraps three accounts via `sync_accounts`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. | | `pagination-integrity-creative-formats` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_creative_formats` response — storyboard seeds two creative formats via `seed_creative_format`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. | | `get-media-buys-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `get_media_buys` response — storyboard seeds three media buys via `seed_media_buy`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. | | `content-standards-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_content_standards` response — storyboard bootstraps three content standards configurations via `create_content_standards`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. | | `collection-lists-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_collection_lists` response — storyboard bootstraps three collection lists via `create_collection_list`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. | | `property-lists-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_property_lists` response — storyboard bootstraps three property lists via `create_property_list`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. | | `deterministic-testing` | `comply_test_controller` state-machine verification — skipped if `capabilities.compliance_testing.supported: false`. | | `signed-requests` | RFC 9421 transport-layer request-signing verification — skipped if `request_signing.supported: false`. | | `billing-gate-dispatch` | `sync_accounts.billing` rejection dispatch — `BILLING_NOT_SUPPORTED` (capability gate, with `error.details.scope`) vs `BILLING_NOT_PERMITTED_FOR_AGENT` (per-buyer-agent commercial-relationship gate, with the clamped `rejected_billing` + `suggested_billing` shape). Capability phase skipped when the seller supports all three `billing` values; per-agent phases skipped when the test kit does not declare `commercial_relationship: passthrough_only`. | Capability-gated rows (`deterministic-testing`, `signed-requests`) are skipped only when the agent advertises the capability as `false`; they cannot be claimed and partially implemented. Declaring `supported: true` and failing the storyboard is non-conformant — declare `false` rather than ship a partial implementation. The `billing-gate-dispatch` and `comply-controller-mode-gate` rows are precondition-gated rather than ordinary capability-gated rows: each phase grades `not_applicable` when its precondition is not met. Sellers wanting full coverage of the per-agent billing gate SHOULD ship a test kit with `commercial_relationship: passthrough_only` declared so the per-agent phases run. ## Protocols Top-level agent capability claims. An agent claims a protocol by listing it in `supported_protocols` and must pass the protocol's baseline storyboard plus every [universal](/dist/docs/3.1.20/building/verification/validate-your-agent#storyboard-taxonomy) storyboard. `supported_protocols` uses snake\_case; compliance paths and specialism IDs use kebab-case. See [Naming conventions](#naming-conventions) below for the full mapping. | `supported_protocols` value | Compliance path | Purpose | | --------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy` | `protocols/media-buy/` | Campaign creation, package management, delivery optimization, conversion tracking | | `creative` | `protocols/creative/` | Creative asset management, format discovery, rendering | | `signals` | `protocols/signals/` | Audience signal discovery and activation | | `governance` | `protocols/governance/` | Property governance, brand standards, compliance | | `brand` | `protocols/brand/` | Brand identity, rights discovery, rights acquisition *— small protocol today, growing with rights licensing work; see `brand-rights` specialism.* | | `sponsored_intelligence` | `protocols/sponsored-intelligence/` | AI-mediated commerce and conversational sponsored experiences | | `measurement` | Preview / no stable baseline | Experimental 3.1 metric-catalog discovery. Agents implementing it must list `measurement.core` in `experimental_features`. | Support for the [compliance test controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) is declared via the `capabilities.compliance_testing` block on `get_adcp_capabilities`, not via `supported_protocols`. Compliance testing is an RPC surface for the test harness, not a functional protocol. An agent can claim multiple protocols — a full-stack media-buy platform might list `media_buy`, `creative`, and `signals`. The runner executes all matching baselines. ## Specialisms Specific capability claims. Each specialism lives under exactly one protocol. An agent claiming a specialism must pass the specialism's storyboard in addition to the parent protocol's baseline — e.g. claiming `sales-guaranteed` requires `media_buy` in `supported_protocols`. Specialisms carry a `status`: * **`stable`** — fully specified storyboard. Compliance runner executes every phase; `AAO Verified` means the agent demonstrably passed. * **`preview`** — ID and scope are reserved; the storyboard is a placeholder while the underlying protocol surface stabilizes. Agents may claim these; the runner emits a result of `{ status: "preview", passed: null, reason: "storyboard not yet defined" }` instead of a verified pass/fail. AAO badges render preview specialisms with a distinct indicator. * **`deprecated`** — retained for backward compatibility but scheduled for removal in a future major. Runner emits `{ status: "deprecated", passed: , reason: "..." }` — still executes the storyboard if one exists, but warns the claim should be migrated. Status is declared per-specialism in the YAML frontmatter and surfaced in `/compliance/{version}/index.json`. Specialisms are grouped below by parent protocol. **What changed in 3.0.** `sponsored_intelligence` was promoted from a specialism to a full protocol (declare it in `supported_protocols`, not `specialisms`). `audience-sync` moved from `governance` to `media-buy` to match its tool family. `broadcast-platform` was renamed to `sales-broadcast-tv` and `social-platform` to `sales-social`. `property-governance` and `collection-governance` split into sibling `property-lists` and `collection-lists` specialisms. ### media-buy | Specialism | Status | Purpose | | ------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sales-guaranteed` | stable | Guaranteed media buys with human IO approval | | `sales-non-guaranteed` | stable | Non-guaranteed auction-based media buys | | `sales-proposal-mode` | deprecated | **Deprecated in 3.1.** Drop this claim and replace with `sales-guaranteed` + `media_buy.supports_proposals: true`. See [#3823](https://github.com/adcontextprotocol/adcp/issues/3823). | | `sales-catalog-driven` | stable | Catalog-driven commerce with conversion tracking | | `sales-broadcast-tv` | stable | Broadcast linear TV with guaranteed inventory and FCC cancellation rules | | `sales-social` | stable | Social media advertising platform with self-service flows | | `governance-aware-seller` | stable | Seller composes with the buyer's campaign-governance agent after baseline registration — calls `check_governance` and propagates approvals, conditions, and denials unchanged. Optional claim for the full governance-check loop. | | `audience-sync` | stable | Syncs buyer-provided audience segments into a platform for activation (uses `sync_audiences`, `list_accounts`) | **Coming in 3.1.** `sales-streaming-tv` (CTV / streaming), `sales-exchange` (programmatic SSP / exchange), and `sales-retail-media` (retail media network) are scheduled for 3.1. Sellers in those categories should claim `sales-guaranteed` or `sales-non-guaranteed` at 3.0 GA. `audience-sync` moved from the `governance` protocol to `media-buy` to match its tool family. If your agent claims `audience-sync` but only declares `governance` in `supported_protocols`, add `media_buy` to `supported_protocols` — the runner now expects the media-buy baseline to run alongside the audience-sync storyboard. ### creative | Specialism | Status | Purpose | | --------------------- | ------ | ---------------------------------------------------- | | `creative-ad-server` | stable | Creative ad server with tag-based delivery | | `creative-generative` | stable | Generative creative agent producing assets on demand | | `creative-template` | stable | Creative template and transformation agent | ### signals | Specialism | Status | Purpose | | -------------------- | ------ | ----------------------------------------------------------------------------------------------------------------- | | `signal-owned` | stable | Owned signal agent exposing first-party segments through `get_signals`; activation is not required for this claim | | `signal-marketplace` | stable | Marketplace signal agent reselling third-party data; requires `get_signals` and `activate_signal` | ### governance | Specialism | Status | Purpose | | ----------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `content-standards` | stable | Content standards enforcement (brand safety, policy compliance) | | `property-lists` | stable | Property list governance — curated inclusion and exclusion lists for targeting and delivery compliance | | `collection-lists` | stable | Collection list governance — curated inclusion and exclusion lists of content programs (shows, series, podcasts) for program-level brand safety | | `governance-delivery-monitor` | stable | Campaign delivery monitoring with drift detection | | `governance-spend-authority` | stable | Conditional spend approval and human-in-the-loop governance | **Experimental in 3.1.** Measurement metric-catalog discovery is available through the experimental `measurement` capability block and `measurement.core` experimental feature. A stable `measurement-verification` specialism and baseline storyboard are deferred until the measurement task surface is frozen. ### brand | Specialism | Status | Purpose | | -------------- | ------ | ---------------------------------------------------------------- | | `brand-rights` | stable | Brand identity and rights licensing (talent, music, stock media) | ## Choosing a sales specialism The `sales-*` specialisms are not mutually exclusive — a hybrid platform with both a guaranteed direct desk and an auction floor should claim both `sales-guaranteed` and `sales-non-guaranteed`. Follow the steps below to resolve your claim. **`sales-proposal-mode` is deprecated in 3.1.** Do not claim it for new agents. Existing agents that declare it must drop it entirely and replace it with `sales-guaranteed` + `media_buy.supports_proposals: true` in `get_adcp_capabilities`. See [#3823](https://github.com/adcontextprotocol/adcp/issues/3823). Three specialisms apply to specific delivery channels and have their own storyboards. If you only sell one of these channel types, claim only the matching specialism. If you also sell general display or video inventory outside these channels, continue to Step 2. | If you operate… | Claim | | ----------------------------------------------------------------------------------------------- | ---------------------- | | Broadcast linear TV with FCC cancellation rules | `sales-broadcast-tv` | | Catalog-driven dynamic ads (product listings, restaurant menus, hotel listings, local commerce) | `sales-catalog-driven` | | Social platform with platform-managed creative | `sales-social` | | If you sell… | Claim | | --------------------------------------------- | ------------------------------------------- | | Guaranteed media (IO approval, fixed pricing) | `sales-guaranteed` → see Step 3 | | Auction / PMP non-guaranteed | `sales-non-guaranteed` | | Both guaranteed and non-guaranteed | `sales-guaranteed` + `sales-non-guaranteed` | `media_buy.supports_proposals` is a boolean in the `media_buy` capabilities block of your `get_adcp_capabilities` response. It gates whether the `proposal_finalize` compliance scenario runs. It is a conformance declaration, not the buyer's per-proposal routing signal: buyers decide whether a returned proposal can be bought from `proposal_status`. | If you… | Set | | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | Accept RFPs, generate proposals, and finalize draft proposals to committed status before create | `media_buy.supports_proposals: true` | | Sell direct-buy guaranteed only (auction PG, retail SKU, quoted-rate — no RFP flow) | `media_buy.supports_proposals: false` (or omit — default is `false`) | ```jsonc theme={null} // Full-service guaranteed seller — proposal lifecycle graded { "supported_protocols": ["media_buy"], "specialisms": ["sales-guaranteed"], "media_buy": { "supports_proposals": true } } ``` ```jsonc theme={null} // Direct-buy guaranteed seller — proposal scenario skipped as capability_unsupported { "supported_protocols": ["media_buy"], "specialisms": ["sales-guaranteed"], "media_buy": { "supports_proposals": false } } ``` ### creative | Specialism | Status | Purpose | | --------------------- | ------ | ---------------------------------------------------- | | `creative-ad-server` | stable | Creative ad server with tag-based delivery | | `creative-generative` | stable | Generative creative agent producing assets on demand | | `creative-template` | stable | Creative template and transformation agent | ### signals | Specialism | Status | Purpose | | -------------------- | ------ | ----------------------------------------------------------------------------------------------------------------- | | `signal-owned` | stable | Owned signal agent exposing first-party segments through `get_signals`; activation is not required for this claim | | `signal-marketplace` | stable | Marketplace signal agent reselling third-party data; requires `get_signals` and `activate_signal` | ### governance | Specialism | Status | Purpose | | ----------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `content-standards` | stable | Content standards enforcement (brand safety, policy compliance) | | `property-lists` | stable | Property list governance — curated inclusion and exclusion lists for targeting and delivery compliance | | `collection-lists` | stable | Collection list governance — curated inclusion and exclusion lists of content programs (shows, series, podcasts) for program-level brand safety | | `governance-delivery-monitor` | stable | Campaign delivery monitoring with drift detection | | `governance-spend-authority` | stable | Conditional spend approval and human-in-the-loop governance | **Experimental in 3.1.** Measurement metric-catalog discovery is available through the experimental `measurement` capability block and `measurement.core` experimental feature. A stable `measurement-verification` specialism and baseline storyboard are deferred until the measurement task surface is frozen. ### brand | Specialism | Status | Purpose | | -------------- | ------ | ---------------------------------------------------------------- | | `brand-rights` | stable | Brand identity and rights licensing (talent, music, stock media) | ### sponsored-intelligence | Specialism | Status | Purpose | | ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sponsored-intelligence` | preview | Agent claim for SDKs that dispatch on specialism ID. The graded storyboard is the `sponsored-intelligence` protocol baseline; this specialism reserves the wire ID and promotes to `stable` when the SI tools graduate from `x-status: experimental`. | ## Cross-resource invariants In addition to per-step validations, specialisms declare cross-step and cross-resource **invariants** the runner observes across the full storyboard run. These catch state inconsistencies that no single response shape would surface. | Invariant | Scope | Specialisms | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `status.monotonic` | Single-resource — rejects status transitions observed across steps that aren't on the spec lifecycle graph. | All specialisms with a stateful resource lifecycle. | | `impairment.coherence` | Cross-resource — verifies that `media_buy.impairments[]` stays in sync with referenced resources. **Forward**: every entry references a currently-offline resource. **Inverse**: any offline resource referenced by a non-terminal buy appears in `impairments[]`. **Health-iff**: on a non-terminal buy, `health == "impaired"` iff `impairments[]` is non-empty (strict iff — stale drift fails). Out of scope: all three rules relax on terminal-status buys (sellers MAY leave `impairments[]` and `health` in whatever state held at the terminal transition); materiality is schema-enforced via `package_ids: minItems: 1`. | `audience-sync`, `creative-ad-server`, `creative-template`, `creative-generative`, `sales-catalog-driven`. Driven by the `media_buy_seller/dependency_impairment` scenario (creative-track via `force_creative_status`); audience-track and catalog-track follow once the compliance test controller adds `force_audience_status` / `force_catalog_item_status`. Grades `not_applicable` on storyboards that don't observe both a resource transition and a media-buy snapshot read. | Invariants are declared in the specialism YAML's `invariants:` array and documented inline with the rule they enforce. See [media-buy lifecycle § Compliance](/dist/docs/3.1.20/media-buy/media-buys/lifecycle#compliance) for the full `impairment.coherence` contract. ## How to claim Declare your protocols and specialisms in `get_adcp_capabilities`: ```json theme={null} { "supported_protocols": ["media_buy", "creative"], "specialisms": ["sales-guaranteed", "creative-template"] } ``` The storyboard runner: 1. Runs every storyboard in `/compliance/{version}/universal/` 2. For each protocol in `supported_protocols`, runs the baseline at `/compliance/{version}/protocols/{protocol}/` (snake\_case → kebab-case) 3. Runs each claimed specialism's storyboard at `/compliance/{version}/specialisms/{id}/` 4. For `preview` specialisms, emits a warning instead of a pass/fail verdict — AAO Verified badges render preview specialisms with a distinct indicator **Implement the tools AND claim the specialism.** An agent that wires all of a specialism's required tools but omits the kebab-case ID from `capabilities.specialisms[]` will be graded **"No applicable tracks found"** by the runner — `tracks_passed = 0, tracks_failed = 0, tracks_skipped = 1`. This is a silent pass at the step level and a silent fail at the track level. The fix is to add the specialism ID (e.g., `"creative-generative"`) to your `get_adcp_capabilities` response. If any `stable` storyboard fails, your agent is not compliant for that claim. See [Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent) for how to run the suite locally. For a detailed walkthrough of how the runner resolves specialism manifests into graded scenarios — including how capability flags like `media_buy.supports_proposals` gate individual scenarios — see [How grading works](/dist/docs/3.1.20/building/verification/how-grading-works). ## Naming conventions Four casings coexist in the taxonomy. Which one applies depends on where the identifier is read: | Casing | Layer | Example | Where it appears | | ------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------ | | `snake_case` | Wire enums (`supported_protocols`, `delivery_type`, channel IDs, `signal_type`) | `media_buy`, `non_guaranteed`, `ctv`, `custom` | `get_adcp_capabilities` response, JSON payloads, generated schemas | | `kebab-case` | Specialism IDs and compliance URLs | `sales-broadcast-tv`, `property-lists`, `audience-sync` | `get_adcp_capabilities.specialisms`, `/compliance/.../specialisms/{id}/` paths | | `snake_case` | Storyboard `id:` and `category:` fields | `sales_broadcast_tv`, `audience_sync` | Compliance YAML frontmatter, runner output, test reports | | Prose / hyphenated | Titles and narrative | "Streaming TV", "non-guaranteed" | Catalog pages, narrative copy | The kebab↔snake swap between wire specialism IDs and storyboard categories is mechanical identity — hyphens become underscores, nothing more. Variant scenarios within a specialism use `{category}/{variant}` path form. | Specialism ID (wire) | Channel / tool family | Storyboard category | Variant scenarios | | ---------------------------- | -------------------------------------- | ---------------------------- | ----------------------------------- | | `sales-broadcast-tv` | `channels: ['linear_tv']` | `sales_broadcast_tv` | — | | `sales-social` | `channels: ['social']` | `sales_social` | — | | `audience-sync` | `sync_audiences` tool | `audience_sync` | — | | `property-lists` | `property_list` tools | `property_lists` | — | | `collection-lists` | `collection_list` tools | `collection_lists` | — | | `governance-spend-authority` | `check_governance`, `sync_plans` | `governance_spend_authority` | `governance_spend_authority/denied` | | `creative-generative` | `build_creative` | `creative_generative` | `creative_generative/seller` | | `brand-rights` | `get_brand_identity`, `acquire_rights` | `brand_rights` | `brand_rights/governance_denied` | The case split is deliberate: `supported_protocols` is a pre-existing 3.0 field already shipped to production agents, while specialism IDs are new and URL-first (each is a directory name under `/compliance/.../specialisms/{id}/`). The runner handles the mapping transparently. ### Specialism ↔ tool family mapping The protocol an agent claims does not always match the tool family name a specialism uses: * `audience-sync` lives under the `media-buy` protocol because `sync_audiences` is a media-buy tool. * `property-lists` (specialism ID, kebab-case) maps to the `property_list` tool family (`create_property_list`, `validate_property_delivery`) and storyboard category `property_lists`. * `sales-broadcast-tv` declares `channels: ['linear_tv']` — "Broadcast TV" is the prose name; `linear_tv` is the wire value. `/compliance/{version}/index.json` surfaces each specialism's `required_tools` so agents can discover the tool families without reading the full storyboard YAML. ### Wire enum vs prose Wire enum values are always `snake_case` (`non_guaranteed`, `pmax_platform`, `ctv`). Prose renders the same concept with hyphens or spaces ("non-guaranteed auction inventory", "Connected TV"). When populating a payload, always use the wire form — hyphenated or spaced spellings are editorial only and will fail schema validation. ### `signal_type` values The `signal_type` enum in signal responses has three values: * `marketplace` — the signal agent is reselling segments published by a third-party data provider (Experian, Peer39, etc.). Buyers can verify authorization via the provider's `/.well-known/adagents.json`. * `owned` — the signal agent exposes its own first-party segments derived from directly owned data (retailer purchase data, publisher behavioral data, telco location data). * `custom` — the signal source builds the segment on demand from models, composites, or buyer-supplied inputs. Use this when no `adagents.json` authorization chain applies — the segment is source-native, not attributable to a standing upstream provider. ## Source of truth The machine index is published alongside schemas: | Path | Contents | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/compliance/{version}/index.json` | Enumerated protocols + specialisms + universal storyboards + per-specialism `status` | | `/schemas/{version}/enums/specialism.json` | Specialism enum used by `get_adcp_capabilities.specialisms` | | `/schemas/{version}/enums/adcp-protocol.json` | Task-classification enum referenced by `tasks-list-request` and webhook payloads. Same axis as `supported_protocols` (kebab-case here, snake\_case on the wire). | The build pipeline verifies the specialism filesystem ↔ enum parity and that every specialism's parent protocol exists in the compliance tree. Drift fails the build. The catalog on this page is maintained by hand to give human context. The authoritative enumeration is always `/compliance/{version}/index.json`. **Building an agent that wraps an upstream platform?** Storyboards in this catalog grade the AdCP wire contract; they cannot detect adapters that return shape-valid responses without integrating with the upstream. See **[Validate adapter agents with mock upstream fixtures](/dist/docs/3.1.20/building/verification/validate-with-mock-fixtures)** for the complementary pre-staging gate. # Conformance Specification Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/conformance What 'AdCP-conformant' means, defined by the storyboards that verify it. Conformance is what the spec requires; verified is what the suite attests. **Status**: Request for Comments **Last Updated**: April 19, 2026 ## Two words, not three AdCP conformance has two load-bearing terms. A third (one you'll hear in the wild) is a trap. * **Conformant** — the agent meets the normative rules. Defined by the storyboards this document indexes. * **Verified** — AAO has tested the agent recently and issued a signed attestation. Gated on active membership and a live heartbeat. The [AAO Verified badge](/dist/docs/3.1.20/building/verification/aao-verified) carries one of two qualifiers: **(Spec)** for storyboard-conformance against a test deployment or dev endpoint, **(Sandbox)** for storyboard-conformance against the seller's real production endpoint under `account.sandbox: true` flagging. An agent can earn either or both. * **"Compliant"** — self-attested, unverified, no external check. Don't claim it; don't design for it. This document uses *conformant* and *verified* exclusively. Put differently: * Conformance is a property of the agent's wire behavior. * Verification is a time-bounded third-party attestation. **(Spec)** attests wire-format conformance against any registered endpoint; **(Sandbox)** attests the same storyboard suite passes against the seller's real production endpoint under sandbox-flagged traffic. Same storyboards, different attestation surface. * The two axes are independent: a seller without a separate test deployment can earn **(Sandbox)** directly on production; a test agent that can never serve real impressions earns **(Spec)** as a complete claim. ## Storyboard conformance vs. AAO Verified This page indexes **storyboard conformance** — the property an agent's wire behavior has when it matches the spec, verified by storyboards running against seeded test data. Storyboard passing earns the **AAO Verified (Spec)** or **AAO Verified (Sandbox)** qualifier (or both) on an agent's badge, depending on where the runner targeted. A second axis — **AAO Verified (Sandbox)** — verifies the seller's real production endpoint correctly handles the full storyboard suite under `account.sandbox: true` flagging. (Sandbox) is the stronger claim: a seller can pass (Spec) on a test deployment while their production stack has a broken sandbox gate (real-world side effects under flagged traffic, missing account-mode verification, etc.) — (Sandbox) closes that gap. The two qualifiers share one brand mark — **AAO Verified** — and an agent can earn either or both. **(Spec) and (Sandbox) are independent**: each independently demonstrates conformance through different evidence. (Spec) attests wire-format conformance against any registered endpoint; (Sandbox) attests the production code path correctly tolerates sandbox-flagged traffic. See [AAO Verified](/dist/docs/3.1.20/building/verification/aao-verified) for the qualifier model and the [Sandbox framing verdict](https://github.com/adcontextprotocol/adcp/issues/4379); the rest of this page indexes the storyboards that back both qualifiers. ## Test surfaces and the storyboard loop Every seller exposes a *test surface* — the mechanism that lets a storyboard runner exercise the seller's tools deterministically without triggering real-world side effects. The test surface is what (Spec) is graded against. How a seller stands up that surface depends on where their state-of-record lives; the implementation differs, the goal does not: | Where state-of-record lives | How the test loop closes | | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Local DB only (typically SSPs, creative agents) | The storyboard runner writes fixtures via `comply_test_controller.seed_*`; the seller's read handlers consume the same store. The seed → read loop closes naturally. | | Upstream system the seller does not control (DSPs proxying to platforms, retail-media networks reading retailer catalogs, signals brokers) | Seeded writes are dead to the read handler. The TypeScript SDK ships a `TestControllerBridge` that runs the real adapter call first (so a broken upstream call still fails the gate), then merges seeded fixtures into the response. | | Mixed (some tools local, some upstream) | Both, per tool. | Both paths earn `(Spec)` — both prove the seller's wire format matches the storyboards. The bridge is **one implementation** of the test-surface pattern, not a separate seller category. A state-local seller without wired seeds and an upstream-proxy seller without a wired bridge are in the same position: storyboards cannot run end-to-end against them. Neither category is what `(Sandbox)` attests; `(Sandbox)` is the separate axis covering whether the seller's production stack honors `account.sandbox: true` without real-world side effects. ### Distinguishing fixture-merged from upstream-derived responses When a response passes through the SDK's `TestControllerBridge`, the SDK stamps a `_bridge: { callback, tool, merged_count }` marker on the response. Marker presence on a step means the response content was merged from a seeded fixture after the seller's handler returned; marker absence means the response came from the seller's adapter end-to-end (or from a local DB the runner seeded directly). The marker is advisory metadata for runners and downstream leaderboards — it is **not** part of the wire contract. Sellers MUST NOT emit it, and conformance checks ignore it. The leading underscore marks the field as SDK/runner-stamped metadata reserved for testing tooling; future fields with the same prefix follow the same rule. Marker design: [`adcp-client#1775`](https://github.com/adcontextprotocol/adcp-client/issues/1775). Shipped: [`adcp-client#1786`](https://github.com/adcontextprotocol/adcp-client/pull/1786). Leaderboard policy that consumes the marker: [`adcp-client#1782`](https://github.com/adcontextprotocol/adcp-client/issues/1782). ### Three signals — don't conflate them Adopters often read these three controls as the same thing. They answer different questions: | Signal | Question it answers | | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Test controller availability (`comply_test_controller` in `tools/list`) | "Has the seller exposed deterministic-mode forces?" | | Sandbox flag (`account.sandbox` on requests) | "Is the targeted account a sandbox account, with no real-world side effects?" | | Bridge participation (`_bridge` marker on a response) | "Did this response come from the adapter's upstream call, or from a fixture the SDK merged in?" | These are **runtime controls** on individual storyboard steps — distinct from the `(Spec)` and `(Sandbox)` verification qualifiers, which describe what a storyboard pass *attests* over time. A storyboard pass can carry any combination of the three signals. ## Storyboards are the truth Rather than restate every MUST in prose — which would inevitably drift from the executable suite — **the storyboards ARE the conformance specification.** This document is a navigational index to them, grouped by the declaration that obligates the storyboard to run. Every normative rule in the suite has exactly one home: the storyboard YAML at [`/compliance/latest/`](https://adcontextprotocol.org/compliance/latest/). Changes to what "conformant" means happen there, in a versioned release, tested against real agents. If a rule isn't in a storyboard, it's not part of conformance. This is deliberate. A separate prose spec that restates storyboard rules creates two sources of truth. Two sources of truth drift. We pick one: the suite. The `@adcp/sdk` package also ships TypeScript files under `testing/scenarios/` that pre-date storyboard-driven `comply()`. They are **not** the conformance spec — see [Storyboards vs. scenarios](/dist/docs/3.1.20/building/verification/storyboards-vs-scenarios) for which is which. The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in the referenced storyboards and prose sections below are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). ## Conformance is layered Every agent satisfies the universal layer. Each `supported_protocols` claim adds a protocol baseline. Each `specialisms` claim adds a specialism baseline. | Layer | Obligation | Path | | -------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | **Universal** | Every AdCP agent | [`/compliance/latest/universal/`](https://adcontextprotocol.org/compliance/latest/universal/) | | **Protocol** | Agent claiming a `supported_protocols` value | [`/compliance/latest/protocols/{protocol}/`](https://adcontextprotocol.org/compliance/latest/protocols/) | | **Specialism** | Agent claiming a `specialisms` value | [`/compliance/latest/specialisms/{id}/`](https://adcontextprotocol.org/compliance/latest/specialisms/) | Agents MUST NOT declare a capability whose storyboards they do not pass. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for the full taxonomy and [Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent) for how to run the suite locally. ## Universal conformance Every agent MUST pass every storyboard below. | Storyboard | What it verifies | | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`capability_discovery`](https://adcontextprotocol.org/compliance/latest/universal/capability-discovery) | `get_adcp_capabilities` shape, protocol/specialism declarations, version advertising | | [`comply_controller_mode_gate`](https://adcontextprotocol.org/compliance/latest/universal/comply-controller-mode-gate) | Sandbox/live isolation for sellers that expose `comply_test_controller` on a shared endpoint — live-mode principals must be refused before scenario dispatch | | [`schema_validation`](https://adcontextprotocol.org/compliance/latest/universal/schema-validation) | Request and response schema conformance, ISO 8601 timestamps, temporal invariants | | [`schema_validation_signals`](https://adcontextprotocol.org/compliance/latest/universal/schema-validation-signals) | Response schema compliance for signals — required fields on every signal; gated on `get_signals` | | [`version_negotiation`](https://adcontextprotocol.org/compliance/latest/universal/version-negotiation) | Release-precision `adcp.supported_versions` advertisement and response-envelope `adcp_version` echo; advisory in 3.1, promoted in later cuts | | [`v3_envelope_integrity`](https://adcontextprotocol.org/compliance/latest/universal/v3-envelope-integrity) | v3 protocol envelopes MUST NOT carry the v2 legacy `task_status` or `response_status` fields — `status` is the single canonical lifecycle field in v3 | | [`error_compliance`](https://adcontextprotocol.org/compliance/latest/universal/error-compliance) | Structured error shape, published error codes, transport binding, no existence leaks across tenants | | [`error_compliance_signals`](https://adcontextprotocol.org/compliance/latest/universal/error-compliance-signals) | Error handling for signals protocol — nonexistent signal IDs, missing fields, VERSION\_UNSUPPORTED, transport binding; discovery phases are gated on `get_signals`; activation phases run only for agents that claim activation support such as `signal-marketplace` | | [`stale_response_advisory`](https://adcontextprotocol.org/compliance/latest/universal/stale-response-advisory) | `STALE_RESPONSE` wire placement — advisory rides in `errors[]` on a populated success response with transport success preserved; stale-cache forcing steps gated on `comply_test_controller` with `force_upstream_unavailable` | | [`idempotency`](https://adcontextprotocol.org/compliance/latest/universal/idempotency) | `idempotency_key` scoping, replay semantics, `IDEMPOTENCY_CONFLICT`, `replayed: true`, declared TTL | | [`read_tool_idempotency`](https://adcontextprotocol.org/compliance/latest/universal/read-tool-idempotency) | Read-only task wrappers accept the 3.1 every-request `idempotency_key` envelope without strict request-wrapper rejection; includes the 3.1 omitted-key grace probe | | [`canonical_format_validate_input`](https://adcontextprotocol.org/compliance/latest/universal/canonical-format-validate-input) | Canonical-format `validate_input` result semantics: structural pass/fail across required slots and `unvalidatable_nondeterministic` for seeded product declarations. Gated on agents that advertise `validate_input`; the seeded-product branch also requires `comply_test_controller` seeding support. | | [`security_baseline`](https://adcontextprotocol.org/compliance/latest/universal/security) | Unauth rejection, static-credential enforcement (Bearer API key or HTTP Basic), OAuth discovery + RFC 9728 audience binding | | [`webhook_emission`](https://adcontextprotocol.org/compliance/latest/universal/webhook-emission) | Outbound webhook conformance — stable `idempotency_key` across retries, RFC 9421 webhook signing (or opt-in HMAC fallback) on every delivery. Runs for any agent accepting `push_notification_config`. | | [`webhook_receiver_envelope`](https://adcontextprotocol.org/compliance/latest/universal/webhook-receiver-envelope) | Buyer receiver replay conformance: accepts full MCP webhook envelopes, rejects bare delivery-result payloads, verifies signatures over raw body bytes, and dedupes retries by `idempotency_key`. | | [`notification_config_event_scope`](https://adcontextprotocol.org/compliance/latest/universal/notification-config-event-scope) | `sync_accounts.accounts[].notification_configs[]` semantic validation — account-level subscribers reject media-buy-anchored notification types even though those values are valid in the shared enum | | [`notification_config_lifecycle`](https://adcontextprotocol.org/compliance/latest/universal/notification-config-lifecycle) | Account-level `notification_configs[]` lifecycle on `sync_accounts`: paused registration, durable `list_accounts` echo, subscriber-keyed replacement, and clear-all semantics | | [`notification_config_rejections`](https://adcontextprotocol.org/compliance/latest/universal/notification-config-rejections) | Semantic `notification_configs[]` request rejection path for duplicate `subscriber_id` values | | [`wholesale_feed_products`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-products) | Product wholesale feed versioning: bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without product rows | | [`wholesale_feed_signals`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-signals) | Signals wholesale feed versioning: bootstrap responses carry `wholesale_feed_version`/`cache_scope`, and matching `if_wholesale_feed_version` probes return `unchanged` without signal rows | | [`wholesale_feed_product_webhooks`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-product-webhooks) | Account-level `notification_configs[]` registration for agents that advertise product wholesale feed webhook events | | [`wholesale_feed_signal_webhooks`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-signal-webhooks) | Account-level `notification_configs[]` registration for agents that advertise signal wholesale feed webhook events | | [`wholesale_feed_bulk_webhooks`](https://adcontextprotocol.org/compliance/latest/universal/wholesale-feed-bulk-webhooks) | Account-level `notification_configs[]` registration for agents that advertise `wholesale_feed.bulk_change` | | [`pagination_integrity`](https://adcontextprotocol.org/compliance/latest/universal/pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `list_creatives` responses, walked from a continuation page through to a terminal page | | [`get_products_pagination_integrity`](https://adcontextprotocol.org/compliance/latest/universal/get-products-pagination-integrity) | `get_products` wholesale pagination semantics: walks a seeded product feed from continuation to terminal while preserving brief/refine as capped curated/refined answers, not feed enumeration | | [`get_signals_pagination_integrity`](https://adcontextprotocol.org/compliance/latest/universal/get-signals-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `get_signals` responses under a broad query, with first-page non-terminal assertion against any non-trivial signal set | | [`pagination_integrity_list_accounts`](https://adcontextprotocol.org/compliance/latest/universal/pagination-integrity-list-accounts) | Continuation-side pagination integrity on `list_accounts` responses; storyboard bootstraps three sandbox accounts via `sync_accounts`, requires `has_more=true` with a usable cursor on the first page, and follows that cursor once | | [`pagination_integrity_creative_formats`](https://adcontextprotocol.org/compliance/latest/universal/pagination-integrity-creative-formats) | `cursor` ↔ `has_more` invariant on paginated `list_creative_formats` responses; storyboard seeds two creative formats via `seed_creative_format` and walks from a continuation page to a terminal page | | [`get_media_buys_pagination_integrity`](https://adcontextprotocol.org/compliance/latest/universal/get-media-buys-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `get_media_buys` responses; storyboard seeds three media buys via `seed_media_buy` and walks from a continuation page to a terminal page | | [`pagination_integrity_content_standards`](https://adcontextprotocol.org/compliance/latest/universal/content-standards-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `list_content_standards` responses; storyboard bootstraps three content standards configurations via `create_content_standards` and walks from a continuation page to a terminal page | | [`pagination_integrity_collection_lists`](https://adcontextprotocol.org/compliance/latest/universal/collection-lists-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `list_collection_lists` responses; storyboard bootstraps three collection lists via `create_collection_list` and walks from a continuation page to a terminal page | | [`pagination_integrity_property_lists`](https://adcontextprotocol.org/compliance/latest/universal/property-lists-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `list_property_lists` responses; storyboard bootstraps three property lists via `create_property_list` and walks from a continuation page to a terminal page | | [`deterministic_testing`](https://adcontextprotocol.org/compliance/latest/universal/deterministic-testing) | `comply_test_controller` state machine — skipped if `capabilities.compliance_testing.supported: false` | | [`signed_requests`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests) | RFC 9421 transport-layer request-signing verification — skipped if `request_signing.supported: false`. | | [`billing_gate_dispatch`](https://adcontextprotocol.org/compliance/latest/universal/billing-gate-dispatch) | Two-gate dispatch on `sync_accounts.billing` rejection: seller-wide capability gate (`BILLING_NOT_SUPPORTED` with `error.details.scope`) vs per-buyer-agent commercial-relationship gate (`BILLING_NOT_PERMITTED_FOR_AGENT` with the clamped `rejected_billing` + optional `suggested_billing` shape). Capability phase skipped when the seller supports all three `billing` values; per-agent phases skipped when the test kit does not declare `commercial_relationship: passthrough_only`. | Agents that declare `capabilities.compliance_testing.supported: true` MUST implement the full [test controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller); a partial controller is non-conformant, so declare `false` rather than ship one. Agents that declare `request_signing.supported: true` MUST implement the full RFC 9421 verifier per the [request-signing profile](/dist/docs/3.1.20/building/by-layer/L1/security#signed-requests-transport-layer); a partial verifier is non-conformant, so declare `false` rather than ship one. ## Protocol conformance A `supported_protocols` claim obligates the protocol's baseline storyboard. | `supported_protocols` | Storyboard | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy` | [`media_buy_seller`](https://adcontextprotocol.org/compliance/latest/protocols/media-buy/) + [`media_buy_state_machine`](https://adcontextprotocol.org/compliance/latest/protocols/media-buy/state-machine) | | `creative` | [`creative_lifecycle`](https://adcontextprotocol.org/compliance/latest/protocols/creative/) | | `signals` | [`signals_baseline`](https://adcontextprotocol.org/compliance/latest/protocols/signals/) | | `governance` | [`media_buy_governance_escalation`](https://adcontextprotocol.org/compliance/latest/protocols/governance/) | | `brand` | [`brand_baseline`](https://adcontextprotocol.org/compliance/latest/protocols/brand/) | | `sponsored_intelligence` | [`si_baseline`](https://adcontextprotocol.org/compliance/latest/protocols/sponsored-intelligence/) | ## Specialism conformance A `specialisms` claim obligates the specialism's storyboard in addition to its parent protocol baseline. The catalog lives at [`/compliance/latest/index.json`](https://adcontextprotocol.org/compliance/latest/index.json); the human-readable index is the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog). Specialisms carry a `status` — `stable` (verified pass/fail), `preview` (storyboard not yet defined; runner emits `passed: null`), `deprecated` (scheduled for removal). Agents MAY claim preview specialisms, but preview claims do not yield a pass/fail verdict. ## Outside the wire Some requirements can't be verified by a storyboard because they're operator-level, not wire-level. They remain part of running a conformant agent, but the suite can't attest to them. Operators MUST self-assess against these; third-party frameworks (SOC 2, ISO 27001) are the usual attestation path. * **Secret storage** — credentials SHOULD live in a KMS or equivalent. The wire shows only whether auth succeeds, not where the key was stored. * **Credential rotation and revocation** — the operator MUST have a documented path to revoke a compromised credential in under an hour. The wire can't observe the runbook. * **Personnel and physical security** — who can touch production, break-glass custody, employee offboarding. Entirely outside the protocol. * **Governance agent due diligence** — when the operator relies on a third-party governance agent, the buyer SHOULD treat it as a processor with multi-customer blast radius and assess its posture. The storyboards verify correct JWS handling by the seller but cannot vouch for the governance agent itself. * **LLM subprocessor posture** — if the agent uses an LLM provider, the DPA with that provider governs whether prompts, brand assets, or creative metadata may be retained. The protocol can't see upstream DPA terms. * **Incident response** — AdCP emits the signals worth watching (`IDEMPOTENCY_CONFLICT` spikes, failed governance verifications, SSRF rejections); detection, alert routing, and response are operator concerns. * **Data residency configuration** — whether and how EU / UK data is kept in-region is typically declared in the agent's capabilities or contract; the wire records the declaration, not the underlying infrastructure. Full operator checklist: [Security Model § What to verify before going live](/dist/docs/3.1.20/building/concepts/security-model#what-to-verify-before-going-live). ## Conformance vs external assurance Conformance is wire-level correctness. SOC 2, ISO 27001, and NIST CSF are operational assurance. They answer different questions and neither substitutes for the other. | External control area | Storyboard evidence | Gap to external assurance | | ----------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------ | | Access control (SOC 2 CC6, ISO 27001 A.5.15) | `security_baseline` (identity) + isolation checks in protocol storyboards | Personnel access reviews, least-privilege admin, offboarding | | Change management (SOC 2 CC8) | `idempotency` proves duplicate state changes are prevented on the wire | Deployment approvals, release gates, rollback procedures | | System monitoring (SOC 2 CC7, ISO 27001 A.8.16) | Error taxonomy produces a monitorable surface | Detection engineering, alert routing, on-call runbooks | | Cryptography (ISO 27001 A.8.24) | TLS, RFC 9421 signing, JWS governance tokens | KMS selection, rotation cadence, cert lifecycle | | Audit logging (SOC 2 CC7) | Governance storyboards verify signed-record issuance | Log retention, legal hold, integrity monitoring | | Data handling (SOC 2 Privacy, GDPR, ISO 27701) | TMP two-call separation, audience hashing, signal access control | Data subject rights, DPA management, cross-border transfer | | Vendor and subprocessor risk (SOC 2 CC9) | `adagents.json` / brand.json discovery, JWKS publication | Third-party risk assessment, LLM provider review | | Incident response (SOC 2 CC7, NIST CSF RS) | Signals observable; response not mandated | Runbooks, tabletop exercises, breach notification | | Business continuity (ISO 27001 A.5.30) | Cross-instance state storyboard checks | RPO/RTO targets, DR testing | Two practical consequences: 1. Storyboard pass evidence MAY support specific external control objectives. It is not a substitute for an audit. 2. External certification does not imply AdCP conformance. SOC 2 Type II says nothing about whether `create_media_buy` responses validate. ## How to claim conformance 1. Declare `supported_protocols` and `specialisms` in `get_adcp_capabilities`. 2. Pass every storyboard the declaration obligates — universal + protocol baselines + specialism baselines — at a specific AdCP major version. 3. Keep declaration and behavior in sync. An undeclared capability the suite happens to test is separate from a declared capability that fails. Both are non-conformant. Conformance is per-version; the suite is per-version. A 3.0-conformant agent is not thereby 3.1-conformant. **For third-party attestation**, run the heartbeat against AAO and earn an [AAO Verified badge](/dist/docs/3.1.20/building/verification/compliance-catalog). The badge is a signed claim that AAO tested the agent recently and the pass still holds. Buyers filtering on *verified* get a smaller set than *conformant* — fewer agents, fresher attestation, a named party on the hook. ## What this document does not do * **Define individual MUSTs.** The storyboards do. If a rule isn't in a storyboard, it isn't part of conformance. * **Grant or revoke certification.** The [AgenticAdvertising.org certification program](/dist/docs/3.1.20/learning/overview) runs on top of this; conformance is necessary but not sufficient. * **Publish reference test vectors beyond those already in the suite.** The [Reference Test Vectors index](/dist/docs/3.1.20/reference/test-vectors) catalogs the vector sets that ship today; broader task-level corpus lands incrementally between 3.0 GA and 3.1, scoped in [#2383](https://github.com/adcontextprotocol/adcp/issues/2383). ## When a storyboard fails When a failure surfaces a disagreement between the spec, the mock, and an SDK, the section below gives the triage order. For symptom-to-cause lookup, see the links at the end of this section. ### Mock-server authority and failure triage The `adcp mock-server` is the reference wire implementation for stable surfaces. Use this triage order when a storyboard failure implicates the mock or an SDK: **Triage order: spec → mock → SDK.** The storyboards (and the schemas they reference) are canonical. The mock interprets the storyboards. The SDK consumes the protocol via the mock. | Condition | Default verdict | Next step | | ---------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------- | | SDK wire shape **differs** from the mock's | SDK is wrong | File a bug against the SDK | | SDK wire shape **matches** the mock's, but a storyboard still fails | Mock is wrong | File an issue against `adcontextprotocol/adcp` to fix the mock | | Storyboard assertion conflicts with an otherwise-passing wire shape | Storyboard is wrong | File an issue against `adcontextprotocol/adcp` to fix the storyboard | | Spec text (storyboard prose or schema) explicitly contradicts the mock | Spec wins; mock is the bug | File an issue against `adcontextprotocol/adcp` to fix the mock | **Scope.** This triage order applies to stable surfaces only. Experimental surfaces (see [Experimental Status](/dist/docs/3.1.20/reference/experimental-status)) are under active revision; mock behavior there is not yet authoritative. **Spec ambiguity vs. spec silence.** When spec text exists but is ambiguous, the mock's behavior pins the authoritative interpretation — that pinning is normative even if the prose has not yet been tightened. When the spec is entirely silent on a point and the mock has no behavior for it, the chain breaks; open a [known-ambiguities issue](/dist/docs/3.1.20/building/cross-cutting/known-ambiguities) instead of treating the mock as authoritative. * **[Storyboard troubleshooting](/dist/docs/3.1.20/building/operating/storyboard-troubleshooting)** — Error pattern → root cause → fix for the most common storyboard failures * **[Known spec ambiguities](/dist/docs/3.1.20/building/cross-cutting/known-ambiguities)** — Open spec gaps with workarounds and issue links; entries are removed as underlying issues close ## Further reading * **[AAO Verified](/dist/docs/3.1.20/building/verification/aao-verified)** — Continuous-observability verification of the seller's live ad-server integration * **[Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog)** — Full taxonomy of protocols and specialisms with storyboard IDs * **[Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** — How to run the suite * **[Security Model](/dist/docs/3.1.20/building/concepts/security-model)** — Strategic framing for the five defense layers that the security storyboards enforce * **[Security (implementation reference)](/dist/docs/3.1.20/building/by-layer/L1/security)** — Normative rules cited by the storyboards * **[Versioning](/dist/docs/3.1.20/reference/versioning)** — Major-version support windows * **[Known Limitations](/dist/docs/3.1.20/reference/known-limitations)** — Visible edges of the specification # Get Test-Ready Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/get-test-ready What a sales agent operator must have in place before running storyboards — capabilities, sandbox accounts, and the compliance test controller. Storyboards are the versioned buyer-simulation suite that decides whether your agent is published as **conformant**. Buyer agents filter on that status — overclaiming or failing storyboards is a public, permanent signal, not a CI warning. This page is the checklist between "I built an agent" and "I can run `npx @adcp/sdk@latest storyboard run`." ## The three surfaces the runner needs The runner drives your agent through the same public tools a buyer would call, plus one sandbox-only tool for fixture setup. Three surfaces must be in place: | Surface | What it tells the runner | Where it lives | | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------- | | [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) | Which protocols and specialisms you claim, and that you support sandbox | Your agent's capability response | | [`sync_accounts`](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox) (or `list_accounts`) | How to obtain a sandbox account to run tests against | Your agent's account tool | | [`comply_test_controller`](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) | How to seed fixtures and force seller-side transitions deterministically | Your agent, sandbox-only | You ship these three surfaces. The runner owns storyboard selection, fixture ordering, and response comparison. ## Step 1 — Declare capabilities honestly `get_adcp_capabilities` is how the runner picks which storyboards apply to you. It is also the [conformance](/dist/docs/3.1.20/building/verification/conformance) contract: you are promising to pass every storyboard that matches what you declare. The example below is for a full-service guaranteed seller (proposal lifecycle enabled). A direct-buy guaranteed seller would set `media_buy.supports_proposals: false` (or omit it). A broadcast-TV seller would claim `sales-broadcast-tv`; a creative-only agent would claim the `creative` protocol with `creative-ad-server` or `creative-generative` specialisms; a signals provider would claim `signals`. The pattern is the same: declare only what you actually implement. ```json theme={null} { "supported_protocols": ["media_buy", "creative"], "specialisms": ["sales-guaranteed"], "media_buy": { "supports_proposals": true }, "account": { "sandbox": true, "require_operator_auth": false } } ``` * **`supported_protocols`** — Pulls in the matching protocol storyboards from `/compliance/{version}/protocols/`. * **`specialisms`** — Pulls in opt-in specialism storyboards (see the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for the full enumeration). * **`account.sandbox: true`** — Signals that you honor sandbox semantics (no real spend, no production side effects). * **`account.require_operator_auth`** — Determines your sandbox bootstrap path (step 2). Claiming `sales-guaranteed` when you only run RTB ships you into storyboards you will fail on record. Conformance status is part of the [Verified](/dist/docs/3.1.20/building/verification/conformance) badge buyer agents use to filter sellers — overclaim once, lose inclusion everywhere. ## Step 2 — Pick your sandbox bootstrap path The runner must obtain a sandbox account before it can do anything. Your `require_operator_auth` flag chooses the path: **Buyer-declared accounts (`require_operator_auth: false`).** Your agent accepts `sync_accounts` from any authenticated buyer. The runner calls `sync_accounts` with `sandbox: true` to mint a test account on demand. Most new sales agents start here. **Account-id namespaces (`require_operator_auth: true`).** Accounts must be pre-provisioned by a human on your side or by an upstream platform. The runner calls `list_accounts` with a sandbox filter to discover pre-existing test accounts. If your credential is bound to exactly one sandbox account, return that singleton. Publish a short note telling operators how to request one — include the contact, the expected turnaround, and what credentials they'll receive. Full details and examples: [Sandbox mode](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox). ## Step 3 — Implement the compliance test controller Without a compliance test controller, the runner tests only buyer-initiated flows (**observational mode**) — schema conformance, auth rejection, happy-path buyer calls. That is enough for early integration work and production-path sandbox smoke tests, but it produces **partial coverage** whenever controller-seeded or controller-forced scenarios are in scope. [Conformance](/dist/docs/3.1.20/building/verification/conformance) treats **deterministic mode** — full lifecycle walks enabled by the controller — as the bar for complete specialism coverage. In practice, sellers usually run both: | Run | Endpoint | Expected result | | ------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Observational sandbox run | Production endpoint with sandbox accounts, no controller exposed | `steps_failed = 0`; live-only probes appear as not selected, and selected controller-dependent scenarios may make the summary `partial` with `missing_test_controller` skips | | Deterministic run | Dev/staging or sandbox-only endpoint with `comply_test_controller` | `steps_failed = 0` and no controller-dependent coverage skips | The first run answers "does the production buyer-visible path tolerate sandbox traffic?" The second answers "can the runner prove every lifecycle path for the declared specialisms?" Do not collapse those into one signal. A zero-failure partial run is useful, but buyers need the skip list to know exactly which lifecycle assertions were not graded. `comply_test_controller` is a single sandbox-only tool with a `scenario` parameter covering three families: | Scenario family | What it does | When you need it | | --------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `seed_*` | Create fixtures (products, pricing options, creatives, plans, media buys) with caller-supplied IDs | Almost every storyboard — this replaces hardcoded-ID discovery | | `force_*` | Drive entities through state transitions that are normally seller-initiated | Any storyboard that tests a state machine (creative approval, account suspension, etc.) | | `simulate_*` | Inject delivery data or budget spend | Reporting and budgeting storyboards | See the [Compliance test controller reference](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) for scenario-by-scenario parameters and response shapes, and the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for which scenarios each specialism requires. ### Wiring the SDK scaffold `@adcp/sdk` (6.x is the production GA on AdCP 3.0) ships `createComplyController` so you wire your data layer to the controller without reimplementing tool registration, param validation, error envelopes, or re-seed idempotency. ```bash theme={null} npm install @adcp/sdk ``` The scaffold is TypeScript/JavaScript. Python, Go, and Java sellers implement the tool directly against the [schema](https://adcontextprotocol.org/schemas/3.1.20/compliance/comply-test-controller-request.json) — the contract below (adapters, error codes, idempotency semantics) applies the same way. SDKs for other languages are tracked in [Choose your SDK](/dist/docs/3.1.20/building/by-layer/L4/choose-your-sdk). ```ts theme={null} import { createComplyController, TestControllerError } from '@adcp/sdk/testing'; // `server` is your AdcpServer or MCP server instance — see `createAdcpServer` in // `@adcp/sdk/server` if you need a reference setup. const controller = createComplyController({ seed: { product: async ({ product_id, fixture }) => { await productRepo.upsert(product_id, fixture); }, creative: async ({ creative_id, fixture }) => { await creativeRepo.upsert(creative_id, fixture); }, // Add pricing_option, plan, media_buy as your claimed storyboards require. }, force: { creative_status: async ({ creative_id, status, rejection_reason }) => { const previous = await creativeRepo.getStatus(creative_id); if (previous == null) { throw new TestControllerError('NOT_FOUND', `creative ${creative_id} not found`); } const result = await creativeRepo.transition(creative_id, status, rejection_reason); if (result.kind === 'invalid_transition') { throw new TestControllerError('INVALID_TRANSITION', result.message, previous); } return { success: true, previous_state: previous, current_state: result.status }; }, // Add account_status, media_buy_status, session_status as needed. }, // simulate: { delivery, budget_spend } — add if you claim reporting/budget specialisms. }); // Primary gate: register the tool only in sandbox deployments, so it never // appears in production `tools/list`. if (process.env.ADCP_SANDBOX === '1') { controller.register(server); } ``` What the scaffold handles for you: * **Tool registration and schema.** `controller.toolDefinition` stays in sync with the published spec version. * **Dispatch and `UNKNOWN_SCENARIO`.** Scenarios you do not register return `UNKNOWN_SCENARIO` automatically — never a schema error. * **Param validation.** Invalid params produce `INVALID_PARAMS` with a readable `error_detail` without reaching your adapter. * **Seed idempotency.** Calling `seed_product` twice with the same `product_id` and an equivalent `fixture` returns `previous_state: "existing"`; a divergent `fixture` returns `INVALID_PARAMS`. Your adapter is only invoked on the first seed. * **Typed error envelopes.** Throw `TestControllerError(code, message, currentState?)` with `code` from the controller error-code table. Common adapter codes are `'INVALID_TRANSITION' | 'NOT_FOUND' | 'FORBIDDEN' | 'INVALID_PARAMS'`; digest-mode `query_upstream_traffic` implementations may also return `JCS_NON_FINITE_NUMBER` when RFC 8785/JCS canonicalization encounters a non-finite numeric value. The scaffold does **not** own the state machine. Transition rules live in your adapters, so compliance testing and production share one source of truth — the mechanic the [anti-teach-to-test section](#avoiding-the-teach-to-test-trap) depends on. ### Two layers of sandbox gating The scaffold supports two gates. Ship both in any deployment that serves both sandbox and production traffic from the same process: 1. **Registration gate (primary).** Wrap `controller.register(server)` in an environment check. This is what keeps `comply_test_controller` out of production `tools/list` entirely. Without it, a leaked sandbox credential on a production endpoint exposes seller-side state-forcing. 2. **Per-request gate (defense-in-depth).** Pass a `sandboxGate: (input) => boolean` to `createComplyController`. The scaffold calls it on every request and returns `FORBIDDEN` when it returns `false`. Use this on shared-process deployments where the tool IS registered but some requests might still reference a production account. `sandboxGate` receives the raw tool input (`Record`). The SDK does not plumb auth context onto it — you decide what to inspect. A typical pattern is to pull the referenced entity ID out of `params` and verify it belongs to a sandbox account in your own data layer: ```ts theme={null} sandboxGate: async (input) => { const params = input.params as { account_id?: string; media_buy_id?: string } | undefined; const accountRef = params?.account_id ?? (params?.media_buy_id && await mediaBuyRepo.getAccountId(params.media_buy_id)); return typeof accountRef === 'string' && await accountRepo.isSandbox(accountRef); } ``` For custom MCP wrappers — AsyncLocalStorage for per-request auth, transport-level sandbox gating, session-backed stores — compose the lower-level `handleTestControllerRequest`, `toMcpResponse`, and `TOOL_INPUT_SHAPE` from `@adcp/sdk/server` directly. ## Step 4 — Run the storyboard runner Once the three surfaces are in place, the runner takes over: ```bash theme={null} npx @adcp/sdk@latest --save-auth my-agent http://localhost:3001/mcp npx @adcp/sdk@latest storyboard run my-agent ``` The runner discovers your capabilities, obtains a sandbox account, seeds fixtures via the controller, and walks each matching storyboard. See [Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent) for the full CLI, debug flags, and Addie workflows. ## Avoiding the teach-to-test trap Storyboards hardcode fixture IDs — `"test-product"`, `"campaign_hero_video"`, `"acmeoutdoor.example"`. A controller that special-cases those strings passes the suite while silently failing on every real buyer. That is the exact industry cost conformance is trying to prevent: every post-conformance integration failure burns seller reputation, inflates buyer agent skepticism, and slows protocol adoption. The SDK scaffold already points you in the right direction: adapters receive `product_id`, `creative_id`, etc. as values, not as conditions. If your adapter contains a switch on `product_id === "test-product"`, you have regressed. Two rules of thumb: 1. **Implement seed scenarios generically.** `seed_product` accepts any `product_id` and persists a product with that ID in your sandbox data layer. Your adapter is a thin wrapper over a real upsert against your sandbox store. 2. **The `fixture` object is the contract, the ID is not.** Storyboard authors set `fixture` to the minimum shape the test needs. Everything beyond that — discovery, filtering, authorization — is your normal code path, exercised on fixture-seeded data the same way it runs on production data. To check: swap a storyboard's fixture IDs for random UUIDs and rerun. If the run still passes, your controller is correct. If it breaks, you have hardcoded behavior to fix. ## Readiness checklist Before your first full storyboard sweep: * [ ] `get_adcp_capabilities` returns only protocols and specialisms you actually implement * [ ] `account.sandbox: true` is declared and honored — sandbox requests produce no real spend, no production platform calls, no persisted production state * [ ] `sync_accounts` (implicit) or `list_accounts` (explicit) handles sandbox requests per step 2 * [ ] `comply_test_controller` is absent from `tools/list` on any production endpoint * [ ] Requests that reference a non-sandbox account are rejected with `FORBIDDEN` * [ ] Every seed scenario your claimed storyboards depend on persists fixtures generically, with no ID special-cases * [ ] Every force scenario uses the same state-transition rules as production, returning typed errors on invalid transitions * [ ] A full storyboard sweep still passes when fixture IDs are swapped for random UUIDs ## What's next * **[Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** — CLI, Addie workflows, and multi-instance verification * **[Compliance test controller reference](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller)** — Full scenario-by-scenario spec * **[Sandbox mode](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox)** — The two account model paths in depth * **[Conformance](/dist/docs/3.1.20/building/verification/conformance)** — What "conformant" and "verified" mean once your runs pass # Auth Graders Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/grading AdCP CLI graders for RFC 9421 request-signing conformance, OAuth handshake diagnosis, and Ed25519/P-256 signing key generation and verification. `@adcp/sdk` 5.21+ ships CLI graders for authentication conformance. They are separate from the [compliance storyboards](/dist/docs/3.1.20/building/verification/validate-your-agent) — storyboards test protocol behavior end-to-end; these graders test the authentication and signing layer specifically, giving per-vector diagnostics and hypothesis-ranked failure analysis. All commands below use `npx @adcp/sdk@latest`. If you have `@adcp/sdk` installed globally (`npm install -g @adcp/sdk`) you can drop the `npx @adcp/sdk@latest` prefix and use `adcp` directly. ## Request-signing grader Validates RFC 9421 conformance against your agent end-to-end. Runs every signing vector and reports per-vector results so you can trace exactly which canonicalization rule or header coverage check is failing. ```bash theme={null} npx @adcp/sdk@latest grade request-signing ``` **What it checks:** * Signature base canonicalization (method, target-uri, authority, content-type, content-digest) * Covered-component completeness and ordering * `alg` and `kid` fields present and valid * Timestamp window (±60 s) and nonce uniqueness * Replay detection (if the agent advertises it) * Negative-vector rejection — each malformed request MUST produce the expected error code **When to use it:** before flipping any operation to `required_for` in `get_adcp_capabilities`; when a counterparty reports signature verification failures; when upgrading key algorithms (Ed25519 → P-256 or the reverse). ## OAuth handshake diagnoser Probes an agent's OAuth discovery documents (RFC 9728 protected-resource metadata, RFC 8414 authorization-server metadata), performs the authorization code + PKCE flow, decodes the resulting JWT, and ranks hypotheses about what is wrong. ```bash theme={null} npx @adcp/sdk@latest diagnose-auth ``` The `` form uses a saved alias from `~/.adcp/config.json` (set via `npx @adcp/sdk@latest --save-auth `). **What it probes:** * `/.well-known/oauth-protected-resource` — presence, `authorization_servers` list, HTTPS enforcement * `/.well-known/oauth-authorization-server` — issuer match, `token_endpoint`, `code_challenge_methods_supported` * Token endpoint response — token type, expiry, scope coverage * JWT claims — `iss`, `sub`, `aud`, `exp`, `iat` presence and validity * Cross-origin `authorization_servers` issuer pinning (flags if the resource metadata's AS URL doesn't match out-of-band config) **Output:** ranked hypothesis list, e.g., `1. token_endpoint not reachable (connection refused) — likely cause`, `2. issuer mismatch — AS URL returned by protected-resource does not match adagents.json`. Each hypothesis links to the relevant spec section. **When to use it:** when `AUTH_REQUIRED` errors persist after bearer token configuration; when dynamic client registration returns unexpected responses; when a new seller's OAuth setup fails silently. ## Key generation Generate an Ed25519 or P-256 keypair formatted for publication at your agent's `jwks_uri`. ```bash theme={null} npx @adcp/sdk@latest signing generate-key ``` Outputs: * A private key file (PEM, for your agent's signing config) * A JWK with `"kid"`, `"use": "sig"`, `"key_ops": ["verify"]`, `"adcp_use": "request-signing"`, and `"alg": "EdDSA"` (or `"ES256"` for P-256) ready to paste into your JWKS endpoint **When to use it:** initial signing setup; key rotation (generate new, publish alongside old, drain in-flight requests, retire old). ## Vector verifier Verify a single signing vector without running the full grader. Useful for debugging a specific canonicalization case during implementation. ```bash theme={null} npx @adcp/sdk@latest signing verify-vector ``` Reads a vector from stdin (JSON matching the test-vector schema at [`/compliance/latest/test-vectors/request-signing/`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/)) and reports whether your client's signature base matches the expected output. **When to use it:** while implementing a signing client to confirm each component rule in isolation before testing end-to-end. ## Related * [Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent) — storyboard-based protocol compliance testing * [Authentication](/dist/docs/3.1.20/building/by-layer/L2/authentication) — auth model overview, bearer tokens, RFC 9421 introduction * [Security implementation reference](/dist/docs/3.1.20/building/by-layer/L1/security#signed-requests-transport-layer) — full RFC 9421 profile, verifier checklist, key publication rules # How grading works Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/how-grading-works How the AdCP compliance runner translates specialism declarations into a concrete set of graded storyboards — and how capability flags alter that set. The [Conformance Specification](/dist/docs/3.1.20/building/verification/conformance#conformance-is-layered) defines three obligation layers: Universal, Protocol, and Specialism. This page explains what happens inside the Specialism layer: how a specialism manifest resolves to a set of graded scenarios, and how per-scenario capability gates can narrow or expand that set. ## From declaration to graded scenarios When your agent declares a specialism in `get_adcp_capabilities`, the runner: 1. Fetches the specialism manifest at `/compliance/{version}/specialisms/{id}/`. 2. Reads the manifest's `requires_scenarios` list — an ordered set of scenario IDs the runner must grade. 3. For each scenario, checks whether the scenario declares a `requires_capability` gate. 4. If a gate is present, reads the named path from your `get_adcp_capabilities` response to decide whether to run or skip the scenario. The manifest drives the full scenario list; capability gates apply per-scenario on top of it. ## Runner evidence vs. verification policy The storyboard runner does not decide whether an agent earns a badge or satisfies a particular buyer. It produces evidence: executed assertions, failures, selected-but-skipped steps, not-selected steps, skip reasons, and the endpoint/run mode used. A verification policy consumes that evidence and decides which gaps are acceptable for a named outcome. | Question | Decided by | Evidence used | | ---------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | "Did this step match the storyboard?" | Storyboard runner | Step validations and response schemas | | "Does this agent earn **Verified (Spec)**?" | AgenticAdvertising.org verification policy | Runner evidence against the registered spec/test endpoint, plus membership and declaration checks | | "Does this agent earn **Verified (Sandbox)**?" | AgenticAdvertising.org verification policy | Runner evidence against the production endpoint under `account.sandbox: true`, plus sandbox-isolation checks | | "Is this good enough for a specific buyer?" | That buyer's requirement profile | Verified mode, declared protocols/specialisms, optional capabilities they require, and any business or integration requirements outside AdCP | This is why runner classification matters. "Not selected because this is a sandbox-only run" is a suite-selection fact, not a seller capability gap. "Skipped because the seller did not claim an optional feature" may be acceptable for the badge but unacceptable to a buyer that requires that feature. "Skipped because a declared required tool is missing" is a seller implementation problem. ## Requirement profiles To answer "what do I need to pass?", start with the profile you are trying to satisfy: | Target | Minimum question the profile answers | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Verified (Spec)** | Does the declared AdCP surface pass the required storyboards on the registered spec/test endpoint, with no blocking missing tools or failed assertions? | | **Verified (Sandbox)** | Does the registered production endpoint pass the sandbox verification profile under `account.sandbox: true`, with sandbox isolation enforced and only Sandbox-acceptable skips present? | | **Specific buyer requirement** | Which verified modes, protocols, specialisms, optional capabilities, operational behaviors, and skip classes does this buyer require? | A buyer requirement profile can be stricter than the public badge. For example, the public Sandbox profile can accept a capability-gated proposal storyboard being skipped when `media_buy.supports_proposals: false`; a buyer that requires proposal workflows can treat the same skip as a blocker. Conversely, a production endpoint omitting `comply_test_controller` can be acceptable for the Sandbox badge, while a buyer doing deterministic integration testing may ask for a separate dev/staging endpoint that exposes it. The storyboard runner should not encode those buyer decisions. It should emit typed evidence that a verification policy or buyer profile can evaluate consistently. ## Specialism manifests Each specialism's `requires_scenarios` field lists the scenarios the runner will grade. Example — the `sales-guaranteed` manifest declares eight required scenarios: ```yaml theme={null} # /compliance/{version}/specialisms/sales-guaranteed/ (source: static/compliance/source/specialisms/sales-guaranteed/index.yaml) id: sales_guaranteed requires_scenarios: - media_buy_seller/refine_products - media_buy_seller/delivery_reporting - media_buy_seller/measurement_terms_rejected - media_buy_seller/pending_creatives_to_start - media_buy_seller/inventory_list_targeting - media_buy_seller/inventory_list_no_match - media_buy_seller/invalid_transitions - media_buy_seller/proposal_finalize # ← capability-gated ``` Seven of these run unconditionally for any `sales-guaranteed` agent. The eighth — `proposal_finalize` — carries a capability gate. ## Capability gates A scenario or phase can declare a `requires_capability` block. The runner reads the named path from your `get_adcp_capabilities` response and checks it against the expected value. If the check fails (the capability is absent or false), the scenario or phase is skipped — the `skip` block will appear in runner output with `reason: not_applicable` — and does not contribute to `steps_failed`. ```yaml theme={null} # /compliance/{version}/protocols/media-buy/scenarios/proposal_finalize/ (source: static/compliance/source/protocols/media-buy/scenarios/proposal_finalize.yaml) id: media_buy_seller/proposal_finalize requires_capability: path: media_buy.supports_proposals equals: true ``` The gate is evaluated against your agent's live `get_adcp_capabilities` response at run time — the same call the runner makes during the universal `capability_discovery` storyboard. Phase-level gates use the same shape and scope only the phase that declares the block. Universal storyboards use this for protocol-specific phase families, for example deterministic testing skips its SI session phase when an agent does not declare `sponsored_intelligence` while still running media-buy or creative phases the agent does declare. ## Worked example **Scenario:** Priya's StreamHaus platform claims `sales-guaranteed` and declares `media_buy.supports_proposals: true`. ```json theme={null} { "supported_protocols": ["media_buy"], "specialisms": ["sales-guaranteed"], "media_buy": { "supports_proposals": true } } ``` **Runner behavior:** all eight `requires_scenarios` run, including `proposal_finalize`. Priya's platform is graded on the full proposal lifecycle — brief with proposals, refine, finalize, and execute via `create_media_buy`. *** **Scenario:** StreamHaus Direct is an auction-based PG platform — no proposal abstraction. It claims `sales-guaranteed` and declares `media_buy.supports_proposals: false`. ```json theme={null} { "supported_protocols": ["media_buy"], "specialisms": ["sales-guaranteed"], "media_buy": { "supports_proposals": false } } ``` **Runner behavior:** seven scenarios run; `proposal_finalize` is skipped. The `skip` block in runner output is the authoritative signal: ```json theme={null} { "storyboard_id": "media_buy_seller/proposal_finalize", "skip": { "reason": "not_applicable", "detail": "requires_capability check: media_buy.supports_proposals must equal true — agent declared false" } } ``` When the `skip` block is present, the step was not graded and does not count against `steps_failed`. The `skip.detail` string identifies the specific cause (capability gate, missing specialism declaration, or missing tool). **Absent = false.** The `supports_proposals` field has `"default": false` in the capabilities schema. Omitting it from your response is equivalent to declaring `false` — the runner skips capability-gated proposal scenarios. Declare `true` explicitly to opt in to grading. This flag is only the grading gate. Buyer agents should not use it to decide whether a specific proposal is executable. If a seller returns a proposal, `proposal_status` is the source of truth: `draft` requires finalization before create, `committed` can be executed before `expires_at`, and absent status is legacy ready-to-buy. ## Grading verdicts at a glance | Outcome | Output field | Meaning | | --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Scenario passed | Step result with no `skip`; `passed: true` | All validations passed | | Scenario failed | Step result with no `skip`; `passed: false` | One or more required validations failed; see `validations[]` for the failing field and `json_pointer` | | Scenario not selected | `run_summary.not_selected[].reason`, counted in `steps_not_selected` | The caller's selected suite, run mode, version, or verification profile excluded this scenario before execution | | Scenario skipped | `skip.reason: not_applicable` | Scenario was selected, but an applicability gate evaluated false, such as an optional capability the seller did not claim | | Required tool missing | `skip.reason: missing_tool` | Scenario was selected and the agent declared the specialism, but did not expose a tool listed in `required_tools` | A run's overall compliance verdict is determined by `steps_failed`. Skipped steps (`skip` block present) and not-selected items (`run_summary.not_selected[]` entries) do not contribute to that counter, but they mean different things. `steps_not_selected` says the runner deliberately excluded those scenarios from this run. `steps_skipped` says the runner selected those scenarios but could not execute them; use `skipped_by_reason` and `skip.detail` to distinguish optional capability choices from missing required surfaces. A sandbox-only run should therefore look like this when the only excluded work is outside the selected mode: ```json theme={null} { "summary": { "steps_passed": 84, "steps_failed": 0, "steps_skipped": 0, "steps_not_selected": 80, "not_selected_by_reason": { "run_mode_excluded": 80 } } } ``` If those 80 items instead appear under `steps_skipped`, they were selected and then skipped; that is a different signal and needs `skipped_by_reason`. ## Pass vs partial coverage Runner summaries distinguish **failure** from **coverage**: | Run shape | What it means | Seller action | | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `steps_failed > 0` | A storyboard assertion failed on a step that ran | Fix the agent before claiming the corresponding protocol or specialism | | `steps_failed = 0`, no coverage-gap skips | Full pass for the declared scope | The declared protocols, specialisms, and capability flags were graded cleanly | | `steps_failed = 0`, with `steps_not_selected > 0` only | The requested suite or run mode intentionally excluded some probes, such as live-only checks during a sandbox-only run | No implementation ask; label the run mode clearly | | `steps_failed = 0`, with only capability-gate `not_applicable` skips | Narrower declared scope, not a failure | The agent honestly declined an optional capability, such as `media_buy.supports_proposals: false` | | `steps_failed = 0`, with `missing_test_controller` skips | Deterministic-test-surface coverage gap | Run a dev/staging deterministic pass or publish the skipped lifecycle coverage explicitly | | Any `missing_tool`, `requirement_unmet`, or `unsatisfied_contract` skip for a declared protocol or specialism | The seller declared a claim the runner could not fully test | Fix the missing surface or narrow the declaration | This distinction matters for production-path sandbox runs. A seller can run the storyboard suite against the real production endpoint under sandbox-flagged traffic and get **zero failures** while still seeing a `partial` summary because the production endpoint correctly does not expose `comply_test_controller`. That result says: "the buyer-visible sandbox path passed every assertion the runner could grade, but controller-seeded lifecycle scenarios were skipped." It is useful evidence for sandbox readiness, but it is not the same as full deterministic specialism coverage. For full coverage, run the same declared scope against a dev or staging endpoint that exposes the controller, or pre-seed the required state and configure the runner to assert seeded-state coverage. For a production-only seller, publish the skipped coverage list alongside the zero-failure result so buyers can see exactly what was and was not graded. ## Where each piece lives | Artifact | URL path | Source | | --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------- | | Specialism manifest | `/compliance/{version}/specialisms/{id}/` | `static/compliance/source/specialisms/{id}/index.yaml` | | Scenario YAML | `/compliance/{version}/protocols/{protocol}/scenarios/{name}/` | `static/compliance/source/protocols/{protocol}/scenarios/{name}.yaml` | | Universal storyboards | `/compliance/{version}/universal/` | `static/compliance/source/universal/` | | Capabilities schema | `/schemas/3.1.20/protocol/get-adcp-capabilities-response.json` | `static/schemas/source/protocol/get-adcp-capabilities-response.json` | The full specialism-to-scenario index is at [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog). The runner output contract defining every skip reason and verdict shape is at `static/compliance/source/universal/runner-output-contract.yaml`. ## Related * [Conformance Specification](/dist/docs/3.1.20/building/verification/conformance) — the three-layer obligation model and the normative storyboard index * [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) — full taxonomy of protocols, specialisms, and universal storyboards * [Validate Your Agent](/dist/docs/3.1.20/building/verification/validate-your-agent) — running the suite locally with `@adcp/sdk` # Storyboards vs. scenarios — which is which Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/storyboards-vs-scenarios Three things in AdCP share the word 'scenarios' and they aren't the same. Here's how to tell them apart. If you're trying to figure out *how* to test an AdCP agent, three things in this ecosystem share overlapping vocabulary. They are not the same. Confusing them produces wrong conclusions — including the kind that get reported as protocol gaps that aren't actually gaps. ## TL;DR | | What it is | Where it lives | Normative? | | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------- | | **Storyboards** | YAML files defining a workflow end-to-end + every wire-shape assertion. **The conformance specification.** | `/compliance/{version}/universal/*.yaml`, `protocols/*/*.yaml`, `specialisms/*/index.yaml` | ✅ Yes | | **`comply_test_controller` scenarios** | Protocol-level tool operations a seller exposes (`force_*`, `simulate_*`, `seed_*`) so storyboards can drive deterministic state. | The `scenario` parameter of the seller's `comply_test_controller` MCP tool. | ✅ Yes | | **`@adcp/sdk/testing/scenarios/*.ts`** | Legacy TypeScript test runners predating storyboard-driven `comply()`. | `node_modules/@adcp/sdk/dist/lib/testing/scenarios/*.js` | ❌ **No** | If you're reading a file at `node_modules/@adcp/sdk/dist/lib/testing/scenarios/media-buy.js` and trying to figure out what AdCP requires — **stop**. That's not the spec. Read [the storyboards](/dist/docs/3.1.20/building/verification/conformance) instead. ## Storyboards (normative) Storyboards are the conformance specification. Each one defines: * A workflow (e.g. *sales-guaranteed proposal/refine/finalize lifecycle*) * The exact tool calls a buyer agent makes * Every wire-shape assertion the seller's responses must satisfy * The `comply_test_controller` operations the runner uses to seed deterministic state [`conformance.mdx`](/dist/docs/3.1.20/building/verification/conformance) calls these *"the truth"* — and means it. These YAML files are the basis for **AgenticAdvertising.org Verified (Spec)**; AgenticAdvertising.org issues that qualifier when its compliance heartbeat runs the applicable storyboards and every assertion passes. You'll see storyboards at: * `/compliance/{version}/universal/*.yaml` — every AdCP agent must pass * `/compliance/{version}/protocols/{protocol}/index.yaml` — anyone claiming the protocol * `/compliance/{version}/specialisms/{id}/index.yaml` — opt-in claims Run them via `npx @adcp/sdk@latest storyboard run ` or interactively through [Addie](https://agenticadvertising.org). ### Source authority and rollout The authored source of truth for storyboards is `static/compliance/source/` in the `adcontextprotocol/adcp` repo. `scripts/build-compliance.cjs` builds `dist/compliance/latest/` during development and stamps `dist/compliance/{version}/` on release, adding only generated distribution artifacts such as `index.json` and the legacy `domains/` alias. SDKs may bundle a snapshot so runners work offline and remain version-pinned, but that snapshot is a distribution copy, not the authority. During the current migration, `@adcp/sdk` still loads its bundled cache by default. Spec repo CI overlays `static/compliance/source/` onto that cache before running the training-agent storyboards so pull requests are graded against the candidate source without switching runtime authority in the same step. SDK release work should separately prove its bundled cache matches the spec bundle it claims to ship. Breaking or materially stricter storyboard changes must roll out in this order: ```text theme={null} spec storyboard change -> reference implementations update -> @adcp/sdk runner release -> downstream consumers update ``` That order prevents the release deadlock where a new runner expects stricter scenarios before repo-owned reference implementations have a tagged implementation that can pass them. ## `comply_test_controller` scenarios (normative — but different) The word "scenario" also appears in the `comply_test_controller` MCP tool that sellers expose to support deterministic testing. Storyboards call this tool with `scenario: ` to drive seller state without waiting for real time to pass: e.g. `force_task_completion` to finish a pending async media buy, `simulate_delivery` to inject impressions, `seed_product` to install a fixture. These are **protocol operations**, not test runners. They're documented at [comply\_test\_controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller). Sellers MUST implement them to be testable; the storyboards MUST use them to stay deterministic. So when you hear "the seller doesn't support that scenario," it usually means: "the seller's `comply_test_controller` doesn't expose `force_X` yet." Not "no storyboard tests X." ## `@adcp/sdk/testing/scenarios/*.ts` (NON-normative legacy) The SDK ships TypeScript files under `testing/scenarios/` — `media-buy.ts`, `signals.ts`, `creative.ts`, and a dozen siblings. These predate the storyboard-driven `comply()` engine. They are: * **Not the conformance spec.** Reading them to learn what AdCP requires will produce wrong answers. * **Not maintained in lockstep with storyboards.** A scenario file may hard-code parameters that the storyboard equivalent has long since corrected. *Example: `scenarios/media-buy.ts` hard-codes `buying_mode: 'brief'` at four call sites; the YAML storyboard for `sales-guaranteed` exercises `buying_mode: 'refine'` + `action: 'finalize'` via the `proposal_finalize` storyboard. Both are valid `buying_mode` values; the scenario file just doesn't cover the lifecycle.* * **Callable, but for different purposes.** Internal smoke tests in the SDK, integration-test fixtures for downstream tooling, and some legacy paths still use these. They are not what AgenticAdvertising.org Verified (Spec) runs against. If you find yourself grepping `testing/scenarios/*.ts` to understand AdCP, you've taken a wrong turn. The right path: 1. Find the storyboard that matches your declared specialism: `npx @adcp/sdk@latest storyboard list` 2. Read the YAML: `npx @adcp/sdk@latest storyboard show ` 3. Run it: `npx @adcp/sdk@latest storyboard run --agent-url ` ## Three-line decoder | If you see… | …you're looking at | Trust as spec? | | --------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- | | A `.yaml` under `/compliance/{version}/...` | A storyboard | ✅ Yes | | A seller responding to `comply_test_controller` with a `scenario` parameter | A protocol-level test-control operation | ✅ Yes (the contract is normative; the seller MAY refuse with `UNKNOWN_SCENARIO`) | | A `.ts` or `.js` under `@adcp/sdk/dist/lib/testing/scenarios/` | A legacy SDK test runner | ❌ No | ## Cross-repo The disambiguation work in the SDK itself — marking the legacy scenarios as `@deprecated`, mirroring the storyboard CLI verbs, or removing the export entirely — lives in [`adcp-client`](https://github.com/adcontextprotocol/adcp-client). The decision on whether `testing/scenarios/*` stays public-but-deprecated or becomes internal-only is tracked in [#4035](https://github.com/adcontextprotocol/adcp/issues/4035). For now: if you want to test an AdCP agent, the answer is storyboards. The other two things named "scenario" have their place, but they aren't it. # Validate adapter agents with mock upstream fixtures Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/validate-with-mock-fixtures Pre-staging gate for AdCP adapter agents — published mock upstreams plus traffic counters surface integration gaps before staging tests. **Non-normative.** This page describes a complementary harness pattern, not a compliance tier. The recipe builds on top of the storyboard runner ([Validate your agent](/dist/docs/3.1.20/building/verification/validate-your-agent)) — it does not replace storyboards or staging integration tests, and is not a certification gate. The mock-fixture conventions described here (`/_lookup/`, `/_debug/traffic`) are reference implementations in `@adcp/sdk`; alternative SDK implementations may diverge. Treat as harness convention, not protocol contract. Most AdCP agents that wrap an external platform (a DSP, SSP, retail data warehouse, creative server, signal marketplace) ship façade bugs that storyboards alone cannot detect: handlers return shape-valid AdCP responses without actually integrating with the upstream. The recipe on this page closes that gap as a **pre-staging gate** — cheap, fast, runs in CI, surfaces façades and contract drift before code reaches a staging tenant. If your agent does not wrap an upstream — for example, a pure decisioning service that owns its own data — the storyboard runner alone is sufficient. See [Validate your agent](/dist/docs/3.1.20/building/verification/validate-your-agent). ## The four-step recipe ```bash theme={null} # 1. Boot a mock upstream for your specialism npx @adcp/sdk@latest mock-server sales-social --port 4250 & # 2. Run your AdCP agent, configured to use http://localhost:4250 as its upstream ./your-agent.sh # Python / Go / Rust / TS — language-agnostic # 3. Grade your agent against the matching storyboard npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp sales_social \ --auth $TOKEN --json > grader.json # 4. Assert your agent actually called the upstream curl -s http://localhost:4250/_debug/traffic # {"traffic": {"POST /oauth/token": 1, "POST /event/track": 6, ...}} ``` Step 3 catches AdCP wire bugs (response shape, error codes, idempotency, missing required fields). Step 4 catches **integration gaps** — agents whose handlers return shape-valid AdCP responses without exercising the upstream's headline endpoints. Both signals matter; one without the other is incomplete. The mock-server CLI shown above ships in `@adcp/sdk` (TypeScript). If your CI runs in another language, the simplest pattern is to run the TS CLI as a sidecar (e.g. a Docker service container in GitHub Actions, or a separate Node process) — the agent under test stays in its native language, only the mock + storyboard runner are TS. A Python or Go SDK shipping its own mock-server would re-implement the conventions described below; until that lands, the TS CLI is the reference. ## Why traffic counters Storyboards check the AdCP wire contract: did the response match the schema, did the agent advertise the right tools, did context echo. They do not assert what happened *behind* the wire. The CLAUDE.md guidance for this repo puts it directly: **storyboards are assertions, not ground truth.** Adapters that look correct under shape-only validation but skip the upstream entirely have shipped to staging more than once. Common shapes: * Handler short-circuits before the upstream call when the input doesn't match a non-spec branch (e.g. empty `members[]` on `sync_audiences` → returns shape-valid empty response, never POSTs). * OAuth client is wired but never invoked from any handler; tree-shaking is defeated with a `void fetchUpstreamToken;` literal. * Synthetic placeholder data is injected to satisfy the upstream's required-field schema, masking real-data shape mismatches. A traffic counter on the mock upstream catches the first two cases unconditionally and the third case if the storyboard exercises the relevant payload variety. Position this in your CI as a **pre-staging gate**: cheap, deterministic, complements your existing staging tests rather than replacing them. ## What's available The reference TS implementation (`@adcp/sdk`) ships four mock-server specialisms covering distinct upstream surface shapes. These are not exhaustive — they exist to exercise representative auth/tenancy/payload patterns, and other specialisms reuse the closest match. | Specialism | Mimics | Auth | Multi-tenant scope | | -------------------- | --------------------------------------------- | ------------------------------------------ | ------------------------------------------- | | `signal-marketplace` | LiveRamp / Lotame / data marketplace | Static Bearer | Header (`X-Operator-Id`) | | `creative-template` | Celtra / Innovid creative-management platform | Static Bearer | Path (`/v3/workspaces/{ws}/…`) | | `sales-social` | TikTok / Meta-shaped social ads platform | OAuth 2.0 client\_credentials with refresh | Path (`/v1.3/advertiser/{advertiser_id}/…`) | | `sales-guaranteed` | GAM / FreeWheel guaranteed-sales platform | Static Bearer | Header (`X-Network-Code`) | The auth shapes and tenancy patterns are realistic; the specific account-field names the adapter receives (e.g. `account.advertiser`, `account.operator`) are SDK conventions for binding AdCP requests to upstream tenants, not normative AdCP terms. See the `@adcp/sdk` source for the canonical mapping. Each mock exposes: * **The upstream's domain endpoints** — shaped to match the real platform's public contract. * **`GET /_lookup/?=`** — runtime resolution from AdCP-side identifiers to upstream tenant IDs. *Harness convention.* * **`GET /_debug/traffic`** — hit counters keyed by ` `, no auth, harness-only. *Harness convention.* Read after the storyboard run; assert each headline route is hit at least once. OpenAPI specs for each mock ship as part of the SDK package. Reference your adapter against the spec, not against any specific seed data — seeds vary and are not part of the contract. ## CI integration Reference shape for a GitHub Actions job, language-agnostic: ```yaml theme={null} jobs: validate-adapter: runs-on: ubuntu-latest services: mock-upstream: image: node:20 ports: ['4250:4250'] # Use a bootstrap script that runs `npx @adcp/sdk@latest mock-server `. steps: - uses: actions/checkout@v4 - run: ./scripts/start-agent.sh & # Your agent in your language - run: ./scripts/wait-for-port.sh 3001 - run: | npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp \ --auth $TOKEN --json > grader.json - run: | # Assert each expected upstream route was hit at least once curl -s http://localhost:4250/_debug/traffic | \ jq -e '.traffic["POST /oauth/token"] >= 1 and .traffic["POST /event/track"] >= 1' ``` **Threshold guidance.** The minimum useful assertion is `≥ 1` per headline route — that proves the handler reached the upstream. Stronger assertions (per-route counts, distinct-payload verification) require encoding storyboard-payload expectations and aren't worth the maintenance burden in a pre-staging gate. If your storyboard exercises 3 audience uploads, expect 3 hits to `custom_audience/upload`; if it doesn't, the storyboard's payload coverage is the lever to pull, not the gate's threshold. For agents claiming multiple specialisms, run one CI job per specialism in parallel; each gets its own mock-server port pair (agent + upstream). Jobs are independent. ## Iteration loop Realistically your first run will not pass both gates. Common shapes and how to debug: * **Storyboard `passing` but traffic gate fails on N endpoints** — classic façade. The handlers for those routes either short-circuited or never got exercised by the storyboard's input. * **Storyboard `partial` with cascade skips** — an early step (`get_products`, `get_signals`) returned a shape-valid response missing fields that the runner extracts state from. Downstream steps skip with `unresolved context variables`. Fix the early step's response shape and most cascade skips clear. * **Storyboard `failing` on a single step + traffic gate clean** — usually a one-line shape bug (wrong field name, missing required field, status mismatch). The per-step `details` names the field via JSON pointer. * **Traffic gate empty (0 hits everywhere) + agent appears to start** — agent boot threw a recoverable error after listening on port. Check the agent's stderr. Fastest debug loop: use `npx @adcp/sdk@latest storyboard step ` to isolate the failing step. Skips the cascade, runs a single tool call, sub-second feedback. Don't run the full storyboard until the isolated step passes — saves minutes per iteration. ## Limitations Be honest with your team about what these gates do and don't catch: ### Storyboard limitations * **Storyboards under-cover payload variety.** A storyboard step may pass shape with an empty input where a real adapter never gets exercised on the variant that matters. Tracked at [adcontextprotocol/adcp#3785](https://github.com/adcontextprotocol/adcp/issues/3785). ### Runner / tooling caveats * **Storyboard cascade skips silently** when an early step's response is shape-valid but missing fields the runner extracts state from. The error you see is on the *downstream* step, not the early one — investigate "skipped" steps before "failed". Tracked at [adcontextprotocol/adcp#3796](https://github.com/adcontextprotocol/adcp/issues/3796) (runner-side). * **Mock seed data may not match storyboard fixture inputs.** If you see 404s on `_lookup/`, the storyboard's payloads may reference IDs the mock doesn't seed. Either widen the mock's seed or seed scenario state at runtime via the [compliance test controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller). ### Traffic gate limitations * **Necessary, not sufficient.** A handler can call upstream with synthetic placeholder data and still satisfy the hit-count assertion. For agents in regulated channels (audience uploads, conversion tracking, signed requests), additional integration tests against the real upstream's payload validation are still required before production. * **Idempotency replay is not exercised by traffic counters.** A façade that ignores `idempotency_key` and doubles upstream writes will pass the hit-count gate. Use the storyboard's idempotency-replay scenarios + a separate counter check (same `idempotency_key` → same hit count) if your platform has at-most-once semantics. * **Outbound webhook delivery is not exercised by upstream traffic counters.** Traffic counters live on the upstream the agent calls *into*; agent → buyer webhook signing/delivery is graded by the storyboard runner's `--webhook-receiver` flag, separately. Both gates apply for adapters that emit webhooks. ## What's next * **[Validate your agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** — the broader storyboard-runner-driven validation checklist (fuzz, multi-instance, request-signing, webhook conformance). * **[Compliance test controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller)** — seed scenario state at runtime when storyboards need fixtures the mock doesn't provide. * **[Build an agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent)** — the language-agnostic guide to building an AdCP agent. * **[Compliance catalog](/dist/docs/3.1.20/building/verification/compliance-catalog)** — the full taxonomy of universal / protocol / specialism storyboards. # Validate your agent using storyboards Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/building/verification/validate-your-agent Test your AdCP agent with storyboards — from the CLI or through Addie. Once your agent is running, validate it before going live. Storyboards exercise a specific workflow end-to-end — media buy creation, creative sync, signals discovery. Each storyboard defines the exact tool call sequence a buyer agent makes and validates every response shape. Storyboards are available from the command line and interactively through [Addie](https://agenticadvertising.org). They are also published alongside schemas at `/compliance/{version}/` and bundled into the per-version protocol tarball at `/protocol/{version}.tgz` — see [Schemas and SDKs](/dist/docs/3.1.20/building/by-layer/L0/schemas#one-shot-protocol-bundle) for how to fetch them offline. The `@adcp/sdk` package also exports legacy TypeScript test runners under `testing/scenarios/*` (e.g. `media-buy.ts`, `signals.ts`). These predate `comply()` and are **not** the conformance specification. If you find yourself grepping those files to learn what AdCP requires, see [Storyboards vs. scenarios](/dist/docs/3.1.20/building/verification/storyboards-vs-scenarios) for which surface is normative. **Wrapping an upstream platform** (DSP, SSP, retail data warehouse, creative server, signal marketplace)? Storyboards check your AdCP wire contract; they cannot tell whether the adapter behind the wire actually integrates with the upstream or returns shape-valid responses with synthetic data. See [Validate adapter agents with mock upstream fixtures](/dist/docs/3.1.20/building/verification/validate-with-mock-fixtures) — published mock fixtures plus traffic counters give you façade-resistant compliance for adapters in any language. ## Storyboard taxonomy Storyboards are organized into three layers so agents declare only what they actually support: | Layer | Path | Who must pass it | | -------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Universal** | `/compliance/{version}/universal/` | Every AdCP agent (capability discovery, error handling, schema validation) | | **Protocol** | `/compliance/{version}/protocols/{protocol}/` | Any agent claiming a protocol (`media-buy`, `creative`, `signals`, `governance`, `brand`) | | **Specialism** | `/compliance/{version}/specialisms/{id}/` | Opt-in claims (e.g. `sales-guaranteed`, `sales-broadcast-tv`, `creative-generative`) — see the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) | Declare your `supported_protocols` and `specialisms` in `get_adcp_capabilities` — the runner picks the matching storyboards automatically. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for the full taxonomy. ## Setup Save your agent as a named alias so you can reference it by name: ```bash theme={null} npx @adcp/sdk@latest --save-auth my-agent http://localhost:3001/mcp ``` This stores the alias in `~/.adcp/config.json`. You only need to do this once. Built-in aliases `test-mcp` and `test-a2a` point to the public test agents — no setup needed. You can also pass a URL directly instead of an alias: `npx @adcp/sdk@latest storyboard run http://localhost:3001/mcp media_buy_seller` ## Run a storyboard ### 1. List available storyboards ```bash theme={null} npx @adcp/sdk@latest storyboard list ``` Each storyboard targets a specific agent type. The [Build an Agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent) page maps skills to their matching storyboards. ### 2. Preview what a storyboard tests ```bash theme={null} npx @adcp/sdk@latest storyboard show media_buy_seller ``` This shows the phases, steps, and validations without running anything. ### 3. Run the storyboard ```bash theme={null} npx @adcp/sdk@latest storyboard run my-agent media_buy_seller ``` Output shows each step with pass/fail: ``` media_buy_seller (9 steps) ✓ get_adcp_capabilities ✓ sync_accounts ✓ get_products ✓ create_media_buy ✓ list_creative_formats ✓ sync_creatives ✓ list_creatives ✓ get_media_buy_delivery ✓ provide_performance_feedback 9/9 passed ``` Pass `--json` for machine-readable results. Pass `--debug` to see full request/response payloads for each step. ### 4. Debug a failing step If a step fails, run it individually: ```bash theme={null} npx @adcp/sdk@latest storyboard step my-agent media_buy_seller create_media_buy --json --debug ``` Pass `--context` to provide state from earlier steps (account IDs, product IDs): ```bash theme={null} npx @adcp/sdk@latest storyboard step my-agent media_buy_seller get_products \ --context '{"account_id":"acct-123"}' --json ``` ### 5. Run all storyboards Run without a storyboard ID to test everything. The CLI discovers your agent's tools via `tools/list` and selects matching storyboards automatically: ```bash theme={null} npx @adcp/sdk@latest storyboard run my-agent ``` Add `--json` for structured output. The storyboard runner operates in two modes depending on whether your agent implements the optional [compliance test controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller): | Mode | When | What it tests | | ----------------- | ----------------------- | ----------------------------------------------------------- | | **Observational** | No test controller | Response schemas and buyer-initiated flows | | **Deterministic** | Test controller present | Full lifecycle state machines, error codes, operation gates | ### Reading `partial` `partial` is a coverage result, not automatically a failure. The runner uses it when one or more selected scenarios could not be graded even though no executed assertion failed. The most common cause is intentional: production endpoints MUST NOT expose `comply_test_controller`, so controller-seeded or controller-forced phases skip with `missing_test_controller`. Read `steps_not_selected` separately from `steps_skipped`. Not-selected scenarios were outside the suite or run mode you asked for. Skipped scenarios were inside the selected suite, but the runner could not execute them because of an applicability gate, missing test surface, missing tool, or prerequisite. Use the summary counters and skip reasons to decide what the result means: | Summary | Interpretation | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `0 failed`, `steps_not_selected > 0`, `steps_skipped = 0` | Expected for the selected run mode, such as sandbox-only testing that excludes live-only probes | | `0 failed`, only capability-gate `not_applicable` skips | Clean pass for the capability scope the seller declared | | `0 failed`, `missing_test_controller` skips | Buyer-visible path passed, but deterministic lifecycle coverage was not graded | | Any `missing_tool` skip for a declared protocol or specialism | The seller overclaimed or forgot to expose a required tool | | Any failed step | Not conformant for the declared scope until fixed | For production-path sandbox validation, a result like `84 passed, 0 failed, 0 skipped, 80 not selected` is a clean sandbox-only selection result: the excluded probes were never part of that run. A result like `84 passed, 0 failed, 80 skipped` needs the skip breakdown before it means anything. Skips because an optional capability was not claimed are selected-scope skips. Skips with `missing_test_controller` are deterministic coverage gaps: the suite tested the public sandbox path and reported that controller-seeded lifecycle scenarios were not graded. To get full green on those, run against a dev/staging endpoint that exposes the controller, pre-seed the required state and tell the runner to assert seeded-state coverage, or accept and publish the skipped coverage list as an explicit limitation. ## Validate through Addie [Addie](https://agenticadvertising.org) provides interactive testing without any CLI setup. Paste your agent URL in any conversation to get started. ### Connectivity check Ask Addie to check your agent. She'll verify it's online, list its advertised tools, and confirm the transport protocol (MCP or A2A). This is the quickest way to confirm your agent is reachable before running any tests. ### Storyboard coaching Addie runs the same storyboards as the CLI but walks you through each step interactively. When a step fails, she explains what went wrong, shows the expected vs actual response, and suggests specific code changes. This is the fastest way to iterate when you're building. ### RFP testing Share a real RFP or campaign brief with Addie. She'll parse it, call your agent's `get_products` with the buyer's actual requirements, and compare results against what your sales team would normally propose. This tests whether your agent can handle real buyer demand — not just synthetic briefs derived from your own inventory description. ### IO execution testing Share an insertion order with Addie. She'll extract the line items, match them against your agent's product catalog, and test whether `create_media_buy` can execute the deal. The output shows line-by-line matching quality (exact, close, weak, unmapped) and rate comparisons so you can see exactly where execution would break down. ### Recommended testing sequence 1. **Connectivity** — Is the agent online? 2. **Storyboards** — Does it pass protocol compliance? 3. **RFP testing** — Can it respond to real buyer demand? 4. **IO execution** — Can it close real deals? Each step builds confidence. Storyboards prove protocol compliance. RFP and IO testing prove business readiness. ## Sandbox mode All storyboard runs use sandbox mode by default. The storyboard runner sets `sandbox: true` on every account reference, so your agent processes requests without real platform calls or spend. Your agent should declare sandbox support in `get_adcp_capabilities`: ```json theme={null} { "account": { "sandbox": true } } ``` When a request references a sandbox account, your agent MUST NOT persist production state or cause real-world side effects — no real orders, no real billing, no real ad platform API calls. Return realistic response shapes with simulated data and include `sandbox: true` in success responses. See [Sandbox mode](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox) for full implementation details and the two account model paths (implicit vs explicit). ## Verifying cross-instance state The protocol requires that `(brand, account)`-scoped state [survive across agent process instances](/dist/docs/3.1.20/protocol/architecture#state-persistence-and-horizontal-scaling) — a media buy created on one replica must be readable from any other. Single-instance storyboard success does not by itself prove that invariant. Choose a verification approach that fits your deployment. **Verify by architecture.** If you run on a managed serverless platform with a shared datastore — Lambda + DynamoDB, Cloudflare Workers + D1, Cloud Run + Firestore, Vercel + Neon — the invariant holds by construction. Storyboards that pass against your deployed endpoint are sufficient. Document your storage pattern so it's discoverable. **Verify by multi-instance testing.** If you deploy long-running processes (containers, VMs, a classic app server behind a load balancer), put ≥2 replicas behind round-robin routing and run storyboards against the shared endpoint: ```bash theme={null} npx @adcp/sdk@latest --save-auth my-agent https://my-agent.example/mcp npx @adcp/sdk@latest storyboard run my-agent ``` The compliance runner rotates requests across replicas for any storyboard that contains a step marked `stateful: true` — the write→read sequences most likely to catch in-process state. Stateless probes (capability discovery, auth rejection, schema validation) are unaffected. A typical failure looks like: ``` ✗ get_media_buy MEDIA_BUY_NOT_FOUND create_media_buy on replica A returned media_buy_id=mb_abc123 (status: active) get_media_buy on replica B returned MEDIA_BUY_NOT_FOUND for the same id → Brand-scoped state is not shared across replicas. ``` **Verify by your own testing.** Property-based tests against a real datastore, chaos fault injection between replicas, or production observability that correlates writes and reads across instances are all valid. The protocol cares about the invariant, not the methodology. Insertion-order approval records, governance tokens, signal activations, and sponsored-intelligence sessions all fall under the same rule. Any state you write that a later call can read back must live in a shared store — not a per-process `Map` or module-level variable. ## Preparing to test uniform error responses The [uniform-response MUST](/dist/docs/3.1.20/building/by-layer/L3/error-handling#standard-error-codes) requires byte-equivalent responses for "the id exists but the caller lacks access" and "the id does not exist" across every observable channel — error body, transport status, headers, side effects, and telemetry. Verifying this needs a paired-probe runner (`adcp fuzz`) that compares two responses per tool. The runner has two modes, and you need to plan tenant setup before you can exercise the strong one. **Baseline mode — single tenant.** One auth token, two fresh UUIDs probed per tool. Catches id-echo in error bodies, header divergence outside the allowlist, MCP `isError` / A2A `task.status.state` divergence, and gross latency deltas. Cannot catch cross-tenant existence leaks, because neither probe resolves to a real resource. **Cross-tenant mode — two tenants.** Tenant A seeds a resource (e.g., a property list, content standard, media buy, creative); tenant B probes against the seeded id plus a fresh UUID. Catches the full MUST, because it exercises the `(exists, unauthorized)` vs `(does not exist)` pair that baseline cannot construct. Both modes exercise spec MUSTs. Only the cross-tenant path verifies the whole invariant. ### Minimum tenant setup Provision two isolated test accounts against your agent: * **Tenant A** — can create resources the invariant seeds (property lists, content standards, media buys, creatives). Sandbox-mode accounts are fine. * **Tenant B** — read-only against shared discovery surfaces. MUST NOT share any per-tenant state with A beyond what your platform makes globally visible (e.g., published product catalogs). Anything else the two tenants share — audit shards, rate-limit buckets keyed by resource type, cache tags — is a potential side channel the invariant is designed to catch. Share only what you'd share in production. ### Runner invocation ```bash theme={null} # Cross-tenant (full MUST) npx @adcp/sdk@latest fuzz my-agent \ --auth-token $TENANT_A_TOKEN \ --auth-token-cross-tenant $TENANT_B_TOKEN # Baseline (partial coverage) npx @adcp/sdk@latest fuzz my-agent --auth-token $TOKEN ``` Tokens may also be supplied via `ADCP_AUTH_TOKEN` and `ADCP_AUTH_TOKEN_CROSS_TENANT`. See the [`@adcp/sdk` uniform-error-response invariant guide](https://github.com/adcontextprotocol/adcp-client/blob/main/docs/guides/VALIDATE-YOUR-AGENT.md#uniform-error-response-invariant-paired-probe) for the full flag list, the header allowlist, and the list of tools currently probed. ### Testing with only one tenant If you haven't provisioned a second tenant yet, run baseline anyway — it still catches a meaningful class of leaks, and the CLI flags the run as baseline-only so operators can see coverage is partial. Treat single-tenant fuzz as a pre-check, not a conformance signal: a clean baseline run does not prove the MUST holds. Add the cross-tenant leg before you claim uniform-response conformance. ## The build-validate-fix loop The typical development workflow: 1. **Build** — Point a coding agent at a [skill file](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent) to generate your agent 2. **Run** — Start the agent locally (`npx tsx agent.ts`) 3. **Validate** — Run the matching storyboard (`npx @adcp/sdk@latest storyboard run my-agent media_buy_seller`) 4. **Fix** — Address any failures (missing fields, wrong status values, invalid transitions) 5. **Repeat** — Run the storyboard again until all steps pass 6. **Full check** — Run `npx @adcp/sdk@latest storyboard run my-agent` (no storyboard ID) for a full assessment before going live For [Practitioner certification](https://agenticadvertising.org/certification), passing storyboard validation is the capstone — it proves your agent handles the complete protocol workflow for your chosen role track. ## CLI reference | Command | Description | | ---------------------------------------------------------- | -------------------------------------------------- | | `npx @adcp/sdk@latest storyboard list` | List all available storyboards | | `npx @adcp/sdk@latest storyboard show ` | Preview storyboard structure | | `npx @adcp/sdk@latest storyboard run [id]` | Run one storyboard, or all matching if no ID given | | `npx @adcp/sdk@latest storyboard step ` | Run a single step | | `npx @adcp/sdk@latest [tool] [payload]` | Call any tool directly | | `npx @adcp/sdk@latest --save-auth ` | Save agent alias | | `npx @adcp/sdk@latest --list-agents` | List saved aliases | All commands support `--json`, `--debug`, `--auth TOKEN`, and `--protocol mcp|a2a`. ## When a storyboard fails * **[Storyboard troubleshooting](/dist/docs/3.1.20/building/operating/storyboard-troubleshooting)** — Error patterns mapped to root causes and fixes (missing fixtures, signature challenges, envelope drift, context echo, capability mismatches) * **[Known spec ambiguities](/dist/docs/3.1.20/building/cross-cutting/known-ambiguities)** — Open spec gaps that affect conformance, with workarounds and issue links ## What's next * **[Compliance test controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller)** — Implement deterministic testing for full lifecycle coverage * **[Task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle)** — Status values, transitions, and polling * **[Error handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling)** — Error categories, codes, and recovery # Introduction to AdCP Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/intro AdCP is an open agentic advertising standard. Follow Alex's team from fragmentation to a unified workflow across all protocol domains. Alex stands arms crossed in a war room of mismatched screens and tangled cables, surveying the chaos — her team hunched over laptops in the background Alex runs media operations at Pinnacle Agency. Her team buys across six channels — CTV, display, audio, social, retail media, and digital out-of-home. Each channel has its own buying methods, its own terminology, its own way of handling creatives, targeting, and reporting. IOs for some. APIs for others. DSPs for programmatic. Dashboards for everything. Now her clients want to try AI-generated creative, influencer campaigns, and local radio. Each new channel means new tools, new integrations, new workflows to learn. She can't keep scaling her team every time a client wants to try something new. The problem isn't her people. It's that every channel speaks a different language, and the industry has no common standard for how agents discover inventory, execute buys, distribute creative, activate data, or report results. AdCP is that standard. One protocol. Every platform. Every step of the campaign. ## More than activation AdCP coordinates the full campaign lifecycle — from brief intake through measurement — not just activation. It works across every transaction model: direct deals, sponsorships, broadcast, and out-of-home, not only programmatic exchange. Most global ad spend flows through non-programmatic channels, and AdCP is designed for all of them. This page follows Alex's team through the entire workflow — from finding new partners to measuring results. Each section shows the human problem, the protocol solution, and the tasks that make it work. By the end, you'll understand every domain AdCP covers and how they connect. *** ## Find new partners Alex reaches toward a glowing network map on a wall display, about to select a new publisher node from a constellation of connected partners Alex wants to work with publishers she's never talked to before. In the old world, that means sales calls, contracts, and weeks of integration work before she can even see what's available. With AdCP, discovery is built into the protocol. Every AdCP-enabled publisher hosts an `adagents.json` file — a machine-readable declaration of their properties and authorized agents. Sales operators publish `brand.json` for company identity, sales-agent endpoints, and signing-key discovery. Alex's buyer agent can resolve both files the same way a browser reads `robots.txt`. ``` https://streamhaus.tv/.well-known/adagents.json ``` ```json theme={null} { "version": "1.0", "publisher": { "name": "StreamHaus", "domain": "streamhaus.tv" }, "agents": [ { "url": "https://ads.streamhaus.tv/mcp", "protocol": "mcp", "capabilities": ["get_products", "create_media_buy", "sync_creatives"] } ] } ``` For broader discovery — "find me CTV publishers with sports inventory" — the [AgenticAdvertising.org registry](/dist/docs/3.1.20/registry) provides entity resolution and agent search. Alex's agent can query the registry by category, geography, or capability, and get back a list of publishers to connect with. The registry API resolves brands to their AdCP agents: ``` GET /api/registry/agents?capability=get_products&channel=ctv ``` ```json theme={null} { "agents": [ { "domain": "streamhaus.tv", "agent_url": "https://ads.streamhaus.tv/mcp", "capabilities": ["get_products", "create_media_buy"], "channels": ["ctv", "olv"] } ] } ``` How publishers declare their properties and authorized agents. *** ## Set up accounts Sam shakes hands across a desk with a laptop showing a teal checkmark between them — setting up a new commercial relationship Before Alex's team can buy media, they need a commercial relationship. In the old world, each platform has different onboarding — portals, forms, sales reps, weeks of back-and-forth. AdCP standardizes this with the accounts protocol. Sam, Alex's media buyer, sets up Pinnacle's relationship with StreamHaus in one call: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/account/sync-accounts-request.json", "idempotency_key": "f6c0a3d4-2345-48b1-2345-6789012345ab", "accounts": [ { "brand": { "domain": "acmeoutdoor.com" }, "operator": "pinnacle-agency.com", "billing": "operator" } ] } ``` The seller responds with the account status — active, pending review, or what additional information is needed. Once active, Sam can buy media. `list_accounts` shows all active relationships across every platform, so Alex can see at a glance which publishers her team is set up with. Commercial identity, billing models, and multi-advertiser management. *** ## Discover what's available Sam gestures at three product cards on a wall screen — CTV, display, and audio inventory options all generated from one campaign brief This is where it gets powerful. Sam wants to find premium sports inventory for Acme Outdoor's Q2 campaign. In the old world, he'd log into four dashboards and compare apples to oranges. With AdCP, `get_products` sends the same brief to every connected seller. Sam describes what he wants in natural language: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-request.json", "buying_mode": "brief", "brief": "Premium sports video inventory, Q2 2026, targeting 25-45 males interested in outdoor recreation. Budget $50K across CTV and display.", "brand": { "domain": "acmeoutdoor.com" } } ``` Every seller responds in the same format — products with pricing, forecasts, targeting options, and creative requirements. Sam compares proposals side by side on one screen instead of four. ```json theme={null} { "products": [ { "product_id": "streamhaus_sports_ctv_q2", "name": "StreamHaus Sports Premium", "channels": ["ctv"], "pricing_options": [ { "model": "cpm", "price": 28.50, "currency": "USD" } ], "forecast": { "impressions": { "min": 500000, "max": 750000 } }, "format_ids": [ { "agent_url": "https://ads.streamhaus.tv", "id": "video_16x9_30s" } ] } ] } ``` But Sam isn't done. He likes StreamHaus's sports package but wants to shift budget toward CTV and drop the display allocation. Instead of starting over, he uses **refine mode** — an iterative conversation with the seller: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-request.json", "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "streamhaus_sports_ctv_q2", "action": "more_like_this", "ask": "More CTV inventory like this, willing to go up to $35 CPM" }, { "scope": "request", "ask": "Drop display entirely, reallocate budget to CTV and OLV" } ] } ``` The seller adjusts and responds with refined options. No new RFP. No starting from scratch. Sam iterates until he has exactly what he wants. The complete media buy walkthrough — brief to delivery across three sellers. *** ## Build the creative Maya sits in a creative studio with her iPad, surrounded by wall screens showing ad formats of different sizes all generated from one brief Maya, Pinnacle's creative strategist, needs to produce ads for Sam's campaign. One campaign, three sellers, six formats — CTV video, OLV pre-roll, display banners, companion ads. In the old world, that's six separate production workflows. First, Maya discovers what each seller accepts: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/creative/list-creative-formats-request.json", "type": "video" } ``` Each seller returns their supported formats with exact specifications — dimensions, codecs, file sizes, duration limits. No guessing. Then Maya briefs the creative agent. One brief produces all formats: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "c1d2e3f4-a5b6-4789-c012-789012345678", "message": "Adventurous, aspirational summer campaign — gear for people who live outside", "brand": { "domain": "acmeoutdoor.com" }, "target_format_ids": [ { "agent_url": "https://ads.streamhaus.tv", "id": "video_16x9_30s" }, { "agent_url": "https://ads.streamhaus.tv", "id": "display_300x250" } ] } ``` The creative agent pulls Acme Outdoor's brand identity — colors, logos, tone guidelines — directly from the brand's `brand.json` (more on that below). No brand guide PDFs. No manual asset handoff. If Maya doesn't like the first draft, she refines with natural language: *"Make the opening shot more dynamic and swap the product shot for the hiking boots."* The `build_creative` task supports iterative refinement — same task, conversational guidance. Once approved, `sync_creatives` distributes the finished assets to every seller simultaneously: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-request.json", "idempotency_key": "d2e3f4a5-b6c7-4890-d123-890123456789", "account": { "brand": { "domain": "acmeoutdoor.com" }, "operator": "pinnacle-agency.com" }, "creatives": [ { "creative_id": "acme_summer_ctv_30s", "name": "Acme Summer CTV 30s", "format_id": { "agent_url": "https://ads.streamhaus.tv", "id": "video_16x9_30s" }, "assets": { "video": { "asset_type": "video", "url": "https://cdn.pinnacle.com/acme_summer_30s.mp4", "width": 1920, "height": 1080, "duration_ms": 30000 } } } ] } ``` Creative generation, format discovery, and multi-seller distribution. *** ## Execute the buy Sam presses a launch button on his laptop with a teal pulse radiating from the screen, Alex standing behind him arms crossed with a satisfied smile Sam has products, creatives, and accounts. Time to buy. One call to `create_media_buy` executes the campaign across every seller: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-request.json", "idempotency_key": "e3f4a5b6-c7d8-4901-e234-901234567890", "account": { "brand": { "domain": "acmeoutdoor.com" }, "operator": "pinnacle-agency.com" }, "brand": { "domain": "acmeoutdoor.com" }, "start_time": "2026-04-01T00:00:00Z", "end_time": "2026-06-30T23:59:59Z", "packages": [ { "product_id": "streamhaus_sports_ctv_q2", "budget": 35000, "pricing_option_id": "cpm_standard" } ] } ``` For sellers that generate creative — AI assistants, conversational ad platforms — the media buy can include a brief instead of pre-built assets. The seller's creative agent generates on the fly, pulling from the brand identity and campaign context. Both models — provided creative and generative creative — use the same `create_media_buy` task. `update_media_buy` handles mid-flight changes: shift budget between packages, adjust flight dates, swap creative assignments. No need to cancel and recreate. That `idempotency_key` on Sam's request isn't decorative. Pinnacle's buyer agent signs the POST with RFC 9421 HTTP Message Signatures before it leaves the network. StreamHaus verifies Pinnacle's signature against Pinnacle's operator-published JWKS, then accepts the buy. When the campaign moves from `pending_start` to `active`, StreamHaus posts a signed webhook back to Pinnacle's orchestrator — same signature profile, with StreamHaus's request-signing key published through its `brand.json` `agents[]` entry and optionally pinned by publisher `adagents.json`. If Sam's laptop drops the response and his agent retries, the `idempotency_key` makes the second call safe — StreamHaus returns the original buy with `replayed: true` instead of charging twice. Governance approvals ride along as signed JWS tokens on `check_governance` so no agent in the chain can forge Jordan's sign-off. See the [Security guide](/dist/docs/3.1.20/building/by-layer/L1/security). *** ## Match at serve time The campaign is live. When a user loads a StreamHaus page, opens OutdoorNet's mobile app, or asks an AI assistant about hiking gear, the publisher needs to know which of Sam's packages should activate — right now, for this content, for this user. The [Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match) handles this with two structurally separated operations. **Context Match** evaluates content signals against the available packages — no user identity crosses this boundary. **Identity Match** checks user eligibility using an opaque token — no page context crosses this boundary. The publisher joins both responses locally. Buyers never see identity and content together. One protocol, every surface: web, mobile, CTV, AI assistants, retail media. *** ## Add your data Sam and Kai sit side by side with laptops as translucent teal data streams arc between their screens — combining campaign and signal data Sam's campaign needs targeting beyond what the sellers provide. His client has CRM data (existing customers to exclude), Pinnacle has audience segments from their DMP, and he wants to layer on third-party signals from Kai's data company, Meridian Geo. **Audiences** travel with the campaign via `sync_audiences`: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-audiences-request.json", "idempotency_key": "a7d1b4e5-3456-48c2-3456-789012345abc", "account": { "brand": { "domain": "acmeoutdoor.com" }, "operator": "pinnacle-agency.com" }, "audiences": [ { "audience_id": "acme_existing_customers", "name": "Acme Outdoor — existing customers", "audience_type": "suppression" } ] } ``` **Signals** — third-party targeting data — are discovered and activated through the signals protocol. Sam searches for what he needs: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/signals/get-signals-request.json", "signal_spec": "Outdoor recreation enthusiasts near sporting goods retailers, 25-45" } ``` Kai's Meridian Geo returns matching signal segments with pricing, coverage, and activation options. Sam activates the ones he wants: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/signals/activate-signal-request.json", "idempotency_key": "f4a5b6c7-d8e9-4012-f345-012345678901", "signal_agent_segment_id": "meridian_outdoor_rec_25_45", "destinations": [ { "type": "platform", "platform": "streamhaus" } ] } ``` The signal activates on StreamHaus's platform. Kai's data reaches Sam's campaign without either side building a custom integration. How Sam discovers and activates Kai's targeting data across platforms. *** ## Govern it Jordan studies a governance approval chain on her tablet, silver hoop earrings catching the lamplight, her expression focused and deliberate Before any of Sam's campaigns go live, they pass through governance. Jordan, Pinnacle's campaign ops manager, set up the governance framework before Alex let any agent spend money. `check_governance` runs automatically before execution — budget limits, brand safety, targeting compliance: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/governance/check-governance-request.json", "plan_id": "acme_outdoor_q2_plan", "caller": "https://buyer.pinnacle-agency.com/a2a", "tool": "create_media_buy", "payload": { "brand": { "domain": "acmeoutdoor.com" }, "start_time": "2026-04-01T00:00:00Z", "end_time": "2026-06-30T23:59:59Z" } } ``` If everything passes, the campaign proceeds. If something exceeds the agent's authority — say, the budget is above Jordan's \$20K auto-approval threshold — the governance agent escalates to a human. Jordan reviews, adds conditions if needed, and approves. The agent can't skip this step; it's architectural, not procedural. After the campaign runs, `get_plan_audit_logs` provides the complete decision trail — who proposed what, who approved it, what conditions were attached, what actually ran. Every decision is recorded and attributable. ```json theme={null} { "entries": [ { "timestamp": "2026-03-15T14:30:00Z", "actor": "buyer_agent", "action": "submit_plan", "details": { "budget": 50000, "channels": ["ctv", "olv"] } }, { "timestamp": "2026-03-15T14:30:01Z", "actor": "governance_agent", "action": "escalate", "reason": "Budget exceeds auto-approval threshold ($20,000)" }, { "timestamp": "2026-03-15T15:12:00Z", "actor": "jordan@pinnacleagency.com", "action": "approve_with_conditions", "conditions": ["Weekly spend cap of $15,000", "CTV only — no OLV until brand safety review"] } ] } ``` The governance walkthrough — from nightmare to audit trail. *** ## Track performance Sam stands before a large performance dashboard with trending charts, coffee in hand, turning back with a confident expression — everything is on track Sam's campaign is live. In the old world, he'd check four dashboards. Now, `get_media_buy_delivery` aggregates performance from every seller into one response: ```json theme={null} { "impressions": 1250000, "clicks": 18750, "spend": { "amount": 34200, "currency": "USD" }, "by_package": [ { "product_id": "streamhaus_sports_ctv_q2", "impressions": 750000, "completion_rate": 0.87 } ] } ``` For deeper performance tracking, AdCP provides two more tools: **`log_event`** records marketing events — purchases, leads, sign-ups — back to the sellers for attribution and optimization: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/log-event-request.json", "idempotency_key": "a5b6c7d8-e9f0-4123-a456-123456789012", "event_source_id": "acme_website_pixel", "events": [ { "event_id": "evt_abc123", "event_type": "purchase", "event_time": "2026-05-15T10:30:00Z", "action_source": "website", "custom_data": { "value": 149.99, "currency": "USD" } } ] } ``` **`provide_performance_feedback`** closes the optimization loop — telling sellers what's working and what isn't, so their algorithms can adjust: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-request.json", "idempotency_key": "b6c7d8e9-f0a1-4234-b567-234567890123", "media_buy_id": "mb_acme_q2_001", "measurement_period": { "start": "2026-04-01T00:00:00Z", "end": "2026-04-30T23:59:59Z" }, "performance_index": 1.35 } ``` *** ## Connect your store Overhead view of a phone showing a product catalog connected by a glowing teal line to a laptop campaign interface — catalog data flowing between them Acme Outdoor has a Shopify store with 200 products. They want their catalog available to AI platforms — AI assistants that recommend products, AI search engines that surface them, retail media networks that need feed data. `sync_catalogs` pushes the product feed to every connected platform: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-catalogs-request.json", "idempotency_key": "b8e2c5f6-4567-48d3-4567-89012345abcd", "account": { "brand": { "domain": "acmeoutdoor.com" }, "operator": "pinnacle-agency.com" }, "catalogs": [ { "catalog_id": "acme_outdoor_products", "name": "Acme Outdoor Product Feed", "type": "product", "url": "https://acmeoutdoor.com/feeds/products.json", "feed_format": "shopify", "update_frequency": "daily" } ] } ``` The seller ingests the catalog and makes it available for product-level targeting, dynamic creative, and conversational recommendations. When a product goes out of stock or a price changes, the feed updates and the seller syncs automatically. *** ## Protect the brand Tomoko stands in a corporate lobby before a frosted glass display of brand identity elements — composed and assured, she controls what goes out Tomoko manages brand operations at Acme Outdoor's parent company, Nova Motors. She published Nova's `brand.json` — a machine-readable brand identity that AI agents consume directly: ``` https://novamotors.com/.well-known/brand.json ``` ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/brand.json", "house": { "domain": "novamotors.com", "name": "Nova Motors" }, "brands": [ { "id": "acme_outdoor", "names": [{ "en": "Acme Outdoor" }], "identity_agent": { "url": "https://brand.novamotors.com/mcp", "id": "nova_brand_agent" } } ] } ``` When Maya's creative agents generate ads, they pull brand guidelines directly from `brand.json` and the `get_brand_identity` task — colors, logos, tone, visual guidelines. No brand guide PDF. No manual asset handoff. The brand controls what AI agents see, and the protocol enforces it. For campaigns using licensed talent or third-party IP, the brand protocol handles [rights licensing](/dist/docs/3.1.20/brand-protocol/walkthrough-rights-licensing) — discovery, acquisition, creative approval, and lifecycle management, all through the same protocol. Brand identity, rights licensing, and how brands control what AI does with their assets. *** ## The full picture Alex started with twelve platforms, twelve integrations, and a team drowning in platform mechanics. Now her team works through one protocol: | What they need | How AdCP handles it | Key tasks | | -------------------- | ------------------------------- | ----------------------------------------------------------- | | Find new partners | Publisher discovery + registry | `adagents.json`, Registry API | | Set up relationships | Standardized onboarding | `sync_accounts`, `list_accounts` | | Discover inventory | One brief, every seller | `get_products` (brief + refine modes) | | Build creative | One brief, every format | `build_creative`, `list_creative_formats`, `sync_creatives` | | Execute campaigns | One buy, multiple sellers | `create_media_buy`, `update_media_buy` | | Add targeting data | Audiences + third-party signals | `sync_audiences`, `get_signals`, `activate_signal` | | Govern everything | Human oversight, built in | `check_governance`, `get_plan_audit_logs` | | Track performance | Unified reporting + events | `get_media_buy_delivery`, `log_event` | | Connect commerce | Product catalog sync | `sync_catalogs` | | Protect the brand | Machine-readable identity | `brand.json`, `get_brand_identity` | Sam buys media. Maya builds creative. Jordan governs. Kai provides data. Tomoko protects the brand. They all speak the same protocol. *** ## How it works underneath AdCP doesn't assume a single AI handles everything. Specialized agents handle what they're best at: * **Media buying agents** discover inventory and execute campaigns * **Creative agents** generate and adapt ads across formats * **Signals agents** find and activate audiences * **Governance agents** enforce brand safety and compliance * **Orchestrators** coordinate the workflow and make sure humans approve what matters These agents communicate over two transport protocols: **MCP** (for AI assistants calling tools) and **A2A** (for agent-to-agent collaboration). Same tasks, same schemas, different transport. **How you know an agent does what it claims:** Every agent tells the network which broad areas it handles and which specific flows it supports — and those claims are testable. The protocol ships compliance storyboards that a runner executes against the agent. If it passes, the claim is verifiable. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog). ## Brief to live ads Here's what Alex's team does now: 1. **Write a brief**: "Find premium video inventory on sports publishers for Q2 with a \$50K budget" 2. **Agents discover options**: `get_products` goes to every connected seller simultaneously 3. **Compare proposals**: Products come back in a standard format — pricing, forecasts, targeting — all comparable 4. **Agents build creatives**: `build_creative` adapts assets to each seller's formats 5. **Approve and launch**: `create_media_buy` executes across platforms in one call 6. **Match at serve time**: TMP Context Match + Identity Match activate packages at each impression, with structural privacy separation 7. **Monitor delivery**: `get_media_buy_delivery` aggregates performance from every seller into one view Each step uses a standard AdCP task with a JSON Schema-defined request and response. No platform-specific code. No manual translation between systems. ## Trust through governance When AI agents spend money autonomously, trust requires structure. AdCP's governance layer provides it: * **Before a campaign launches**: `check_governance` validates budget limits, brand safety, and regulatory compliance * **If something exceeds authority**: The governance agent escalates to a human — your team approves, not the AI * **While campaigns run**: Governance agents monitor delivery against approved parameters * **After delivery**: `get_plan_audit_logs` provides a complete decision trail — who proposed what, who approved it, what actually ran Governance isn't a gate that slows things down. It's the safety net that lets you give agents more autonomy over time. ## Where do you want to start? For brands, agencies, and businesses who want to advertise on AI surfaces For platforms, publishers, and developers implementing the protocol ## Get started Ask questions about AdCP, explore the protocol, and test tasks — no code required JavaScript, Python, and Go libraries with CLI tools for testing Create and validate your brand's brand.json file Validate or create your publisher's adagents.json file Browse registered agents, brands, and publishers Choose between MCP and A2A, learn implementation patterns ## See it in action Follow Sam through a complete campaign — brief to delivery Real-time package activation with structural privacy separation Follow Maya through creative generation and distribution Follow Jordan through the trust model that protects your spend ## For platform providers AI is buying ads. Make sure it can buy yours. If you operate a DSP, SSP, publisher, data platform, creative platform, governance service, or any ad tech solution, AdCP lets AI agents discover and transact with your platform. To get started: 1. **Implement an AdCP agent** — Expose your platform's capabilities as AdCP tasks over MCP or A2A. Start with `get_adcp_capabilities`. 2. **Publish discovery and authorization records** — Use `brand.json` for operator identity, agent endpoints, and signing-key discovery; use publisher `adagents.json` for properties and authorized agents. 3. **Test your implementation** — Validate with [Addie](https://adcontextprotocol.org/chat) or the [client SDKs](/dist/docs/3.1.20/building/schemas-and-sdks). Implement the protocol domains relevant to your business: * **Publishers and SSPs**: [Media Buy](/dist/docs/3.1.20/media-buy) and [adagents.json](/dist/docs/3.1.20/governance/property) * **Data providers**: [Signals](/dist/docs/3.1.20/signals/overview) and [data provider guide](/dist/docs/3.1.20/signals/data-providers) * **Creative platforms**: [Creative](/dist/docs/3.1.20/creative) * **Governance vendors**: [Governance protocol](/dist/docs/3.1.20/governance/overview) * **Brands**: [Brand Protocol](/dist/docs/3.1.20/brand-protocol) and [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json) ## For advertisers and agencies Run campaigns across more platforms without scaling your team. AdCP-enabled agents work across all your media partners through a single interface — the same tasks buy CTV inventory, activate audience data, and manage creatives regardless of which platform you're working with. 1. **Read the buyer's guide** — The [monetizing AI guide](/dist/docs/3.1.20/sponsored-intelligence/monetizing-ai) explains how this works for brands, agencies, and SMBs. 2. **Check platform support** — See which of your media partners support AdCP, or browse the [registry](/dist/docs/3.1.20/registry). 3. **Try it with Addie** — [Ask Addie](https://adcontextprotocol.org/chat) to walk you through the protocol — no code required. 4. **Build your own agent** — No engineering team required. The [certification program](/dist/docs/3.1.20/learning/overview) teaches anyone to build a working advertising agent through vibe coding — describe what you want, an AI coding assistant writes the code. 5. **Connect with your team** — Share the [building guide](/dist/docs/3.1.20/building) and [client SDKs](/dist/docs/3.1.20/building/schemas-and-sdks) with your technical team to start integrating. ### Client libraries ```bash JavaScript/TypeScript theme={null} npm install @adcp/sdk ``` ```bash Python theme={null} pip install adcp ``` ```bash Go theme={null} go get github.com/adcontextprotocol/adcp-go/adcp ``` * **NPM**: [@adcp/sdk](https://www.npmjs.com/package/@adcp/sdk) | [GitHub](https://github.com/adcontextprotocol/adcp-client) * **PyPI**: [adcp](https://pypi.org/project/adcp/) | [GitHub](https://github.com/adcontextprotocol/adcp-client-python) * **Go**: [adcp-go](https://github.com/adcontextprotocol/adcp-go) ## Open-source examples The [Prebid Sales Agent](https://github.com/prebid/salesagent) is a full-stack seller agent (Python backend, TypeScript protocol layer) with GAM integration, built by a Prebid working group. It is a community example, not a maintained reference implementation. To build your own agent, start with the [official SDKs](/dist/docs/3.1.20/building/schemas-and-sdks) and [skill files](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent). ## Organization AdCP is a project of [AgenticAdvertising.org](https://agenticadvertising.org), an industry organization of publishers, platforms, agencies, and technology providers advancing open standards for AI-powered advertising. Members join AgenticAdvertising.org to develop and adopt the protocol. Foundation governance — structure, voting classes, Board composition, specification lifecycle, and conduct rules — is summarized in the repository's [CHARTER](https://github.com/adcontextprotocol/adcp/blob/main/CHARTER.md) and published at [agenticadvertising.org/governance](https://agenticadvertising.org/governance). ## Need help? * Browse the documentation * Ask in [Slack Community](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) * Email: [support@adcontextprotocol.org](mailto:support@adcontextprotocol.org) # Commerce Media Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/commerce-media Commerce media in AdCP: why retail media networks should stand up agentic storefronts instead of building self-service platforms, and how to model sponsored products, in-store digital, and closed-loop attribution. Every retail media network that builds a self-service ad platform ends up in the same place: competing for agency attention against the largest grocery, pharmacy, and general merchandise retailers — each asking brands to learn one more interface, manage one more login, reconcile one more set of reports. There's an alternative. Stand up an agentic storefront: expose your inventory so buyer agents can discover, transact on, and measure it through the same protocol they use for every other seller. When agents handle discovery, the advantage shifts from UI quality to data quality — and data is where most retailers already have a moat. ## The storefront, not the platform Daniel reviews a cluttered platform roadmap on a wall screen — dashboard mockups, audience builders, and reporting UIs pile up while he looks skeptical Daniel runs retail media at ShopGrid, a marketplace with 200M monthly shoppers and deterministic purchase data from its loyalty program. When he was tasked with building ShopGrid's ad business, the roadmap looked familiar: self-service campaign tool, proprietary audience builder, custom reporting dashboard, eventually a DSP. He got halfway through the build before he realized the problem. The brands ShopGrid wanted to attract — CPG companies, health and beauty brands, consumer electronics — were already managing campaigns across a dozen retail media platforms. Each platform had its own API, its own audience taxonomy, its own reporting format. Asking brands to integrate yet another platform meant competing on UI quality against companies with 100x the engineering budget. Daniel walks a retail floor as data streams flow from his tablet to glowing digital screens on end-caps and checkout lanes — the store as a data asset ShopGrid's moat wasn't going to be its dashboard. The moat was the data: deterministic purchase attribution from millions of loyalty members, real-time inventory across thousands of stores, and in-store digital screens at the point of purchase. The question was how to make that data and inventory accessible to the broadest possible set of buyers. Daniel at his desk as five retail media product cards radiate outward from his monitor — sponsored products, display, video, in-store, and premium placements being published Daniel stood up an AdCP sales agent instead. ShopGrid's entire retail media catalog — sponsored products, on-site display, in-store screens — is exposed as products that any buyer agent can discover via `get_products`. The loyalty data powers closed-loop attribution reported through standard delivery metrics. The store locations are modeled as catalogs with catchment areas for proximity targeting. Split scene — Sam runs a brief from his agency desk as search beams connect to Daniel's retail media products floating on the right, discovery in action The result: any buyer agent that speaks AdCP can transact on ShopGrid inventory without a custom integration. Sam at Pinnacle Agency discovered ShopGrid's sponsored products while running a cross-retailer campaign for Summit Foods — through the same protocol and brief he used for other retailers. What every retail media team actually wants is the control of self-serve, the ease of managed service, and all the data in their own internal tools. A self-service platform gives you control but requires massive engineering investment. Managed service is easy for buyers but doesn't scale. An agentic storefront resolves the tension: the retailer defines the products, pricing, and rules (control); buyer agents handle discovery and execution without a sales rep in the loop (ease); and because the retailer runs the MCP server, every transaction flows through their infrastructure (data stays home). This doesn't mean ShopGrid abandoned everything else. Brands that aren't using buyer agents still need basic self-service access. But the agentic storefront is the growth layer — it's how ShopGrid reaches buyers who would never have integrated a mid-size retailer's proprietary platform. The table below maps familiar retail media concepts to their AdCP equivalents. ## Concept mapping | Retail media concept | AdCP equivalent | Reference | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Retailer / retail media network | Sales agent (MCP server) | [`adagents.json`](/dist/docs/3.1.20/media-buy/advanced-topics/accounts-and-security) | | Advertiser account with retailer | `account_id` + `list_accounts` | [Accounts & Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) | | Brand's product catalog | `brand.product_catalog` | [Brand identity](/dist/docs/3.1.20/brand-protocol/brand-json) | | GTIN / SKU selection for promotion | Catalog selectors (`ids`, `gtins`, `tags`) on `Catalog` | [Catalogs](/dist/docs/3.1.20/creative/catalogs) | | Sponsored product listing | Product with catalog-rendered creative | [Creative Formats](/dist/docs/3.1.20/creative/formats) | | On-site display / video | Product with standard `format_ids` | [Media Products](/dist/docs/3.1.20/media-buy/product-discovery/media-products) | | Retailer-owned shopper signal | Brief-based; optional named segments discovered in `signal_targeting_options` and selected with package-level `signal_targeting_groups` | [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) | | ROAS target / max conversions | `optimization_goals` on package | [Optimization & Reporting](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) | | Closed-loop measurement | `outcome_measurement` + `conversion_tracking` on product | [Delivery Reporting](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) | | Attribution window (14d click, 1d view) | `attribution_window` in delivery response | [Delivery Reporting](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) | | ROAS / attributed revenue | `roas`, `conversion_value` in delivery metrics | [Delivery Reporting](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) | | In-store attribution | `by_action_source` with `in_store` | [Delivery Reporting](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) | | Store locations / store locator | Store catalog (seller-provided or buyer-synced) | [Catalogs — Stores](/dist/docs/3.1.20/creative/catalogs#stores) | | Real-time inventory / in-stock | Inventory catalog via `sync_catalogs` | [Catalogs](/dist/docs/3.1.20/creative/catalogs) | | Proximity / catchment targeting | Store catchment areas (isochrone, radius, GeoJSON) | [Catalogs — Catchment areas](/dist/docs/3.1.20/creative/catalogs#catchment-areas) | Each retailer is a separate sales agent. Their media offerings are modeled as products. The buyer's brand identity carries the product catalog for SKU-level creative rendering. Account relationships between brands and retailers are managed via `list_accounts` and `account` on media buys — see [Accounts & Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents). ## The product spectrum Retail media networks offer diverse product types — not just sponsored listings. All share the `retail_media` channel, but differ in format, pricing, and creative requirements. Daniel modeled ShopGrid's full portfolio as separate products so buyer agents can mix and match across the funnel. ### Sponsored product listings The simplest commerce media product. The retailer renders the creative from the buyer's product catalog — no custom creative upload needed. Pricing is typically CPC. ```json theme={null} { "product_id": "shopgrid_sponsored_products", "name": "Sponsored Products - Search & Browse", "description": "Sponsored product listings in search results and category pages. Products are rendered from retailer catalog data.", "channels": ["retail_media"], "publisher_properties": [ { "publisher_domain": "shopgrid.example", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://ads.shopgrid.example", "id": "sponsored_product_listing" } ], "delivery_type": "non_guaranteed", "pricing_options": [ { "pricing_option_id": "sp_cpc", "pricing_model": "cpc", "floor_price": 0.25, "price_guidance": { "p50": 0.85, "p75": 1.20 }, "currency": "USD", "min_spend_per_package": 50 } ], "delivery_measurement": { "provider": "ShopGrid deterministic purchase attribution", "notes": "Deterministic purchase attribution from first-party shopper data. 14-day lookback." }, "outcome_measurement": { "type": "attributed_sales", "attribution": "deterministic_purchase", "window": { "interval": 14, "unit": "days" }, "reporting": "daily_api" }, "conversion_tracking": { "action_sources": ["website", "app", "in_store"], "supported_targets": ["cost_per", "per_ad_spend"], "platform_managed": true }, "creative_policy": { "co_branding": "none", "landing_page": "any", "templates_available": true }, "catalog_types": ["product"], "catalog_match": { "matched_gtins": ["00013000006408", "00013000006415", "00013000006422"], "matched_count": 3, "submitted_count": 1200 } } ``` The `catalog_types` field declares what catalog types this product supports. The `catalog_match` field tells the buyer which of their catalog items are eligible on this retailer. Buyers use these values as catalog selectors (`gtins`, `ids`) when creating media buys — the same way `publisher_properties` lists available properties for refinement. Sellers can include `matched_gtins`, `matched_ids`, or both. Sponsored product listings are catalog-rendered — the retailer pulls title, price, image, and rating from its own catalog data matched via GTIN. Enhanced product content (comparison charts, lifestyle galleries, brand story modules) and brand stores are complementary content strategies managed through the retailer's content systems, not through the ad buy. Key characteristics: * **CPC pricing** with auction-based bidding * **`platform_managed: true`** — the retailer provides always-on purchase attribution * **`supported_targets`** — tells buyers which target kinds are available when setting `optimization_goals` on packages * **`templates_available: true`** — the retailer renders creatives from catalog data * **`action_sources`** includes `in_store` for omnichannel attribution ### On-site display and video Display and video ads on retailer properties, targeted using retailer first-party shopper data. Buyers provide standard creatives. Higher minimums and CPMs reflect the value of retailer audience data and guaranteed placement. ```json theme={null} { "product_id": "shopgrid_onsite_display", "name": "Category Shoppers - On-Site Display & Video", "description": "Display and video ads on ShopGrid marketplace and app, targeting shoppers with relevant purchase history.", "channels": ["retail_media"], "publisher_properties": [ { "publisher_domain": "shopgrid.example", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "onsite_cpm", "pricing_model": "cpm", "fixed_price": 14.00, "currency": "USD", "min_spend_per_package": 10000 } ], "delivery_measurement": { "provider": "Retailer ad server with IAS viewability", "notes": "Impressions per IAB guidelines. MRC-accredited viewability." }, "outcome_measurement": { "type": "incremental_sales_lift", "attribution": "deterministic_purchase", "window": { "interval": 30, "unit": "days" }, "reporting": "weekly_dashboard" }, "conversion_tracking": { "action_sources": ["website", "app"], "supported_targets": ["cost_per", "per_ad_spend"], "platform_managed": true }, "creative_policy": { "co_branding": "required", "landing_page": "retailer_site_only", "templates_available": true } } ``` Note the creative policy: retailers commonly require co-branding and restrict landing pages to the retailer's own site. ### Off-site audience extension The retailer uses first-party purchase data to target audiences on third-party inventory — extending reach beyond the retailer's own site. The product still belongs to the `retail_media` channel because the buying context is the retailer's data asset. ```json theme={null} { "product_id": "shopgrid_offsite_extension", "name": "Shopper Audiences - Off-Site Display & Video", "description": "Reach ShopGrid shoppers across premium display and video inventory using first-party purchase data.", "channels": ["retail_media"], "publisher_properties": [ { "publisher_domain": "shopgrid.example", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "offsite_cpm", "pricing_model": "cpm", "fixed_price": 13.50, "currency": "USD", "min_spend_per_package": 10000 } ], "delivery_measurement": { "provider": "Self-reported impressions from proprietary ad server", "notes": "Impressions counted per IAB guidelines. Viewability via IAS." }, "outcome_measurement": { "type": "incremental_sales_lift", "attribution": "deterministic_purchase", "window": { "interval": 30, "unit": "days" }, "reporting": "weekly_dashboard" }, "conversion_tracking": { "action_sources": ["website", "app", "in_store"], "supported_targets": ["cost_per", "per_ad_spend"], "platform_managed": true } } ``` ### Premium placements Homepage takeovers, category sponsorships, seasonal event placements. These high-visibility positions are sold at fixed rates with guaranteed delivery — often booked well in advance. ```json theme={null} { "product_id": "shopgrid_homepage_takeover", "name": "Homepage Takeover - 24 Hours", "description": "Exclusive homepage banner and hero placement for 24 hours.", "channels": ["retail_media"], "publisher_properties": [ { "publisher_domain": "shopgrid.example", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_970x250" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } ], "placements": [ { "kind": "seller_inline", "placement_id": "homepage_hero", "name": "Homepage Hero Banner", "mode": "targetable", "format_ids": [{ "agent_url": "https://creative.adcontextprotocol.org", "id": "display_970x250" }] }, { "kind": "seller_inline", "placement_id": "homepage_sidebar", "name": "Homepage Sidebar", "mode": "targetable", "format_ids": [{ "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }] } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "takeover_flat", "pricing_model": "flat_rate", "fixed_price": 50000.00, "currency": "USD" } ], "delivery_measurement": { "provider": "Retailer ad server", "notes": "Guaranteed impressions based on homepage traffic projections." }, "outcome_measurement": { "type": "attributed_sales", "attribution": "deterministic_purchase", "window": { "interval": 14, "unit": "days" }, "reporting": "daily_api" }, "creative_policy": { "co_branding": "required", "landing_page": "retailer_site_only", "templates_available": false } } ``` Premium placements use the `placements` array so buyers can assign different creatives to different positions within the same product. ### In-store digital Digital screens in physical retail locations — checkout lanes, end-caps, entrance displays, and waiting areas. These bridge the digital-physical gap, with measurement tied to in-store purchases. When paired with a synced store catalog, in-store digital products can target specific locations and show inventory-aware creative. In-store is where retail media networks have inventory that no one else can replicate. A retailer with thousands of physical locations has screens at the point of purchase — reaching shoppers while they're browsing aisles, comparing products at the shelf, and waiting in line. Many of these shoppers never see digital ads elsewhere: they shop in-store exclusively, making them unreachable through online channels. ```json theme={null} { "product_id": "shopgrid_instore_screens", "name": "In-Store Digital Screens", "description": "Digital screens at checkout lanes, end-cap displays, and pharmacy waiting areas across 2,000+ locations.", "channels": ["retail_media"], "publisher_properties": [ { "publisher_domain": "shopgrid.example", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_1080x1920" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" } ], "placements": [ { "kind": "seller_inline", "placement_id": "checkout_screen", "name": "Checkout Lane Screens", "mode": "targetable", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_1080x1920" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" } ] }, { "kind": "seller_inline", "placement_id": "endcap_screen", "name": "Aisle End-Cap Displays", "mode": "targetable", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_1080x1920" } ] }, { "kind": "seller_inline", "placement_id": "pharmacy_waiting", "name": "Pharmacy Waiting Area", "mode": "targetable", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_1080x1920" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" } ] } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "instore_cpm", "pricing_model": "cpm", "fixed_price": 12.00, "currency": "USD", "min_spend_per_package": 5000 } ], "delivery_measurement": { "provider": "Venue traffic sensors", "notes": "Impressions estimated from foot traffic data. Updated weekly." }, "conversion_tracking": { "action_sources": ["in_store"], "platform_managed": true }, "creative_policy": { "co_branding": "none", "landing_page": "any", "templates_available": true } } ``` Different screen placements have different creative requirements. Checkout screens offer 30-90 seconds of captive dwell time with a stationary shopper at close range. End-cap displays catch shoppers walking past with 2-3 seconds of glance time from further away. Pharmacy waiting areas have extended dwell time and a seated audience. Most in-store networks are silent — creative should be designed for sound-off unless the placement specifies otherwise. In-store products pair with the retailer's store catalog. The catalog exposes store locations with catchment areas (radius, isochrone, or GeoJSON polygons), enabling buyer agents to target specific geographies or store clusters. When combined with the brand's product catalog, in-store screens can show contextually relevant creative — the right product, in the right store, near the right shelf. ### Beyond these examples Retail media portfolios often extend beyond the five product types above. Sponsored brand ads — a headline, brand logo, and 2-3 featured products — sit between sponsored product listings and display, with a custom headline from the buyer and catalog-rendered product tiles. Digital coupons and cashback offers tie directly to purchase and don't fit neatly into impression-based models — they can be modeled as products with CPA pricing and `conversion_tracking`. Sponsored placements in retailer email newsletters and app push notifications are high-performing, low-funnel products modeled the same way as on-site display with different `format_ids`. Shoppable video — where products are tagged within the video frame and viewers can add items to cart — combines video formats with catalog-rendered product overlays. Keyword-level search sponsorships, sampling programs, and recipe integrations each have distinct economics but follow the same pattern: a product with the right pricing model, format, and measurement declarations. ## End-to-end workflow ### Brand with product catalog The buyer provides a brand reference that includes their product catalog feed. This enables SKU-level targeting and catalog-rendered creatives: ```json theme={null} { "name": "Summit Foods", "url": "https://summitfoods.example.com", "product_catalog": { "feed_url": "https://summitfoods.example.com/products.xml", "feed_format": "google_merchant_center", "categories": ["food/sauces", "food/condiments", "beverages"], "update_frequency": "daily" } } ``` The feed contains GTINs, titles, prices, and images — everything needed to render sponsored product listings and match products across retailers. ### Sync catalogs to the account Before creating media buys, sync the brand's data feeds to the retailer's account via `sync_catalogs`. This builds account state that creatives and targeting reference at serve time. See [Account state](/dist/docs/3.1.20/building/by-layer/L2/account-state) for how this fits the broader setup sequence. ```json theme={null} { "account": { "account_id": "acct_summitfoods_shopgrid_001" }, "catalogs": [ { "catalog_id": "product-feed", "name": "Summit Foods Product Catalog", "type": "product", "url": "https://summitfoods.example.com/products.xml", "feed_format": "google_merchant_center", "update_frequency": "daily" }, { "catalog_id": "inventory-feed", "name": "Store-Level Inventory", "type": "inventory", "url": "https://feeds.summitfoods.example.com/inventory.json", "feed_format": "custom", "update_frequency": "hourly" } ] } ``` The platform ingests each feed: * **Product catalog** — validates GTINs against the retailer's own catalog, reports item-level approval status * **Inventory feed** — refreshes stock data hourly so creatives can show "In stock nearby" **Store locations are seller-provided, not buyer-synced.** In retail media, the retailer owns their store locations — a CPG brand doesn't sync the retailer's stores to the retailer. The retailer's store catalog (with catchment areas for proximity targeting) is platform-side data that the buyer can reference in targeting. Buyers who operate their own physical locations (e.g., a DTC brand with retail stores, a restaurant chain) would sync their store catalogs to other platforms via `sync_catalogs`. Formats that require synced catalogs declare `catalog` asset types in their `assets` array — the buying agent checks these before submitting creatives. A sponsored product carousel might require both a `product` and `inventory` catalog. ### Find products Query `get_products` with `channels: ["retail_media"]` to find commerce media products. The brand identity enables the seller to filter by catalog eligibility, and the `keywords` filter signals search terms you want to target so the seller can assess availability and recommend products where those terms are actionable: ```json theme={null} { "brief": "Promote our organic ketchup line across grocery retailers. Focus on high-intent shoppers.", "brand": { "domain": "summitfoods.example.com" }, "filters": { "channels": ["retail_media"], "keywords": [ { "keyword": "organic ketchup", "match_type": "exact" }, { "keyword": "condiments" } ] } } ``` The seller returns products where the buyer's GTINs have matches in the retailer's catalog. A multi-product retailer might return sponsored products, on-site display, and off-site extension as separate products. For sponsored product listings, sellers include `catalog_match` on each product to indicate which of the buyer's catalog items are eligible: ```json theme={null} { "product_id": "shopgrid_sponsored_products", "catalog_match": { "matched_gtins": ["00013000006408", "00013000006415", "..."], "matched_count": 3, "submitted_count": 1200 } } ``` This tells the buyer which GTINs are eligible on this retailer and how many of their total catalog items were evaluated. Buyers use `matched_gtins` values as catalog `gtins` selectors when creating media buys — or use broader selectors like `tags` and `category` knowing the catalog coverage. Sellers can also return [proposals](/dist/docs/3.1.20/media-buy/product-discovery/media-products#proposals) with recommended budget allocations across product types — encoding media planning expertise that traditionally required human sales reps. ### Create media buy A single media buy can span multiple product types and retailers. Each package targets a different product: ```json theme={null} { "account": { "account_id": "acct_summitfoods_shopgrid_001" }, "brand": { "domain": "summitfoods.example.com" }, "start_time": "2026-04-01T00:00:00Z", "end_time": "2026-04-30T23:59:59Z", "packages": [ { "product_id": "shopgrid_sponsored_products", "pricing_option_id": "sp_cpc", "budget": 15000, "bid_price": 1.10, "pacing": "even", "optimization_goals": [{ "kind": "event", "event_sources": [ { "event_source_id": "shopgrid_purchases", "event_type": "purchase", "value_field": "value" } ], "target": { "kind": "per_ad_spend", "value": 3.0 }, "priority": 1 }] }, { "product_id": "shopgrid_onsite_display", "pricing_option_id": "onsite_cpm", "budget": 25000, "pacing": "even" }, { "product_id": "shopgrid_homepage_takeover", "pricing_option_id": "takeover_flat", "budget": 50000 } ] } ``` The `account` identifies the brand's billing relationship with this retailer. The sponsored products package uses catalog selectors (`gtins`, `tags`) to specify which items to promote and `optimization_goals` with a `per_ad_spend` target to tell the retailer to optimize for 3x return on purchase value. The display and premium packages use standard creative workflows. ### Delivery reporting Commerce media delivery reports include attribution metrics that traditional media lacks. The `attribution_window` makes the measurement methodology transparent for cross-platform comparison: ```json theme={null} { "reporting_period": { "start": "2026-04-01T00:00:00Z", "end": "2026-04-14T23:59:59Z" }, "currency": "USD", "attribution_window": { "post_click": { "interval": 14, "unit": "days" }, "post_view": { "interval": 1, "unit": "days" }, "model": "last_touch" }, "media_buy_deliveries": [ { "media_buy_id": "mb_shopgrid_001", "status": "active", "totals": { "impressions": 850000, "spend": 10350 }, "by_package": [ { "package_id": "pkg_sp_001", "pricing_model": "cpc", "rate": 0.92, "currency": "USD", "impressions": 850000, "spend": 10350, "clicks": 11250, "conversions": 2100, "conversion_value": 28500, "roas": 2.75, "cost_per_acquisition": 4.93, "by_action_source": [ { "action_source": "website", "count": 1500, "value": 20000 }, { "action_source": "app", "count": 350, "value": 4800 }, { "action_source": "in_store", "count": 250, "value": 3700 } ], "delivery_status": "delivering" } ] } ] } ``` The `by_action_source` breakdown shows where conversions happened — website, app, and physical store. This omnichannel view is unique to commerce media. ## Closed-loop attribution Daniel and Sam view a closed-loop attribution dashboard — impressions flow through clicks, store visits, and purchases in a circular diagram with website, app, and in-store sources Commerce media's defining advantage is deterministic purchase attribution. Retailers match ad exposure to transactions using loyalty card data, login state, and point-of-sale records. This is the asset that makes retail media fundamentally different from every other media channel — and it's exactly what an agentic storefront exposes to buyer agents through standard delivery reporting. ### How products declare it Products signal closed-loop capability through two fields: **`reporting_capabilities.available_metrics`** declares which outcome metrics the product reports. Closed-loop retail media products typically report `conversions`, `conversion_value`, `roas`, `cost_per_acquisition`, `units_sold`, `new_to_brand_rate`, and incremental-lift metrics like `incremental_sales_lift` and `conversion_lift`. The methodology and attribution window flow through the `qualifier` slot on `committed_metrics` and `metric_aggregates` (`attribution_methodology: "deterministic_purchase"` for retail-media closed loop; `attribution_window: { interval: 14, unit: "days" }` for the 14-day match window). ```json theme={null} { "reporting_capabilities": { "available_metrics": [ "conversions", "conversion_value", "roas", "cost_per_acquisition", "units_sold", "new_to_brand_rate", "incremental_sales_lift" ] } } ``` The legacy `outcome_measurement` field is **deprecated** but continues to work for one minor. Existing payloads: ```json theme={null} { "outcome_measurement": { "type": "attributed_sales", "attribution": "deterministic_purchase", "window": { "interval": 14, "unit": "days" }, "reporting": "daily_api" } } ``` …are equivalent to declaring `conversion_value` (the standardized name for "attributed sales") in `available_metrics` plus the matching qualifier on `committed_metrics`. Migrate by populating both fields during the transition window; remove `outcome_measurement` at the next major. **`conversion_tracking`** declares action sources and whether the retailer manages measurement: ```json theme={null} { "conversion_tracking": { "action_sources": ["website", "app", "in_store"], "supported_targets": ["cost_per", "per_ad_spend"], "platform_managed": true } } ``` When `platform_managed` is `true`, the retailer provides always-on measurement. No buyer-side pixel or event source configuration needed. Different retailers use different attribution windows (e.g., 14-day click vs. 7-day click). The `attribution_window` in delivery responses makes this transparent so buyers can normalize ROAS comparisons across retailers. ### Key delivery metrics | Metric | Field | Description | | -------------------- | ---------------------- | ---------------------------------------------------- | | Return on ad spend | `roas` | `conversion_value / spend` | | Attributed revenue | `conversion_value` | Total purchase value attributed to ads | | Conversions | `conversions` | Purchase events attributed to ads | | Cost per acquisition | `cost_per_acquisition` | `spend / conversions` | | New-to-brand rate | `new_to_brand_rate` | Fraction of conversions from first-time brand buyers | | In-store sales | `by_action_source` | Breakdown by `website`, `app`, `in_store` | | By event type | `by_event_type` | Breakdown by `purchase`, `add_to_cart`, etc. | Daniel by a retail storefront and Priya by a streaming TV shape flank Sam in the center — different sell-side verticals connecting to one buyer through the same protocol ## Differences from traditional media buying | Aspect | Traditional media | Commerce media | | -------------------- | ------------------------ | --------------------------------------------------- | | Attribution | Probabilistic, modeled | Deterministic, purchase-based | | Targeting data | Third-party, contextual | First-party purchase/loyalty data | | Primary KPIs | Impressions, clicks, CTR | ROAS, conversions, attributed revenue | | Creative (sponsored) | Buyer-provided | Catalog-rendered by retailer | | Measurement owner | Third-party (IAS, DV) | Retailer platform (`platform_managed`) | | Conversion sources | Website only | Website + app + in-store | | Landing page | Any destination | Often retailer site only | | Pricing models | CPM, CPC, CPCV | CPC (sponsored), CPM (display), flat rate (premium) | ## Best practices ### For retailers: standing up an agentic storefront The default retail media playbook — build a self-service platform, hire a sales team, grow managed service revenue, eventually build a DSP — works for the largest retailers. For everyone else, it means years of engineering investment competing against platforms with orders-of-magnitude more resources. An agentic storefront changes the economics. Instead of building a platform that brands must learn, you expose your inventory through a standard protocol that buyer agents already speak. Your engineering investment goes into your actual differentiators — data quality, measurement accuracy, inventory breadth — not into dashboard UX. 1. **Model product types separately** — sponsored products, on-site display, off-site, premium placements, and in-store digital should be distinct products with appropriate pricing and formats. This is how buyer agents comparison-shop across retailers. 2. **Lead with your data** — declare `outcome_measurement` with `deterministic_purchase` attribution and `conversion_tracking` with `platform_managed: true`. This is what buyer agents optimize against. Retailers with weak measurement lose to retailers with strong measurement, regardless of UI quality. 3. **Expose your physical footprint** — provide store catalogs with catchment areas so buyer agents can target by geography. In-store digital inventory is something no online-only platform can offer. Make it discoverable. 4. **Return `catalog_match`** on sponsored product listings so buyers see which GTINs/SKUs are eligible. This is the equivalent of a sales rep saying "we carry 3 of your 1,200 products" — but it happens automatically at query time. 5. **Include `attribution_window`** in delivery responses — buyers comparing across retailers need to know your lookback windows and model. 6. **Report `by_action_source`** to show omnichannel impact — website, app, and in-store conversions. The cross-channel view is unique to commerce media and drives budget allocation. 7. **Use proposals** — return recommended budget allocations across product types with `get_products` responses. This encodes your media planning expertise into the protocol, replacing the sales rep conversation with structured data that buyer agents can evaluate. 8. **Set `channels: ["retail_media"]`** on all commerce media products so buyers can filter by channel. ### For brands 1. **Set up accounts early** — use `list_accounts` to confirm your billing relationship with each retailer before placing buys 2. **Sync product and inventory feeds early** — product and inventory catalogs should be synced to the account before creating buys. Inventory feeds update hourly; product feeds daily. 3. **Reference retailer store catalogs in targeting** — the retailer's store locations and catchment areas are platform-side data. Use them for proximity targeting without needing to sync your own store catalog. 4. **Use `optimization_goals`** — set event goals with `cost_per` or `per_ad_spend` targets on packages to let the retailer optimize delivery against their purchase data 5. **Use catalog selectors strategically** — `gtins` for specific products, `tags` or `category` for broad promotions 6. **Budget across the funnel** — sponsored products for conversion, on-site display for awareness, off-site for reach extension 7. **Compare attribution windows** — use `attribution_window` in delivery reports to normalize ROAS across retailers with different lookback windows ## Related documentation * [Account state](/dist/docs/3.1.20/building/by-layer/L2/account-state) — How catalogs, event sources, and campaigns build on the account * [Accounts & Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) — Account setup and billing relationships * [Media Channel Taxonomy](/dist/docs/3.1.20/reference/media-channel-taxonomy) — `retail_media` channel definition * [Media Products](/dist/docs/3.1.20/media-buy/product-discovery/media-products) — Product model reference * [Catalogs](/dist/docs/3.1.20/creative/catalogs) — Product, inventory, store, and promotion feeds * [Brand identity](/dist/docs/3.1.20/brand-protocol/brand-json) — Product catalog and brand identity * [Pricing Models](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models) — CPC, CPM, flat rate details * [Optimization & Reporting](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) — `optimization_goals` and conversion optimization * [Delivery Reporting](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) — Commerce attribution metrics * [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) — Brief-based targeting approach # Media buy protocol Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/index AdCP media buy protocol walkthrough — follow a campaign from brief to delivery across CTV, display, and audio using one unified workflow. A media buyer sits at a desk surrounded by four different platform dashboards, each showing different data formats and interfaces Sam is a media buyer at Pinnacle Agency. His client just greenlit a \$50,000 Q2 campaign for Acme Outdoor's Trail Pro 3000 — premium video and display across sports and outdoor lifestyle publishers. Three sellers to evaluate. Creatives to match. A governance review before anything goes live. Last time he ran a campaign this size, it took two weeks. Four dashboards. Four logins. A spreadsheet to compare proposals that were never in the same format. He entered the same flight dates into four different systems. This walkthrough follows Sam through the same campaign on AdCP — one protocol that replaces four platform-specific workflows. ## Step 1: Write the brief Sam starts with what he knows: the campaign objectives. Sam's brief glows on screen and radiates outward as clean teal rays to three seller agent robots, each standing at their own podium examining the incoming request In AdCP, the brief is natural language inside `get_products`. Sam doesn't need to learn each publisher's targeting taxonomy or inventory categories — he describes what he wants, and each sales agent interprets it against their own inventory: ```javascript theme={null} const products = await Promise.all( sellers.map(seller => seller.getProducts({ buying_mode: "brief", brief: "Premium video inventory on sports and outdoor lifestyle publishers. Q2 flight, $50K budget. Adults 25-54, US and Canada.", brand: { domain: "acmeoutdoor.com" }, account: { brand: { domain: "acmeoutdoor.com" }, operator: "pinnacle-agency.com" } })) ); ``` One brief. Three sellers. Same JSON structure back. | What Sam says | What the protocol calls it | | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | Campaign brief | `brief` field on `get_products` | | Media plan | Products returned from `get_products` | | IO / insertion order | `create_media_buy` | | Trafficking creatives | `sync_creatives` for library-backed sellers, or inline `packages[].creatives` for inline-only sellers | | Campaign report | `get_media_buy_delivery` across agents | | Flight dates | `start_time` / `end_time` on the media buy or packages | | Viewability / IVT / completion SLAs | `performance_standards` on packages — see [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability) | | Makegoods | `makegood_policy` in `measurement_terms` — see [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability) | ## Step 2: Compare proposals Three seller robots present their offerings — a video slate, a display banner, and a podcast waveform — while Sam reviews them side by side on a single clean screen, looking pleased Products come back in a standard format. For the first time, Sam sees pricing, delivery forecasts, targeting options, and creative requirements — all side by side: | Seller | Product | CPM | Forecast | Format | | ---------- | --------------------------- | ---- | ---------------- | --------------------- | | StreamHaus | CTV sports pre-roll | \$28 | 890K impressions | SSAI 30s video | | OutdoorNet | Adventure lifestyle display | \$12 | 2.1M impressions | 300x250, 728x90 | | PodTrail | Outdoor podcast mid-roll | \$22 | 340K impressions | Audio 30s + companion | No CSVs. No spreadsheets. No manual data entry. The products are comparable because every seller returns the same schema. Sam wants to narrow down. He switches to `refine` mode, telling each agent exactly how to adjust: ```javascript theme={null} const refined = await seller.getProducts({ buying_mode: "refine", refine: [ { scope: "request", ask: "Only guaranteed packages. Must include completion rate SLA above 80%." } ], brand: { domain: "acmeoutdoor.com" }, account: { brand: { domain: "acmeoutdoor.com" }, operator: "pinnacle-agency.com" } }); ``` The `refine` array lets Sam layer constraints without starting over. Each refinement narrows the previous result set. ## Step 3: Match creatives Creative format templates — a 16:9 video frame, a 300x250 banner, and an audio waveform with companion — snap together with actual ad assets like puzzle pieces, assisted by a small robot Each product has creative requirements. Sam's platform calls `list_creative_formats` on each seller to understand exactly what they need: * **StreamHaus** needs SSAI-compatible 30s video (MP4, specific codecs) * **OutdoorNet** needs display banners (300x250 and 728x90) * **PodTrail** needs 30s audio plus a 300x250 companion banner Sam's creative team already has assets in their library. The platform matches existing manifests to each seller's format requirements and flags the gap — PodTrail needs an audio cut that doesn't exist yet. ```javascript theme={null} const result = await seller.syncCreatives({ account: { brand: { domain: "acmeoutdoor.com" }, operator: "pinnacle-agency.com" }, creatives: [ { creative_id: "video_30s_trail_pro", name: "Trail Pro 3000 - 30s CTV Spot", format_id: { agent_url: "https://streamhaus.example", id: "ssai_30s" }, assets: { video: { url: "https://cdn.pinnacle-agency.example/trail-pro-30s.mp4", mime_type: "video/mp4" } } }, { creative_id: "display_trail_pro_300x250", name: "Trail Pro 3000 - Display 300x250", format_id: { agent_url: "https://outdoornet.example", id: "display_300x250" }, assets: { image: { url: "https://cdn.pinnacle-agency.example/trail-pro-300x250.png", mime_type: "image/png" } } } ] }); if (result.errors) { console.error('Sync failed:', result.errors); } else { console.log(`Synced ${result.creatives.length} creatives`); } ``` ## Step 4: Launch the campaign Sam presses a glowing launch button and a holographic campaign blueprint materializes above his desk, showing three branches — CTV, display, and audio — with budget amounts flowing along each branch Sam creates the media buy. One call per seller, same structure everywhere: ```javascript theme={null} const buy = await seller.createMediaBuy({ account: { brand: { domain: "acmeoutdoor.com" }, operator: "pinnacle-agency.com" }, brand: { domain: "acmeoutdoor.com" }, start_time: "2026-04-01T00:00:00Z", end_time: "2026-06-30T23:59:59Z", packages: [{ product_id: "streamhaus_sports_preroll_q2", budget: 25000, pricing_option_id: "cpm_standard", creative_assignments: [{ creative_id: "video_30s_trail_pro" }] }] }); ``` The seller validates the creatives and either approves the buy or sends it through review. Sam doesn't log into any dashboard — the protocol handles status updates. ## Step 5: Governance checks The campaign blueprint passes through a security checkpoint staffed by a governance robot who scans each branch, stamping three green checkmarks for budget, brand safety, and targeting compliance Before any money moves, Sam's governance agent validates the buy: * **Budget**: \$25K is within Sam's authorized spending limit * **Brand safety**: StreamHaus is on Acme Outdoor's approved publisher list * **Compliance**: Targeting parameters meet regulatory requirements for US and Canada * **Creative**: All creatives carry required provenance metadata If the buy exceeds Sam's authority — say, if the total across all sellers hit \$75K — the governance agent escalates to his manager. The `create_media_buy` task sits in `submitted` (or `input-required`) at the task layer until a human signs off; only once approved does the task complete and the media buy get its `media_buy_id`. Campaign governance requires the orchestrator to register the campaign plan via [`sync_plans`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans) before any governance checks. The plan defines authorized parameters — budget limits, channels, flight dates, and compliance policies — against which all subsequent actions are validated. The full governance sequence is `sync_plans` → `check_governance` (proposed) → `create_media_buy` → `check_governance` (committed by seller). See [Campaign Governance](/dist/docs/3.1.20/governance/campaign/index) for the complete specification. ```javascript theme={null} // Step 1: Register the campaign plan with the governance agent const plan = await governance.syncPlans({ plans: [{ plan_id: "acme-q2-trail-pro", brand: { domain: "acmeoutdoor.com" }, objectives: "Q2 Trail Pro 3000 launch across sports and outdoor lifestyle publishers", budget: { total: 50000, currency: "USD", reallocation_threshold: 5000 }, flight: { start: "2026-04-01T00:00:00Z", end: "2026-06-30T23:59:59Z" }, countries: ["US", "CA"] }] }); // Step 2: Check governance before sending to seller (intent check) const check = await governance.checkGovernance({ plan_id: "acme-q2-trail-pro", caller: "https://orchestrator.pinnacle-agency.example", tool: "create_media_buy", payload: buy }); if (check.status === "denied") { // Don't proceed — governance rejected the plan } // Step 3: Send the buy to the seller with governance_context attached const governanceContext = check.governance_context; const mediaBuy = await seller.createMediaBuy({ ...buy, governance_context: governanceContext }); // Step 4: The seller independently calls check_governance with media_buy_id + // planned_delivery before confirming — validating against the same plan ``` ## Step 6: Match at serve time The campaign is approved and live. When a user loads a StreamHaus page, opens OutdoorNet's app, or asks an AI assistant a question, the publisher's TMP Router evaluates which of Sam's packages should activate. Two operations run separately — Context Match asks "does this content fit the package's targeting?" while Identity Match asks "is this user eligible?" The publisher joins both responses locally. Sam's buyer agent never sees user identity and content context together — the structural separation is built into the protocol. The same flow works on every surface. Sam didn't write surface-specific activation code for CTV versus web versus AI. TMP handles all of them. When a user visits a StreamHaus article about hiking gear: 1. StreamHaus sends a Context Match request with the article's content signals and Sam's available packages 2. Sam's buyer agent responds: "Activate pkg-outdoor-display — this hiking content matches the targeting" 3. StreamHaus sends a separate Identity Match request with a user token and ALL of Sam's active packages 4. Sam's buyer agent responds: "This user is eligible for pkg-outdoor-display (intent\_score: 0.82)" 5. StreamHaus joins the results locally and activates the line item See the [Trusted Match Protocol](/dist/docs/3.1.20/trusted-match) for the full specification. ## Step 7: Monitor delivery Sam leans back at his desk, relaxed, with a single clean dashboard showing unified performance charts from all three sellers — bar charts rising, line graphs converging, all in teal The campaign is running. Sam monitors through a single view — his platform calls `get_media_buy_delivery` on each seller and merges the results: ```javascript theme={null} const delivery = await seller.getMediaBuyDelivery({ account: { brand: { domain: "acmeoutdoor.com" }, operator: "pinnacle-agency.com" }, media_buy_ids: [buy.media_buy_id], include_package_daily_breakdown: true }); ``` Every seller reports in the same format: impressions, clicks, spend, completion rates. Sam sees one dashboard instead of four. When StreamHaus underdelivers on the CTV package, he reallocates budget to OutdoorNet — one `update_media_buy` call instead of logging into two platforms. ## The full picture A horizontal pipeline showing the five stages of a media buy: Discovery (magnifying glass), Planning (blueprint), Execution (rocket launch), Optimization (adjusting dials), and Reporting (clean dashboard) Sam went from four dashboards to one protocol. The same tasks that bought CTV inventory also bought display and audio — no platform-specific code, no manual data translation, no spreadsheet reconciliation. | Before AdCP | With AdCP | | -------------------------------- | ------------------------------------------------- | | 4 dashboards, 4 logins | 1 protocol, 1 view | | Manual CSV comparison | Standardized product proposals | | Platform-specific creative specs | `list_creative_formats` on any seller | | 4 campaign setup workflows | `create_media_buy` everywhere | | Manual reporting reconciliation | `get_media_buy_delivery` aggregated | | Per-surface activation ad-ops | TMP matches packages on any surface automatically | ## Go deeper * **Product discovery**: [How `get_products` works](/dist/docs/3.1.20/media-buy/product-discovery/media-products) — briefs, wholesale mode, proposals, and refinement * **Campaign lifecycle**: [Managing media buys](/dist/docs/3.1.20/media-buy/media-buys/index) — status transitions, updates, and approvals * **Optimization**: [Delivery and reporting](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) — metrics, dimensional breakdowns, and feedback loops * **Governance**: [Campaign governance](/dist/docs/3.1.20/governance/overview) — how the three-party trust model protects Sam's spend * **Creative**: [Creative walkthrough](/dist/docs/3.1.20/creative/index) — how Maya builds the creatives Sam uses * **Real-time matching**: [Trusted Match Protocol](/dist/docs/3.1.20/trusted-match) — how packages activate at serve time via Context Match and Identity Match * **Get certified**: The [Buyer track](/dist/docs/3.1.20/learning/tracks/buyer) teaches the full media buy workflow through interactive modules # Media Buy Lifecycle Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/media-buys/index AdCP media buy lifecycle — create, update, monitor, and optimize campaigns across sellers using create_media_buy, update_media_buy, and get_media_buys tasks. Media buys represent the complete lifecycle of advertising campaigns in AdCP. The AdCP:Buy protocol provides a unified interface for managing media buys across multiple advertising platforms, from initial campaign creation through ongoing optimization and updates. ## Overview AdCP's media buy management provides a unified interface for: * **Campaign Creation** from discovered products and packages * **Lifecycle Management** through all campaign states * **Budget and Targeting Updates** for ongoing optimization * **Cross-Platform Orchestration** with consistent operations * **Asynchronous Operations** with human-in-the-loop support ## The Media Buy Lifecycle Phases ### 1. Creation Phase Transform discovered products into active advertising campaigns using [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy): * **Package Configuration**: Combine products with formats, targeting, and budget * **Campaign Setup**: Define timing, overall budget, and brand context * **Validation & Approval**: Automated checks with optional human approval * **Platform Deployment**: Campaign creation across advertising platforms This phase may involve: * Immediate creation with `active` status (instant activation) * Held creation with `paused` status when the buyer passes top-level `paused: true` and activation prerequisites are otherwise satisfied * Deferred creation with `pending_creatives` status (awaiting creative assignment) or `pending_start` status (ready to serve, waiting for flight date) * Human approval workflow via `pending_manual` task status (see [Asynchronous Operations](#asynchronous-operations-and-human-in-the-loop)) * Permission requirements via `pending_permission` task status (see [Asynchronous Operations](#asynchronous-operations-and-human-in-the-loop)) `pending_manual` and `pending_permission` are **task-level** statuses from the human-in-the-loop queue — they describe whether the *operation* requires approval, not the media buy's lifecycle state. The media buy itself enters `pending_creatives`, `pending_start`, `active`, or `paused` once the operation completes. **Platform Mapping:** * **Google Ad Manager**: Creates an Order with LineItems * **Kevel**: Creates a Campaign with Flights * **Triton Digital**: Creates a Campaign with Flights ### 2. Creative Supply Phase Once created, the media buy requires creative assets through the path the seller advertises: [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for library-backed sellers, or inline `packages[].creatives` on [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) and [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) for inline-only sellers. * **Platform-specific format support** (video, audio, display, custom) * **Validation and policy review** for creative compliance * **Assignment to specific packages** for targeted delivery ### 3. Activation & Delivery Phase Monitor and manage active campaigns: * **Status Tracking**: Campaign transitions from `pending_creatives` to `pending_start` to `active`, or to `paused` when created with delivery held * **Creative Assignment**: Attach assets from the creative library * **Delivery Monitoring**: Track pacing and performance metrics with [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) * **Issue Resolution**: Handle approval delays and platform issues ### 4. Optimization & Reporting Phase Ongoing performance monitoring and data-driven campaign optimization using AdCP's comprehensive reporting tools. Key activities include: * **Performance monitoring** with real-time and historical analytics * **Campaign optimization** through budget reallocation and targeting refinement * **Dimensional reporting** using the same targeting dimensions for consistent analysis * **AI-driven insights** through performance feedback loops For complete details on optimization strategies, performance monitoring, standard metrics, and best practices, see **[Optimization & Reporting](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting)**. ## Key Concepts ### Media Buy Structure A media buy contains: * **Campaign metadata** (buyer reference, brand, timing) * **Overall budget** with currency and pacing preferences * **Multiple packages** representing different targeting/creative combinations * **Status tracking** through creation, approval, and execution phases ### Package types Three distinct types represent a package at different lifecycle stages: | Type | Schema | Used in | Purpose | | ---------------- | -------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- | | `PackageRequest` | `media-buy/package-request.json` | `create_media_buy` request | What the buyer sends to create a package | | `Package` | `core/package.json` | `create_media_buy` success response | What the seller returns after creation (confirmed state) | | `PackageStatus` | inline in `media-buy/get-media-buys-response.json` | `get_media_buys` response | Delivery/reporting view — includes creative approvals and optional snapshot | When implementing `create_media_buy`, send a `PackageRequest`. The response returns `Package` objects. When calling `get_media_buys` to check status or delivery, the response contains `PackageStatus` items with delivery-specific fields. ### Package model Packages are the building blocks of media buys: * **Single product** selection from discovery results - when you buy a product, you buy the entire product (unless using property targeting) * **Creative formats** to be provided for this package * **Targeting overlays** for refinements including geo restrictions, frequency caps, and property targeting * **Budget allocation** as portion of overall media buy budget * **Pricing option** selection from product's available pricing models * **Pacing strategy** for budget delivery (even, asap, or front\_loaded) * **Bid price** for auction-based pricing models (when applicable) * **Flight scheduling** with optional `start_time` and `end_time` per package * **[Accountability terms](/dist/docs/3.1.20/media-buy/advanced-topics/accountability)** for guaranteed buys — `performance_standards`, `measurement_terms`, and `cancellation_policy` ### Flight scheduling Packages can have independent flight dates within a media buy. This enables weekly (or any cadence) flight patterns where the same product appears as multiple packages with different date windows and budgets. * **Inheritance**: When `start_time` or `end_time` is omitted on a package, the package inherits the media buy's dates. Each field inherits independently — a package may specify `start_time` while inheriting the media buy's `end_time`, or vice versa. * **Validation**: Package dates must fall within the parent media buy's date range. Sellers SHOULD reject packages where `start_time` is equal to or after `end_time`. * **Overlapping flights**: Multiple packages for the same product may have overlapping date ranges. Each package maintains its own independent budget. * **Format**: Plain ISO 8601 date-time — packages do not support `"asap"` **Weekly flights example:** A display campaign running March 1-31, broken into weekly \$2,000 flights with a dark period for lift measurement (abbreviated — see [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) for the full request shape): ```json theme={null} { "start_time": "2026-03-01T00:00:00Z", "end_time": "2026-03-31T23:59:59Z", "packages": [ { "product_id": "prod_premium_display", "pricing_option_id": "cpm_usd_fixed", "budget": 2000, "start_time": "2026-03-01T00:00:00Z", "end_time": "2026-03-07T23:59:59Z" }, { "product_id": "prod_premium_display", "pricing_option_id": "cpm_usd_fixed", "budget": 2000, "start_time": "2026-03-08T00:00:00Z", "end_time": "2026-03-14T23:59:59Z" }, { "product_id": "prod_premium_display", "pricing_option_id": "cpm_usd_fixed", "budget": 2000, "start_time": "2026-03-22T00:00:00Z", "end_time": "2026-03-28T23:59:59Z" } ] } ``` Week 3 is intentionally omitted — a dark period for lift measurement. Each flight gets its own budget, so pacing and spend are controlled per week. To adjust mid-campaign, update individual package budgets without affecting other flights. ### Creative Assignment and Placement Routing When a product defines multiple buyer-targetable placements, buyers can assign different creatives to those placements while still purchasing the product as a package. Creative placement refs route creatives inside the package inventory; they do not narrow the inventory purchased by the package. **Key Points:** * **Packages buy the product** - product-level `placements[].mode` says which publisher-scoped placements are buyer-targetable * **Placement identity is publisher-scoped** - publisher-referenced placements resolve as `{publisher_domain, placement_id}` against the publisher's `adagents.json` * **Inline placements are still allowed** - if there is no public publisher placement declaration, the sales agent may define an inline placement with `name`, formats, and public buyer-facing detail; its `placement_id` is still interpreted in the named publisher namespace, or in the seller agent's own publisher namespace when `publisher_domain` is omitted in a single-publisher legacy context * Package inventory scope comes from the selected product, product refinement, or a seller-supported package targeting surface. `creative_assignments[].placement_refs` only routes creatives among placements already in scope * Creatives without `placement_refs` or `placement_ids` run on all buyer-targetable placements in the package * If both `placement_refs` and legacy `placement_ids` are present, `placement_refs` wins and sellers ignore `placement_ids` * Placements with `mode: "included"` are part of the product's public composition and cannot be referenced in `creative_assignments[].placement_refs` * Use `placement_refs` for multi-publisher products. `placement_ids` remains a legacy shorthand only when the placement namespace is unambiguous. * Publishers can authorize agents for specific placements using `authorized_agents[].placement_ids` or governed `placement_tags` in `adagents.json`; the seller should only return publisher-referenced placements it is authorized to sell **Example Workflow:** 1. **Product returns targetable placements:** ```json theme={null} { "product_id": "network_premium", "placements": [ { "kind": "publisher_ref", "publisher_domain": "daily-pulse.example", "placement_id": "homepage_banner", "name": "Homepage Banner", "mode": "targetable", "format_ids": [{"agent_url": "...", "id": "display_728x90"}] }, { "kind": "publisher_ref", "publisher_domain": "metro-report.example", "placement_id": "homepage_banner", "name": "Homepage Banner", "mode": "targetable", "format_ids": [{"agent_url": "...", "id": "display_728x90"}] }, { "kind": "seller_inline", "publisher_domain": "daily-pulse.example", "placement_id": "article_sidebar", "name": "Article Sidebar", "mode": "targetable", "format_ids": [{"agent_url": "...", "id": "display_300x250"}] }, { "kind": "seller_inline", "publisher_domain": "daily-pulse.example", "placement_id": "sponsorship_lockup", "name": "Sponsorship lockup", "mode": "included" } ] } ``` The first two placements both use `placement_id: "homepage_banner"`, but they are distinct because each is scoped by a different `publisher_domain`. The third placement is inline because it has no catalog reference; it still carries `publisher_domain` because this product spans multiple publisher namespaces. 2. **Buyer creates package (buys entire product) and assigns different creatives to each placement:** ```json theme={null} { "product_id": "network_premium", "creative_assignments": [ { "creative_id": "creative_daily_pulse", "placement_refs": [ { "publisher_domain": "daily-pulse.example", "placement_id": "homepage_banner" } ] }, { "creative_id": "creative_metro_report", "placement_refs": [ { "publisher_domain": "metro-report.example", "placement_id": "homepage_banner" } ] } ] } ``` 3. **Or assign one creative to all targetable placements (omit placement\_refs and placement\_ids):** ```json theme={null} { "product_id": "network_premium", "creative_assignments": [ { "creative_id": "creative_universal" } ] } ``` Omitting both `placement_refs` and legacy `placement_ids` means the creative runs on all buyer-targetable placements in the package. **Use Cases:** * **Format-specific placements**: Homepage takes 728x90, sidebar takes 300x250 * **A/B testing**: Test different creatives on different placements * **Geo-targeting**: Different creatives for different DOOH screen locations * **Dayparting**: Different creatives for morning vs evening placements See [Media Products - Placements](/dist/docs/3.1.20/media-buy/product-discovery/media-products.mdx#placements) for complete placement documentation. ### Property Targeting For products with `property_targeting_allowed: true`, buyers can specify which properties to target using `property_list` in the `targeting_overlay`: ```json theme={null} { "product_id": "flexible_news_network", "targeting_overlay": { "property_list": { "agent_url": "https://governance.example.com", "list_id": "pl_brand_safe_2024" } }, "budget": 50000 } ``` **Key Points:** * Only valid for products with `property_targeting_allowed: true` * The package runs on the intersection of the product's `publisher_properties` and the `property_list` * If omitted, the package runs on all of the product's properties * If provided for a product with `property_targeting_allowed: false`, the seller SHOULD return a validation error See [Media Products - Property Targeting](/dist/docs/3.1.20/media-buy/product-discovery/media-products#property-targeting) for more on how products declare targeting flexibility. ### Lifecycle States Media buys progress through defined states with explicit transition rules: ``` create_media_buy ──┬──▶ pending_creatives ──▶ pending_start ──▶ active ├──▶ active ──(pause)──▶ paused └──▶ paused (when created with paused: true) paused ──(resume)──▶ active active ─────────────▶ completed (terminal) paused ─────────────▶ completed (terminal) pending_creatives ──▶ rejected (terminal) — seller rejects during setup pending_start ──────▶ rejected (terminal) — seller rejects during setup Any non-terminal ──── update(canceled: true) ──▶ canceled (terminal) ``` * **`pending_creatives`**: Approved but no creatives assigned — **buyer-side action required** (use `sync_creatives` for library-backed sellers, or inline `packages[].creatives` for inline-only sellers). Not a publisher- or governance-side approval queue: the seller has already accepted the buy; only the buyer's creative submission is missing. * **`pending_start`**: Ready to serve, waiting for flight date The `pending_X` naming convention names the lifecycle phase that is next required, NOT a state of waiting on seller/operator approval — `pending_creatives` means "creatives are the next phase," `pending_start` means "the flight date start is the next phase." Both are post-seller-acceptance states. * **`active`**: Running and delivering impressions * **`paused`**: Temporarily stopped by buyer or seller. A buy may also be created directly into `paused` with top-level `paused: true` when it otherwise satisfies activation prerequisites; setup blockers such as missing creatives or future flight dates still surface as `pending_creatives` or `pending_start` until cleared, then the create-time hold becomes visible as `paused`. * **`completed`**: Finished — flight ended, goal met, or budget exhausted * **`rejected`**: Declined by the seller (terminal) * **`canceled`**: Terminated before natural completion. Check `cancellation.canceled_by` to determine whether the buyer or seller initiated. **Display collapsing.** `pending_creatives` and `pending_start` are granular to support downstream gating — conditional UI, task routing, readiness checks. Buyer applications MAY render both as a single `pending` label for end users, but MUST preserve the raw status value on the wire (API responses, webhooks, persisted records, logs) so logic that depends on the distinction keeps working. Treat the raw enum as the source of truth and derive display labels from it. Where possible, drive UI affordances from `valid_actions` rather than from the status value directly. **Effect on creatives**: A media buy reaching `rejected`, `canceled`, or `completed` releases its creative assignments but does not modify the creatives themselves. Assigned creatives remain in the library with their existing review status and are available for assignment to other media buys. See [creative state and assignment state](/dist/docs/3.1.20/creative/creative-libraries#creative-state-and-assignment-state-are-separate). **Order confirmation**: A committed `create_media_buy` response constitutes order confirmation. The response includes `confirmed_at` with the seller's commitment timestamp. Deferred/manual approval flows may expose `confirmed_at: null` until the seller commits; once populated, the timestamp remains stable through later lifecycle changes. See [revision and confirmation semantics](/dist/docs/3.1.20/media-buy/specification#revision-and-confirmation-semantics). **Terminal states**: `completed`, `rejected`, and `canceled` are terminal — no transitions out. Sellers MUST reject updates to terminal-state media buys with error code `INVALID_STATE`. **Create-time holds.** Top-level `paused: true` on `create_media_buy` is a latent delivery hold when setup blockers exist. Buyers still see the blocker state first (`pending_creatives` or `pending_start`) because that is the next required phase. Once creatives are present and the flight can start, the buy enters `paused` instead of `active`. Buyers may clear the hold before blockers clear with `update_media_buy` and `paused: false`; the visible status stays `pending_creatives` or `pending_start` until the blocker clears, then proceeds to `active`. **Seller implementation requirement — persist status, never recompute from dates**: `status` MUST be stored as an explicit field and mutated only by protocol events. Flight-date arithmetic cannot represent `paused`, `canceled`, or `rejected` — those are driven by explicit commands, not the clock. Sellers that recompute `status` from `start_time`/`end_time` at request time will silently drop these states, breaking `valid_actions` for every buyer reading the media buy. The correct approach: date comparison sets the initial status at `create_media_buy` time (`pending_creatives`, `pending_start`, `active`, or `paused`); after that, the state machine owns the field. **Discovering valid actions**: The `get_media_buys` response includes `valid_actions` for each media buy — a list of actions the buyer can perform in the current state. Agents SHOULD use this instead of hardcoding the state machine: ```json theme={null} { "media_buys": [{ "media_buy_id": "mb_12345", "status": "active", "revision": 3, "valid_actions": ["pause", "cancel", "update_budget", "update_dates", "update_packages", "add_packages", "sync_creatives"], "packages": [...] }] } ``` **Revision tracking**: Each media buy carries a `revision` number that increments on every mutating change. Pass `revision` in `update_media_buy` for optimistic concurrency — the seller rejects with `CONFLICT` if the revision has changed since you last read it. Sellers must enforce this check atomically with the write; application-level read/compare/write logic can race concurrent updates. ## Core Operations ### Creating Media Buys The creation process handles: * **Product validation** ensuring discovered products are still available * **Format compatibility** checking creative requirements across packages * **Budget distribution** allocating spend across multiple packages * **Platform coordination** creating campaigns across multiple ad servers ### Updating Media Buys The operation type for each package is structurally explicit — determined by where it appears in the request: | Operation | Request field | Example | | ------------ | ---------------------------------- | ------------------------------------------ | | **New** | `new_packages[]` | Add a line item mid-flight | | **Changed** | `packages[]` | Adjust budget, targeting, dates, creatives | | **Canceled** | `packages[]` with `canceled: true` | Cancel a line item (irreversible) | Campaign-level modifications include: * **Budget adjustments** for increased/decreased spend * **Targeting updates** to refine audience parameters * **Schedule changes** for extended or shortened campaign timing * **Pause/resume** for campaign-level delivery control ### Canceling Media Buys Cancel a media buy or individual package using [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) with `canceled: true`: ```json theme={null} { "media_buy_id": "mb_12345", "canceled": true, "cancellation_reason": "Campaign strategy changed" } ``` Cancel a single package within an active media buy: ```json theme={null} { "media_buy_id": "mb_12345", "packages": [ { "package_id": "pkg_67890", "canceled": true, "cancellation_reason": "Underperforming — reallocating budget" } ] } ``` * Cancellation is **irreversible** — canceled media buys and packages cannot be reactivated * Sellers MAY reject cancellation with error code `NOT_CANCELLABLE` (e.g., contractual obligations, in-production print orders) * A canceled package does not affect other packages in the same media buy. When all packages are canceled, sellers that support `add_packages` allow the buyer to add new packages via `new_packages` in `update_media_buy`. Otherwise, the buyer SHOULD explicitly cancel the media buy. * Sellers MAY cancel media buys or packages (e.g., policy violation, inventory withdrawal). Seller-initiated cancellations set `cancellation.canceled_by: "seller"` and MUST trigger a webhook notification to the orchestrator. ### Package Lifecycle Packages follow the same pause/cancel pattern as media buys, with additional creative deadline enforcement: * **`paused`**: Temporarily stopped — can be resumed with `paused: false` * **`canceled`**: Permanently stopped — irreversible * **`creative_deadline`**: Per-package deadline for creative uploads or changes. After this deadline, creative changes are rejected with `CREATIVE_REJECTED`. When `creative_deadline` is absent on a package, the media buy's `creative_deadline` applies. This is important for mixed-channel orders — a print package may have an earlier material deadline than a digital package in the same media buy. ### Status Management Campaign state transitions: * **Activation requests** to start pending campaigns * **Pause/resume operations** for campaign control * **Cancellation** for buyer-initiated termination * **Completion handling** for successful campaign closure * **Error recovery** for failed operations ## Response Times Media buy operations use a unified status system with predictable timing: * **[`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy)**: Instant to days * `completed`: Simple campaigns created immediately * `working`: Processing within 120 seconds (validation, setup) * `submitted`: Complex campaigns requiring hours to days (human approval) * **[`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy)**: Instant to days * `completed`: Budget changes applied immediately * `working`: Targeting updates within 120 seconds * `submitted`: Package modifications requiring approval (hours to days) * **[`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery)**: \~60 seconds (data aggregation) * **Performance analysis**: \~1 second (cached metrics) **Status Meanings:** * **`completed`**: Operation finished, process results immediately * **`working`**: Processing, expect completion within 120 seconds * **`submitted`**: Long-running operation, provide webhook or poll with `tasks/get` ## Best Practices ### Campaign Planning * **Start with clear objectives** defined in your product discovery brief * **Plan package structure** around distinct audience/creative combinations * **Set realistic budgets** based on product pricing guidance * **Allow time for approval** in publisher workflows ### Ongoing Management * **Monitor daily pacing** to ensure delivery against targets * **Review performance weekly** for optimization opportunities * **Update targeting gradually** to avoid disrupting delivery * **Refresh creatives regularly** to prevent audience fatigue ### Budget Management * **Allocate conservatively** initially, then increase based on performance * **Reserve budget** for high-performing packages * **Plan for seasonality** in audience availability and pricing * **Monitor spend efficiency** across different targeting approaches * **Budget Management**: The system automatically recalculates impressions based on CPM when budgets are updated ### Technical Implementation * **Pause/Resume Strategy**: Use campaign-level controls for maintenance, package-level for optimization * **Performance Monitoring**: Regular status checks and delivery reports ensure campaigns stay on track * **Asynchronous Design**: Design orchestrators to handle long-running operations gracefully * **Task Tracking**: Maintain persistent storage for pending task IDs * **Webhook Integration**: Implement webhooks for real-time updates * **User Communication**: Clearly communicate pending states to end users ## Error Handling For comprehensive error handling guidance including pending vs error states, response patterns, and recovery strategies, see [Error Handling](/dist/docs/3.1.20/building/by-layer/L3/error-handling). Media buy specific error codes are documented in each task specification and the [Error Handling Reference](/dist/docs/3.1.20/building/by-layer/L3/error-handling). ## Asynchronous Operations and Human-in-the-Loop The AdCP:Buy protocol is designed for asynchronous operations as a core principle. Orchestrators MUST handle pending states gracefully. ### Human-in-the-Loop (HITL) Operations Many publishers require manual approval for automated operations. The protocol supports this through the HITL task queue: 1. **Operation Request**: Orchestrator calls any modification task 2. **Pending Response**: Server returns `pending_manual` status with task ID 3. **Task Monitoring**: Orchestrator polls or receives webhooks 4. **Human Review**: Publisher reviews and approves/rejects 5. **Completion**: Original operation executes upon approval ### HITL Task States ``` pending → assigned → in_progress → completed/failed ↓ escalated ``` ### Orchestrator Requirements Orchestrators MUST: 1. Handle `pending_manual` and `pending_permission` as normal states 2. Store task IDs for tracking pending operations 3. Implement retry logic with exponential backoff 4. Handle eventual rejection of operations gracefully 5. Support webhook callbacks for real-time updates (recommended) ## Standard Metrics All platforms must support these core metrics: * **impressions**: Number of ad views * **spend**: Amount spent in currency * **clicks**: Number of clicks (if applicable) * **ctr**: Click-through rate (clicks/impressions) Optional standard metrics: * **conversions**: Post-click/view conversions * **viewability**: Percentage of viewable impressions * **completion\_rate**: Video/audio completion percentage * **engagement\_rate**: Platform-specific engagement metric ## Platform-Specific Considerations Different platforms offer varying reporting and optimization capabilities: ### Google Ad Manager * Orders can contain multiple LineItems * LineItems map 1:1 with packages * Sophisticated targeting and frequency capping * Requires creative approval process * **Reporting**: Comprehensive dimensional reporting, real-time and historical data, advanced viewability metrics ### Kevel * Campaigns contain Flights * Flights map 1:1 with packages * Real-time decisioning engine * Supports custom creative templates * **Reporting**: Real-time reporting API, custom metric support, flexible aggregation options ### Triton Digital * Optimized for audio advertising * Campaigns contain Flights for different dayparts * Strong station/stream targeting capabilities * Audio-only creative support * **Reporting**: Audio-specific metrics (completion rates, skip rates), station-level performance data, daypart analysis ## Advanced Analytics ### Cross-Campaign Analysis * **Portfolio performance** across multiple campaigns * **Audience overlap** and frequency management * **Budget allocation** optimization across campaigns ### Predictive Insights * **Performance forecasting** based on historical data * **Optimization recommendations** from AI analysis * **Trend prediction** for proactive adjustments ## Integration Patterns ### Discovery to Media Buy Seamless flow from product discovery to campaign creation: 1. Use [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) to find inventory 2. Select products that align with campaign objectives 3. Configure packages with appropriate targeting and formats 4. Create media buy with [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) ### Creative Integration Coordinate with creative management: 1. Understand format requirements from selected products 2. Prepare assets using [Creative Management](/dist/docs/3.1.20/media-buy/creatives/) 3. Assign creatives during campaign creation or via updates 4. Monitor creative performance and refresh as needed ### Performance Optimization Data-driven campaign improvement leveraging comprehensive analytics: 1. **Track delivery** with [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) * Monitor real-time delivery metrics and pacing analysis * Get package-level performance breakdown for optimization opportunities * Track performance across different targeting approaches 2. **Analyze performance** across packages and targeting * Use dimensional reporting for granular insights * Monitor performance index scores for AI-driven optimization * Identify high and low performing segments 3. **Update campaigns** with [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) * Reallocate budgets between high and low performing packages * Adjust targeting based on performance data * Pause underperforming packages and scale successful ones 4. **Iterate** based on performance data and business outcomes * Feed performance data back into optimization algorithms * Continuously refine targeting and creative assignments * Scale successful strategies across similar campaigns #### Optimization Best Practices 1. **Report Frequently**: Regular reporting improves optimization opportunities 2. **Track Pacing**: Monitor delivery against targets to avoid under/over-delivery 3. **Analyze Patterns**: Look for performance trends across dimensions 4. **Consider Latency**: Some metrics may have attribution delays 5. **Normalize Metrics**: Use consistent baselines for performance comparison ## Related Documentation * **[Product Discovery](/dist/docs/3.1.20/media-buy/product-discovery/)** - Finding inventory for media buys * **[Task Reference](/dist/docs/3.1.20/media-buy/task-reference/)** - Complete API documentation * **[Creatives](/dist/docs/3.1.20/media-buy/creatives/)** - Creative asset management * **[Orchestrator Design Guide](/dist/docs/3.1.20/building/operating/orchestrator-design)** - Implementation best practices # Media Buy Lifecycle Flow Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/media-buys/lifecycle Step-by-step sequence from product discovery through delivery, including the guaranteed-deal IO acceptance path and creative sync timing. This page is the canonical sequence reference for media buy lifecycle. For conceptual background on the full lifecycle — campaign structure, package model, property targeting, and async operations — see [Media Buy Lifecycle](/dist/docs/3.1.20/media-buy/media-buys/). ## Standard flow Every media buy follows four steps: ```mermaid theme={null} flowchart TD A[get_products] --> B[create_media_buy] B --> C{initial state} C -->|creatives missing| D[pending_creatives] C -->|creatives present,\nflight not yet started| E[pending_start] C -->|creatives present,\nflight started| F[active] C -->|paused: true,\notherwise active| J[paused] D --> G[creative supply] G --> E E --> F F --> H{delivery} H -->|budget exhausted /\ngoal met / flight ended| I[completed] H -->|buyer or seller action| J[paused] J --> F J -->|flight ended| I ``` 1. **`get_products`** — discover available inventory matching your brief. 2. **`create_media_buy`** — submit packages; the seller validates and confirms. 3. **Creative supply** — assign creative assets to packages that need them, either through `sync_creatives` and `creative_assignments` or through inline package `creatives` when the seller advertises inline creative management. 4. **Delivery** — the buy enters `active` and accrues impressions, or enters `paused` when created with delivery held. It eventually reaches a terminal state. ## State machine ### Media buy states | State | Meaning | Terminal? | | ------------------- | -------------------------------------------- | --------- | | `pending_creatives` | Approved; no creatives assigned yet | No | | `pending_start` | Creatives assigned; waiting for flight date | No | | `active` | Delivering impressions | No | | `paused` | Temporarily halted | No | | `completed` | Flight ended, goal met, or budget exhausted | Yes | | `rejected` | Seller declined the buy | Yes | | `canceled` | Buyer or seller terminated before completion | Yes | `pending_manual` and `pending_permission` are **task-level** statuses — they describe whether the *operation* (e.g., `create_media_buy`) is queued for human review, not the media buy's own state. The media buy enters `pending_creatives`, `pending_start`, `active`, or `paused` once the operation completes. See [Asynchronous Operations](/dist/docs/3.1.20/media-buy/media-buys/#asynchronous-operations-and-human-in-the-loop). ### Transitions ```mermaid theme={null} stateDiagram-v2 [*] --> pending_creatives : create_media_buy\n(no creatives) [*] --> pending_start : create_media_buy\n(creatives present,\nflight future) [*] --> active : create_media_buy\n(creatives present,\nflight started) [*] --> paused : create_media_buy\n(paused: true,\notherwise active) pending_creatives --> pending_start : sync_creatives\nor update_media_buy creatives pending_creatives --> paused : sync_creatives\nor update_media_buy creatives\n(create held) pending_start --> active : flight date reached pending_start --> paused : flight date reached\n(create held) active --> paused : update_media_buy\n(paused: true) paused --> active : update_media_buy\n(paused: false) active --> completed : flight ended /\ngoal met / budget exhausted paused --> completed : flight ended /\ngoal met / budget exhausted pending_creatives --> rejected : seller declines pending_start --> rejected : seller declines pending_creatives --> canceled : update_media_buy\n(canceled: true) pending_start --> canceled : update_media_buy\n(canceled: true) active --> canceled : update_media_buy\n(canceled: true) paused --> canceled : update_media_buy\n(canceled: true) completed --> [*] rejected --> [*] canceled --> [*] ``` ### Discovering valid actions at runtime Rather than hardcoding the state machine, read `valid_actions` from `get_media_buys`. The seller returns exactly what the buyer can do in the current state: ```json theme={null} { "media_buy_id": "mb_12345", "status": "active", "revision": 3, "valid_actions": ["pause", "cancel", "update_budget", "update_dates", "update_packages", "add_packages", "sync_creatives"] } ``` Buyers SHOULD pass the latest `revision` in every `update_media_buy` call intended to change state. The field is optional for backward compatibility, but when present the seller rejects with `CONFLICT` if the revision has changed since your last read, and the check must happen atomically with the write so concurrent updates cannot overwrite each other. See [optimistic concurrency](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy#optimistic-concurrency). For creative changes, `sync_creatives` in `valid_actions` is the legacy action label. Use the creative path the seller advertises: `sync_creatives` and `creative_assignments` for library-backed sellers, or `packages[].creatives` on `update_media_buy` for inline-only sellers. ## Guaranteed / PG deal variation Products with `delivery_type: "guaranteed"` require contractual commitment before delivery begins. The flow diverges after `create_media_buy`: ```mermaid theme={null} flowchart TD A[get_products\ndelivery_type: guaranteed] --> B[create_media_buy\nwith accountability_terms] B --> C{task status} C -->|IO signing needed| D[submitted\ntask_id returned] C -->|IO pre-signed| E[pending_creatives\nor pending_start] D --> F[IO signed\nout-of-band] F --> E E --> G[creative supply\nif needed] G --> H[active — guaranteed delivery] H --> I{performance} I -->|standards met| J[completed] I -->|under-delivery| K[makegood / remediation] K --> J ``` ### What makes a guaranteed buy different **`accountability_terms` are required** on each package with a guaranteed product. Three fields are required: * `performance_standards` — viewability, IVT, completion rate, and other thresholds with measurement vendor * `measurement_terms` — who counts the billing metric, acceptable variance, and makegood remedies * `cancellation_policy` — notice period and cancellation fee for early termination Omitting any of these on a guaranteed package causes the seller to return `TERMS_REJECTED`. **IO signing** — `create_media_buy` for a guaranteed product may return task status `submitted` with a `task_id` rather than completing synchronously. This means the seller's system is awaiting insertion order (IO) acceptance. Poll with `tasks/get` or configure a webhook. Once the IO is signed, the completion artifact carries the `media_buy_id` and the media buy enters `pending_creatives` or `pending_start`. **Makegoods** — if the seller under-delivers against agreed `performance_standards`, they propose a remedy from the `makegood_policy`: `additional_delivery`, `credit`, or `invoice_adjustment`. The buyer accepts or disputes. A seller who accepts without under-delivering earns a favorable accountability signal. See [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability) for the full negotiation flow including how buyers can propose non-default terms at `create_media_buy` time. ## Creative sync timing ### When creatives are required `create_media_buy` accepts inline `creative_assignments` or `creatives` per package. If you supply them at creation time and the flight date has passed, the buy enters `active` directly, or `paused` when the request carries top-level `paused: true`. If the flight date is in the future, it enters `pending_start`; with top-level `paused: true`, the hold is latent and the buy enters `paused` when the flight date arrives. If no creatives are assigned at creation, the buy enters `pending_creatives`. With top-level `paused: true`, the hold is latent and the buy enters `paused` after required creatives are supplied and any future start date has arrived. Delivery cannot begin until the buyer supplies at least one creative per package through a path the seller advertises. Sellers with `creative.has_creative_library: true` use `sync_creatives` and `creative_assignments`. Sellers that advertise both `creative.has_creative_library: true` and `inline_creative_management: true` also accept inline `packages[].creatives` on `create_media_buy` and `update_media_buy`. Inline-only sellers use only `packages[].creatives` on `update_media_buy`. Create-time holds may be cleared before delivery is otherwise ready. `update_media_buy` with `paused: false` clears the stored hold; if creatives are still missing or the flight date is still in the future, the visible status remains `pending_creatives` or `pending_start` until that blocker clears. ### The `creative_deadline` `create_media_buy` returns a `creative_deadline` timestamp on the media buy response. Individual packages may carry their own `creative_deadline`. **Package-level deadlines take precedence over the media buy deadline.** This matters for mixed-channel orders — a print package may have a material deadline days before the digital packages in the same buy. After the deadline, creative submissions for that package return `CREATIVE_REJECTED`, whether they arrive through `sync_creatives` or inline `packages[].creatives` on `update_media_buy`. Creative changes are blocked; delivery continues with whatever creatives are currently assigned (or the package remains in `pending_creatives` if none were ever assigned). ``` Deadline hierarchy: package.creative_deadline (if present — wins) ↓ else media_buy.creative_deadline ``` ### Effect on creatives when a buy ends When a media buy reaches `rejected`, `canceled`, or `completed`, creative assignments are released. For library-backed sellers, the creatives themselves are not deleted — they remain in the library with their existing review status and are available for assignment to other media buys. Inline-only sellers may retain package-scoped creative records for audit and reporting without exposing reusable library entries. ## Health and dependency impairment `status` describes operational state — is the buy serving, paused, or terminal? **`health`** is a separate orthogonal field describing whether upstream dependencies are intact: | Field | Tracks | Values | | -------- | ----------------- | --------------------------------------------------------------------------------------------- | | `status` | Operational state | `pending_creatives`, `pending_start`, `active`, `paused`, `completed`, `rejected`, `canceled` | | `health` | Dependency state | `ok`, `impaired` | The two are orthogonal. A buy can be `paused`-and-impaired, `pending_creatives`-and-impaired, or `active`-and-impaired. Health does not change `status`; `valid_actions` is unaffected. ### When `health` is `impaired` `health` transitions to `impaired` when an upstream dependency referenced by the buy enters an offline state that affects delivery for at least one package: * An audience the buy targets transitions to `suspended` (consent expiry, TTL, policy enforcement). * A creative the buy uses transitions from `approved` to `suspended` (recoverable dependency/authorization loss), from `suspended` to `rejected` (terminal dependency/authorization loss), or from `approved` to `rejected` (post-approval revocation). * A catalog item the buy targets transitions to `withdrawn` (seller-initiated removal). * An event source the buy depends on enters `insufficient` (zero events received). * A property the buy targets is depublished via brand.json / adagents.json. The buy's `impairments[]` array carries one entry per affected dependency: ```json theme={null} { "media_buy_id": "mb_456", "status": "active", "health": "impaired", "impairments": [ { "impairment_id": "imp_01HZX9...", "resource_type": "audience", "resource_id": "aud_123", "package_ids": ["pkg_a"], "transition": { "from": "ready", "to": "suspended" }, "reason_code": "consent_expired", "reason": "Hashed identifier consent basis expired on 2026-06-01.", "observed_at": "2026-06-02T14:11:00Z", "remediation": "Re-sync audience after refreshing consent upstream." } ] } ``` ### Materiality Each entry in `impairments[]` MUST list at least one package whose ability to serve is degraded. Cosmetic effects (one rejected creative in a package that still has serviceable peers) MUST NOT be reported as impairments — they're surfaced via the resource's own status, not the buy's. ### Reverse direction When the underlying resource returns to a serviceable state (audience re-syncs, creative re-approved), the seller MUST remove the corresponding entry from `impairments[]` and flip `health` to `ok` if no other impairments remain. The buyer sees the recovered state on the next snapshot read or via the next `impairment` push (which carries the closure). ### Pushed via `impairment` webhook When a buy's `health` transitions or an impairment is added/removed, the seller fires an `impairment` notification against the buy's `push_notification_config`. The payload reuses the `impairment` object shape plus the buy's updated `health`. See the [persistent webhook contract](/dist/docs/3.1.20/building/by-layer/L3/webhooks#persistent-channel-contract) for delivery semantics (at-least-once, no-ordering, coalescence, replay via snapshot). Impairment webhooks identify affected packages with `package_ids[]` by design. Buyers that need package-level correlation context, such as `context.buyer_ref`, should call `get_media_buys` after receiving an impairment fire and read the package snapshot. ### Materiality coverage The MUST-strength materiality rule applies to resource types where the resource → buy join is cheap and 1:N — audience, event\_source, property. For creative and catalog\_item, materiality is SHOULD-strength: a creative in a large pool may not degrade serving when removed, and the join is more expensive for sellers to compute. Implementers SHOULD report conservatively when uncertain and MUST NOT report when serving is provably unaffected. ### Remediation by `reason_code` Each `reason_code` has a typical buyer remediation path. Sellers don't fill this in per-impairment — buyer agents key remediation off `reason_code` directly. The table below is the protocol-level guidance; the per-impairment free-text `remediation` field carries seller-specific context that doesn't fit the typical path (e.g., "we restored this audience yesterday; sync now to pick up the refresh"). | `reason_code` | Typical buyer remediation | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `consent_expired` | Refresh upstream consent (e.g., hashed-id consent renewal on a clean-room flow), then re-sync the audience via `sync_audiences`. | | `ttl_expired` | Re-sync the audience via `sync_audiences` to renew. | | `pii_audit_failed` | Address audit findings upstream (hashing, identifier hygiene). Re-sync only **after** the seller signals the audit is cleared — a buyer agent that loops `sync_audiences` against an uncleared audit will not converge. | | `content_rejected` | Same path as initial creative approval — fix the issue and resubmit via `sync_creatives` for library-backed sellers, or via `packages[].creatives` on `update_media_buy` for inline-only sellers. Campaign-level decision (replace vs. wait for resubmit) depends on creative pool composition and is left to the buyer. | | `identity_authorization_revoked` | Restore the downstream identity/post authorization through the seller's connection flow, or replace the affected `published_post` creative if authorization cannot be restored. | | `identity_authorization_expired` | Renew the downstream identity/post authorization through the seller's connection flow, then let the seller re-check or re-review the creative. | | `source_private` | Restore source post visibility or replace the affected `published_post` reference. | | `source_offline` | Verify the tag is firing on the buyer's properties (this is often a buyer/publisher integration issue, not a seller-side outage), then re-sync via `sync_event_sources`. | | `seller_removed` | No buyer-side resubmit path. Find a replacement via the same discovery tool used at campaign setup (`get_products`, `get_signals`, `list_creative_formats`), or contact the seller for restoration ETA. | | `policy_violation` | Seller-side enforcement; buyer-side resubmit unlikely to clear. Await resolution or escalate per the seller's standard contact path. | | `property_depublished` | No buyer-side fix — property publication is controlled by the publisher's `brand.json` / `adagents.json`. Find a replacement property via `get_products` or remove from targeting. | ### Triage ordering Buyer agents triaging a non-empty `impairments[]` SHOULD sort entries by `observed_at` ascending — the oldest open impairment is the most likely to have already eaten into delivery. Webhook arrival time is not a reliable proxy: under the coalescence rule (see [webhooks § Coalescence](/dist/docs/3.1.20/building/by-layer/L3/webhooks#coalescence)) the seller MAY batch multiple state changes into one fire, and re-emission under a fresh `idempotency_key` resets the transport timestamp without changing `observed_at`. ### Impairments are operational signals, not commercial events An `impairment` reports degraded delivery from upstream dependency change. It is **not** a billing event, makegood trigger, or credit dispute. Commercial remedies for under-delivery are governed by `accountability_terms` on guaranteed buys and remain out of scope for this surface. Integrators building dispute pipelines should drive them from delivery reports and accountability terms, not from `impairments[]`. ### Compliance The `impairment.coherence` assertion verifies that the buy's `impairments[]` surface stays in sync with the underlying resources it references. It is a cross-resource invariant — it observes both the resource transition and the buy snapshot in the same compliance run. **Forward rule.** Every entry in a buy's `impairments[]` MUST reference a resource whose current status is an offline state — `audience: suspended` (on `audience-status`), `creative: suspended` or `creative: rejected` (on `creative-status`), `catalog_item: withdrawn` (on `catalog-item-status`), `event_source: insufficient` (the `assessment-status` value surfaced through `event-source-health.status`), or a property depublished via `brand.json` / `adagents.json`. A buy reporting an impairment whose referenced resource is no longer offline fails the check — the seller has stale state on the buy. **Inverse rule.** Any resource in an offline state that is referenced by a non-terminal buy MUST appear in that buy's `impairments[]`. A seller that transitions a resource without propagating to the affected buys fails the check — the seller has stale state on the resource. **Health-iff rule.** A non-terminal buy's `health` MUST be `impaired` whenever `impairments[]` is non-empty, and MUST be `ok` whenever `impairments[]` is empty. This is a strict iff — stale `health: "impaired"` with an empty `impairments[]` (or `health: "ok"` with a non-empty `impairments[]`) violates the rule even when the forward and inverse rules are individually satisfied. **Out of scope.** All three rules relax on buys in terminal status (`completed`, `canceled`, `rejected`). Sellers MAY leave `impairments[]` and `health` in whatever state they held at the terminal transition — they are not required to clean up. Buyers MUST NOT treat post-terminal drift as a coherence violation; the buy is no longer serving and synchronisation is wasted effort. Materiality (the requirement that `package_ids` be non-empty) is enforced at the schema layer by `package_ids: minItems: 1` on `impairment.json` — `impairment.coherence` does not re-check it. **Snapshot is one of several propagation surfaces.** Sellers declare which surfaces they use via [`capabilities.media_buy.propagation_surfaces`](https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json) on `get_adcp_capabilities` — a non-exclusive array, so a seller mirroring impairments on both the buy snapshot AND firing webhooks declares `["snapshot", "webhook"]` (the common case for premium guaranteed sellers). The three surface values: * **`snapshot`** — seller populates `health` + `impairments[]` on `get_media_buys` reads. The contract above governs this surface; `impairment.coherence` storyboards grade it when declared, `not_applicable` otherwise. * **`webhook`** — seller fires `notification-type: impairment` webhooks via `push_notification_config`. Subject to the persistent-channel webhook contract. * **`out_of_band`** — seller propagates via channels outside the AdCP protocol surface (email, dashboard, partner-specific feeds). Long-tail and enterprise-bundled platforms commonly use this when impairment workflows are managed in human channels. Sellers declaring only `["out_of_band"]` are not graded by snapshot or webhook compliance — their bar is the offline agreement. Default when absent is `["snapshot"]`. Each surface is independent of the others; declare the actual mix of surfaces buyers will observe on the agent. A seller with impairment data in their API under a non-AdCP field name (a mapping gap) SHOULD document the mapping rather than declare `out_of_band` — the spec's gap is what `out_of_band` legitimately covers. **Relationship to other invariants.** `impairment.coherence` complements `status.monotonic`, which observes single-resource transitions only. The two run together on every specialism whose storyboard exercises both a resource-state transition and a media-buy snapshot read — audience-sync, creative-ad-server, creative-template, creative-generative, sales-catalog-driven. The cross-resource exercise that drives non-NA grading is the dependency-impairment storyboard (`media_buy_seller/dependency_impairment`, creative-track), which forces a creative on an active buy into an offline state (`approved → suspended` for recoverable dependency loss or `approved/suspended → rejected` for terminal revocation), verifies the buy reflects `health: impaired` with a matching `impairments[]` entry, recovers or replaces the creative, and verifies the buy returns to `health: ok` with `impairments[]` cleared. Audience-track and catalog-track variants are follow-ups, pending `force_audience_status` / `force_catalog_item_status` support in the compliance test controller. See the [Snapshot and log contract](/dist/docs/3.1.20/protocol/snapshot-and-log) for the read-side rules that tie `impairments[]` (snapshot) to the `impairment` push (log). For library-backed sellers, creative library state and creative assignment state are tracked independently. A creative that was assigned to a canceled buy still has whatever review status it earned and can be immediately assigned to a new buy. Inline-only sellers expose package-scoped creative status on `get_media_buys` but do not advertise reusable library creatives. See [creative state and assignment state](/dist/docs/3.1.20/creative/creative-libraries#creative-state-and-assignment-state-are-separate). ## See also * [Media Buy Lifecycle](/dist/docs/3.1.20/media-buy/media-buys/) — full lifecycle reference: campaign structure, package model, async operations * [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) — task reference with request parameters, response shapes, and examples * [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) — upload and update library creatives when the seller advertises `creative.has_creative_library: true` * [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability) — performance standards, measurement terms, makegood resolution * [Optimization & Reporting](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) — delivery monitoring, dimensional reporting, campaign updates # Optimization & Reporting Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting AdCP optimization and reporting — monitor delivery metrics, analyze dimensional breakdowns, share performance feedback, and optimize active media buys. Continuous improvement through data-driven monitoring and optimization. AdCP provides comprehensive reporting tools and optimization features to help you track performance, analyze delivery, and improve campaign outcomes. Reporting in AdCP aligns with the [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) system used for campaign setup, enabling consistent analysis across the campaign lifecycle. This unified approach means you can report on exactly what you targeted. Performance data feeds into AdCP's [Accountability & Trust Framework](/dist/docs/3.1.20/media-buy/index.mdx#accountability--trust-framework), enabling publishers to build reputation through consistent delivery and helping buyers make data-driven allocation decisions. ## Key Optimization Tasks ### Delivery Reporting Use [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) to retrieve comprehensive performance data including impressions, spend, clicks, and conversions across all campaign packages. Alternatively, configure **webhook-based reporting** during media buy creation to receive automated delivery notifications at regular intervals. ### Campaign Updates Use [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) to modify campaign settings, budgets, and configurations based on performance insights. ## Optimization Workflow The typical optimization cycle follows this pattern: 1. **Monitor Delivery**: Track campaign performance against targets 2. **Analyze Performance**: Identify optimization opportunities 3. **Make Adjustments**: Update budgets, targeting, or creative assignments 4. **Track Changes**: Monitor impact of optimizations 5. **Iterate**: Continuous improvement through regular analysis ## Metric lifecycle Every optimization metric in AdCP — whether seller-native (clicks, views, reach), graduated vendor-attested (viewability), or vendor-defined (attention, brand lift, emissions) — flows through the same set of surfaces. Buyers reason about a metric by asking three questions in order: * **Can you?** — Is the product capable of optimizing for this metric? *(Discovery)* * **Will you?** — For this package, are you committing to optimize and report it? *(Commitment)* * **Did you?** — In delivery, what was the value, and did you meet the commitment? *(Reporting)* Each question maps to a specific protocol surface. The same `metric_id` (or `(vendor, metric_id)` tuple for vendor-attested metrics) flows through every layer — discovery, capability declarations, package commitments, and delivery — so buyers reconciling delivery against a goal never need a translation table. ### Standard metric flow (e.g., `clicks`, `viewable_rate`) | Question | Surface | Field | | -------------------------------------------------- | ------------------ | ------------------------------------------------------------- | | Can the product report it? | Product capability | `reporting_capabilities.available_metrics` | | Can the product optimize for it? | Product capability | `metric_optimization.supported_metrics` | | Will the seller commit to reporting it? | Package | `committed_metrics[]` (scope: `standard`) | | Does the buyer want it optimized? | Package | `optimization_goals[]` (kind: `metric`) | | Does the buyer want accountability? | Package | `performance_standards[]` | | What was the value? | Delivery | Standard scalar (e.g., `clicks`, `viewability.viewable_rate`) | | Did the seller fail to deliver a committed metric? | Delivery | `missing_metrics[]` (scope: `standard`) | ### Vendor-attested metric flow (e.g., Adelaide attention, Scope3 emissions, Kantar brand lift) Same lifecycle, with the `(vendor, metric_id)` tuple replacing the plain `metric_id` everywhere: | Question | Surface | Field | | -------------------------------------------------- | --------------------- | ------------------------------------------------ | | Does the vendor define this metric? | Vendor's capabilities | `get_adcp_capabilities.measurement.metrics[]` | | Can the product report this vendor metric? | Product capability | `reporting_capabilities.vendor_metrics[]` | | Can the product optimize for this vendor metric? | Product capability | `vendor_metric_optimization.supported_metrics[]` | | Will the seller commit to reporting it? | Package | `committed_metrics[]` (scope: `vendor`) | | Does the buyer want it optimized? | Package | `optimization_goals[]` (kind: `vendor_metric`) | | Does the buyer want accountability? | Package | `performance_standards[]` (with `vendor` field) | | What was the value? | Delivery | `vendor_metric_values[]` | | Did the seller fail to deliver a committed metric? | Delivery | `missing_metrics[]` (scope: `vendor`) | ### Why both flows matter The two flows aren't a duplication — they encode the difference between *standardized* and *vendor-specific* measurement. Clicks mean the same thing across every seller; attention does not. Vendor binding is the wire-level evidence that the buyer and seller agreed on *which* attention model is being optimized and reported. See [`measurement/taxonomy.mdx`](/dist/docs/3.1.20/measurement/taxonomy) for the Tier 0 → Tier 1 graduation policy that decides which flow a given metric uses. ### Required coherence between layers Three rules connect the layers and prevent orphaned goals: 1. **Optimization requires capability.** A package's `optimization_goals[]` entry MUST match a product capability — `metric_optimization.supported_metrics` for `kind: "metric"`, or `vendor_metric_optimization.supported_metrics` for `kind: "vendor_metric"`. Sellers reject mismatches with `TERMS_REJECTED`. 2. **Optimization requires reporting commitment.** For `kind: "vendor_metric"` goals, the matching `(vendor, metric_id)` MUST also appear in the package's `committed_metrics[]`. Optimizing for a metric the seller isn't committed to reporting is unverifiable — the buyer has no way to grade the goal. Sellers reject uncommitted vendor\_metric goals with `TERMS_REJECTED`. (This rule is normative for vendor metrics specifically; for seller-native `metric` goals, the metric is usually always reported by virtue of being seller-native, so the coherence is implicit.) A third precondition — discovery, that `metric_id` appears in the vendor's published `measurement.metrics[]` catalog — is SHOULD this minor and tightens to MUST at the next minor while measurement-vendor adoption of AdCP-conformant capability publication catches up. 3. **Performance accountability is independent.** `performance_standards[]` lives in parallel — buyers MAY add a threshold commitment on any metric the seller commits to reporting, regardless of whether they also set an optimization goal on it. The three surfaces (goal / commitment / standard) compose: * Goal only — "push toward this," no accountability floor * Standard only — "you owe me ≥ X," seller decides how to hit it * Both — optimization steers; standard backstops 4. **Priority is ordinal, lowest-value-first.** When `optimization_goals[]` carry explicit `priority` values, the seller treats the goal with the **lowest** priority value as primary — even when no goal is `priority: 1` (priorities of `2` and `3` mean `2` is primary). When `priority` is omitted, sellers may use array position. Duplicate priority values are undefined. ### Worked example: Adelaide attention end-to-end A buyer wants to optimize against Adelaide's `attention_score` with a threshold of 70. Here's how the four surfaces line up: **1. Product capability** (discovered via `get_products`): ```json theme={null} { "product_id": "premium_ctv_video", "vendor_metric_optimization": { "supported_metrics": [ { "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score", "supported_targets": ["cost_per", "threshold_rate"] } ] }, "reporting_capabilities": { "vendor_metrics": [ { "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score" } ] } } ``` **2. Package proposal on `create_media_buy`** — the buyer commits to reporting AND sets the optimization goal (both required by the reporting-coherence rule): ```json theme={null} { "product_id": "premium_ctv_video", "committed_metrics": [ { "scope": "vendor", "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score" } ], "optimization_goals": [ { "kind": "vendor_metric", "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score", "target": { "kind": "threshold_rate", "value": 70 }, "priority": 1 } ] } ``` A `performance_standard` MAY be layered on top if the buyer wants a contractual floor — e.g., `{ "metric": "attention_score", "vendor": { "domain": "adelaidemetrics.com" }, "threshold": 65 }` — but it's independent of the goal. **3. Delivery** (`get_media_buy_delivery` response): ```json theme={null} { "vendor_metric_values": [ { "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score", "value": 73.2, "measurable_impressions": 420000 } ] } ``` The seller's bidding stack steered toward higher Adelaide attention scores; the reported value `73.2` is above the threshold `70`, so the goal is met. The `measurable_impressions` denominator (`420000` out of, say, 1M delivered impressions) is the coverage figure for Adelaide's measurement — vendor SDKs rarely fire on 100% of delivered impressions, and attention-vendor coverage on CTV today is typically 30–60% depending on app-SDK presence. Always compute coverage from `measurable_impressions / impressions` before reasoning about the reported value. **4. Accountability gap reporting** (also in the `get_media_buy_delivery` response, when commitment isn't met): ```json theme={null} { "by_package": [{ "package_id": "...", "missing_metrics": [ { "scope": "vendor", "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score" } ] }] } ``` If Adelaide's coverage on the inventory dropped below the seller's confidence threshold and they couldn't populate `vendor_metric_values` for this period, the same `(vendor, metric_id)` shows up in `missing_metrics` — same key across the entire lifecycle, so buyer reconciliation is a row-level join. ## Performance Monitoring ### Real-Time Metrics Track campaign delivery as it happens: * **Impression delivery** vs. targets * **Spend pacing** against budget * **Click-through rates** and engagement * **Conversion tracking** for business outcomes ### Historical Analysis Understand performance trends over time: * **Daily/hourly breakdowns** of key metrics * **Performance comparisons** across time periods * **Trend identification** for optimization opportunities ### Alerting and Notifications Stay informed of important campaign events: * **Delivery alerts** for pacing issues * **Performance notifications** for significant changes * **Budget warnings** before limits are reached ## Delivery Methods Publishers can proactively push reporting data to buyers through webhook notifications or offline file delivery. This eliminates continuous polling and provides timely campaign insights. **Webhook Push (Real-time)** - HTTP POST to buyer endpoint * Best for: Most buyer-seller relationships * Latency: Near real-time (seconds to minutes) * Cost: Standard webhook infrastructure **Offline File Delivery (Batch)** - Cloud storage bucket push * Best for: Large buyer-seller pairs (high volume) * Latency: Scheduled batch delivery (hourly/daily) * Cost: Significantly lower (\$0.01-0.10 per GB vs. \$0.50-2.00 per 1M webhooks) * Format: JSON Lines, CSV, or Parquet files * Storage: S3, GCS, Azure Blob Storage ### Webhook-Based Reporting #### Webhook Configuration Configure reporting webhooks when creating a media buy using the `reporting_webhook` parameter: ```json theme={null} { "packages": [...], "reporting_webhook": { "url": "https://buyer.example.com/webhooks/reporting", "authentication": { "schemes": ["Bearer"], "credentials": "secret_token_min_32_chars" }, "reporting_frequency": "daily" } } ``` **Or with HMAC signature (recommended for production):** ```json theme={null} { "packages": [...], "reporting_webhook": { "url": "https://buyer.example.com/webhooks/reporting", "authentication": { "schemes": ["HMAC-SHA256"], "credentials": "shared_secret_min_32_chars" }, "reporting_frequency": "daily" } } ``` **Security is Required:** * `authentication` configuration is mandatory (minimum 32 characters) * **Bearer tokens**: Simple, good for development (Authorization header) * **HMAC-SHA256**: Production-recommended, prevents replay attacks (signature headers) * Credentials exchanged out-of-band during publisher onboarding * See [Security](/dist/docs/3.1.20/building/by-layer/L1/security) for implementation details #### Supported Frequencies Publishers declare supported reporting frequencies in the product's `reporting_capabilities`. Publishers are **not required** to support all frequencies - choose what makes operational sense for your platform. * **`hourly`**: Receive notifications every hour during campaign flight (optional, consider cost/complexity) * **`daily`**: Receive notifications once per day (most common, recommended for Phase 1) * **`monthly`**: Receive notifications once per month (timezone specified by publisher) **Cost Consideration:** Hourly webhooks generate 24x more traffic than daily. Large buyer-seller pairs may prefer offline reporting mechanisms (see below) for cost efficiency. #### Available Metrics Metric availability is declared at two levels: 1. **Product level**: `reporting_capabilities.available_metrics` declares what the platform can report 2. **Format level**: `reported_metrics` on a creative format declares what the format can produce (see [Reported Metrics](/dist/docs/3.1.20/creative/formats#reported-metrics)) Buyers receive the intersection of both. `impressions` and `spend` are always reported regardless of the intersection. Standard metrics include: * **`impressions`**: Ad views (always available) * **`spend`**: Amount spent (always available) * **`clicks`**: Click events * **`ctr`**: Click-through rate * **`views`**: Views at platform-defined threshold * **`completed_views`**: Video/audio completions (or threshold-based completion when an optimization goal sets a custom view duration) * **`completion_rate`**: Completion rate (`completed_views` / `impressions`); `null` when not applicable (e.g. a non-video buy) * **`conversions`**: Post-click or post-view conversions * **`conversion_value`**: Monetary value of attributed conversions * **`roas`**: Return on ad spend * **`cost_per_acquisition`**: Cost per conversion * **`new_to_brand_rate`**: Fraction of conversions from first-time brand buyers * **`leads`**: Leads generated * **`reach`**: Unique reach (paired with `reach_unit`). Measurement window declared via `reach_window` (`cumulative` / `period` / `rolling`). When `reach_window` is omitted, the window is unspecified — buyers MUST NOT sum reach across rows. * **`reach_window`**: Window semantics for reach/frequency — object with `kind` (`cumulative` since campaign start, `period` for non-overlapping snapshot, `rolling` for trailing window) and `period: Duration` (required for `period` and `rolling`) * **`frequency`**: Average frequency per reach unit, measured over `reach_window` * **`grps`**: Gross Rating Points (for CPP billing) * **`engagements`**: Direct ad interactions beyond viewing (reactions, taps, opens) * **`engagement_rate`**: Platform-specific engagement rate * **`follows`**: New followers, page likes, artist/podcast/channel follows, or free channel/feed subscribes attributed to delivery. Paid subscriptions are conversion events with `event_type: "subscribe"`. * **`saves`**: Saves, bookmarks, playlist adds attributed to delivery (platforms vary in name — Pinterest "repins", TikTok "video\_saves" — all report under this canonical key) * **`profile_visits`**: Visits to the brand's in-platform page * **`viewability`**: Viewability data (measurable\_impressions, viewable\_impressions, viewable\_rate, viewed\_seconds, standard, vendor). Separates MRC and GroupM standards. `viewed_seconds` is the average in-view duration per measurable impression — reporting-side counterpart to the `viewed_seconds` optimization goal, governed by the same `standard` threshold as `viewable_rate`. The optional `vendor` field carries a `BrandRef` so the row is self-describing — buyer agents reading delivery in isolation can attribute the numbers to a measurement vendor without joining back to `package.committed_metrics` or `package.performance_standards`. * **`quartile_data`**: Video quartile completion data (q1-q4); `null` when not applicable (e.g. a non-video buy) * **`dooh_metrics`**: DOOH-specific metrics (loop plays, screens, venue breakdown) * **`cost_per_click`**: Cost per click (`spend / clicks`) * **`cost_per_completed_view`**: Cost per completed view (`spend / completed_views`); CPCV pricing scalar for video/audio inventory * **`cpm`**: Cost per thousand impressions (`(spend / impressions) × 1000`); universal pricing scalar across CTV, display, mobile/web video, native, audio, and DOOH * **`downloads`**: Audio/podcast downloads (IAB Podcast Measurement Technical Guidelines methodology); distinct from `views` * **`units_sold`**: Items sold attributed to delivery (retail-media commerce scalar; distinct from `conversions` — one transaction may carry multiple units; attribution-window declared via `measurement_terms`) * **`new_to_brand_units`**: Unit-volume parallel to `new_to_brand_rate` — count of units sold to first-time brand buyers * **`plays`**: Raw play count for DOOH/broadcast inventory (mirrors `forecastable-metric.plays`); distinct from `dooh_metrics.loop_plays` (per-screen rotation) and `impressions` (multiplied audience figure) Buyers can optionally request a subset via `requested_metrics` to reduce payload size and focus on relevant KPIs. For `completion_rate` and `quartile_data`, sellers MAY return `null` to signal the metric does not apply (e.g. on a non-video buy), and clients MUST accept `null` as a valid value for these two fields. Every other metric signals "not applicable" by omission — sellers omit it rather than sending `null`. #### Vendor-Defined Metrics The standard `available_metrics` enum is the closed protocol vocabulary. Vendor-defined metrics — proprietary attention scores, emissions per impression, panel-based demographics, brand-lift surveys, in-flight attention panels, custom viewability variants — live in a parallel structured surface: * **Declaration** (`reporting_capabilities.vendor_metrics`): each entry is a pointer (`{ vendor: BrandRef, metric_id }`) into the vendor's metric catalog. Sellers say "I support this vendor's metric"; everything else (category, methodology, standard alignment, human-readable documentation) lives at the vendor. Identifiers are namespaced by the vendor — the same `metric_id` may mean different things in different vendors' vocabularies. * **Discovery anchor**: the vendor's `brand.json` `agents[type='measurement']` is the canonical place to find the measurement agent's URL, capability profile, the methodology, the standards each metric implements, and per-metric documentation. AdCP does not duplicate that metadata on every seller's per-product extension; buyers resolve it once per vendor (cacheable) when they need it. * **Filter** (`get_products` `filters.required_vendor_metrics`): each entry pins `vendor` and/or `metric_id` — at least one. Cross-vendor queries (e.g., "any attention measurement from a vendor that supports it") are the buyer agent's responsibility: the agent resolves which vendors offer a category via their `brand.json` records, then enumerates them as filter entries. Same filter-not-fail convention as the other `required_*` filters. * **Reporting** (`vendor_metric_values` on each `by_package` delivery row): each value carries `{ vendor, metric_id, value, unit?, measurable_impressions?, breakdown? }`. `measurable_impressions` is the coverage denominator — vendor measurement is rarely 100% of delivered impressions. When the field is present, buyers compute coverage as `measurable_impressions / impressions`; when absent, coverage is unspecified (do not compute a rate or assume full coverage). The `breakdown` slot is the place vendors put structured payloads beyond a single scalar (panel demographics, co-view ratios, incremental decompositions); it is the only escape hatch — the rest of the value envelope is closed. * **Promotion path**: when the industry converges on a metric via a published standard, the spec adds it to the closed `available_metrics` enum and the vendor extensions become historical aliases. Promotion is anchored on standards-body publication, not on ad-hoc vendor convergence counts. * **Accountability scope**: when the seller stamps a vendor metric in `package.committed_metrics` (with `scope: "vendor"`), it is subject to the same `missing_metrics` contract on `get_media_buy_delivery` as standard metrics. Sellers who can't credibly attest to a vendor metric SHOULD NOT stamp it in `committed_metrics`; absence keeps the metric advisory and reconciliation falls back to `vendor_metric_values.measurable_impressions` coverage plus out-of-band verification via the vendor's measurement agent. The advisory-vs-accountable distinction is now explicit at the contract layer rather than asymmetric across metric scopes. #### Publisher Commitment When a reporting webhook is configured, publishers commit to sending: **(campaign\_duration / reporting\_frequency) + 1** notifications * One notification per frequency period during the campaign * One final notification when the campaign completes * If reporting data is delayed beyond the expected delay window, a `"delayed"` notification will be sent #### Webhook Payload Reporting webhooks send a full MCP webhook envelope. The delivery report itself uses the same payload structure as [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) with additional metadata, but it is nested under `result`. Do not send the inner delivery object as the top-level POST body. ```json theme={null} { "idempotency_key": "whk_20240205_example_000005", "operation_id": "delivery_report_mb001_2024_02_05", "task_id": "delivery_report_mb001_2024_02_05_000005", "task_type": "media_buy_delivery", "status": "completed", "timestamp": "2024-02-06T08:00:00Z", "message": "Scheduled media buy delivery report available", "result": { "notification_type": "scheduled", "sequence_number": 5, "next_expected_at": "2024-02-06T08:00:00Z", "reporting_period": { "start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z" }, "currency": "USD", "media_buy_deliveries": [ { "media_buy_id": "mb_001", "status": "active", "totals": { "impressions": 125000, "spend": 5625.0, "clicks": 250, "ctr": 0.002 }, "by_package": [] } ] } } ``` The inner `result` object can be shown or stored separately, but this shape is not valid as the top-level webhook POST body: ```json theme={null} { "notification_type": "scheduled", "sequence_number": 5, "reporting_period": { "start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z" }, "currency": "USD", "media_buy_deliveries": [ { "media_buy_id": "mb_001", "status": "active", "totals": { "impressions": 125000, "spend": 5625.0 }, "by_package": [] } ] } ``` **Fields:** * **`idempotency_key`**: Unique per webhook event and stable across retries of that same event. Receivers dedupe by this field. * **Top-level `status`**: Async webhook status, such as `"completed"` for a delivered report. * **`notification_type`**: `"scheduled"` (regular update), `"final"` (campaign complete), or `"delayed"` (data not yet available) * **`sequence_number`**: Sequential notification number (starts at 1) * **`next_expected_at`**: ISO 8601 timestamp for next notification (omitted for final notifications) * **`media_buy_deliveries[].status`**: Media buy lifecycle or reporting status, distinct from the top-level async webhook status. * **`media_buy_deliveries`**: Array of media buy delivery data (may contain multiple media buys aggregated by publisher) Webhook signatures and content digests are computed over the exact raw bytes of the full envelope being sent, not over a reserialized `result` object. #### Timezone Handling **All reporting MUST use UTC.** This eliminates DST complexity, simplifies reconciliation, and ensures consistent 24-hour reporting periods. ```json theme={null} { "reporting_capabilities": { "timezone": "UTC", "available_reporting_frequencies": ["daily"], "date_range_support": "date_range" } } ``` **Reporting periods:** * Daily: 00:00:00Z to 23:59:59Z (always 24 hours) * Hourly: Top of hour to 59:59 seconds (always 1 hour) * Monthly: First to last day of month **Example webhook payload:** ```json theme={null} { "reporting_period": { "start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z" } } ``` #### Delayed Reporting If reporting data is not available within the product's `expected_delay_minutes`, publishers send a notification with `notification_type: "delayed"`: ```json theme={null} { "notification_type": "delayed", "sequence_number": 3, "next_expected_at": "2024-02-06T10:00:00Z", "message": "Reporting data delayed due to upstream processing. Expected availability in 2 hours." } ``` This prevents buyers from incorrectly assuming a missed notification. #### Measurement Maturation Windows For channels where billing-grade data is produced in **phases** rather than arriving final on day one, sellers declare `measurement_windows` on their products. Each window describes a maturation stage with its own expected availability. This pattern is used across channels: | Channel | Typical windows | | -------------------------- | -------------------------------------------------------------------------------- | | Broadcast / linear TV | `live` (same day) → `c3` (\~4 days) → `c7` (\~15–22 days, guarantee basis) | | DOOH | `tentative` (same day) → `final` post-IVT/fraud-check (\~1 day, guarantee basis) | | Digital with IVT filtering | raw → `post_givt` → `post_sivt` (\~2–3 days, guarantee basis) | | Podcast | `downloads_7d` → `downloads_30d` (guarantee basis) | Each window's numbers supersede the previous one. One window is typically the `is_guarantee_basis` — the number both sides reconcile against. The measurement vendor's processing time is captured in `expected_availability_days` on each window (it includes both accumulation and processing). For broadcast, delayed or sparse early-window data is usually a measurement-vendor settlement issue, not a seller-side metadata problem. Nielsen, Comscore, VideoAmp, or another named vendor may publish Live, C3, C7, Live+5, or market-level results in waves. During that period, sellers should label delivery as provisional or awaiting measurement-vendor finalization, preserve the `measurement_window`, and use `is_final`, `finalized_at`, `supersedes_window`, and delayed/window-update notifications to show what changed. Buyers should not fork creative records or product metadata to represent partial affiliate or station visibility; aggregate the seller's delivery rows by market, placement, daypart, creative, and measurement window instead. Sellers set `expected_delay_minutes` on their products to reflect the first-available data pipeline. The `measurement_windows` array on `reporting_capabilities` provides per-window timelines. Delivery data for products with measurement windows includes three fields on each package: * **`is_final`** — `true` when the seller considers this data closed for the reporting period. `false` when the data will be updated (wider window, more processing). Absent when the seller doesn't distinguish provisional from final. * **`measurement_window`** — which window this data represents (e.g., `"c3"`). References a `window_id` from the product's `measurement_windows`. Absent for standard digital reporting without windowed maturation. * **`supersedes_window`** — which previous window this report replaces (e.g., `"live"` when C3 data arrives). Absent on the first report for a period. When the seller sends updated data for the same period with a wider window, they use `notification_type: "window_update"`. This is distinct from `adjusted` (a correction within the same window). **Measurement window lifecycle example** — a broadcast spot airing March 1: **March 2** — Live data arrives (notification\_type: `scheduled`): ```json theme={null} { "notification_type": "scheduled", "media_buy_deliveries": [{ "media_buy_id": "mb_nova_q4", "by_package": [{ "package_id": "primetime_30s", "impressions": 980000, "spend": 24500, "is_final": false, "measurement_window": "live" }] }] } ``` **March 5** — C3 data supersedes live (notification\_type: `window_update`): ```json theme={null} { "notification_type": "window_update", "media_buy_deliveries": [{ "media_buy_id": "mb_nova_q4", "by_package": [{ "package_id": "primetime_30s", "impressions": 1050000, "spend": 26250, "is_final": false, "measurement_window": "c3", "supersedes_window": "live" }] }] } ``` **March 16** — C7 data arrives, final for this period (notification\_type: `window_update`): ```json theme={null} { "notification_type": "window_update", "media_buy_deliveries": [{ "media_buy_id": "mb_nova_q4", "by_package": [{ "package_id": "primetime_30s", "impressions": 1120000, "spend": 28000, "is_final": true, "measurement_window": "c7", "supersedes_window": "c3" }] }] } ``` The buyer replaces stored data each time a `window_update` arrives. When `is_final: true`, this is the number to reconcile against the guarantee. The same lifecycle shape applies to DOOH (`tentative` → `final`), digital with IVT filtering (`post_givt` → `post_sivt`), podcast (`downloads_7d` → `downloads_30d`), and any other channel where data matures in phases — only the window IDs and timing differ. For how `measurement_window` appears on billing terms and drives reconciliation and invoicing clocks, see [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability). #### Webhook Aggregation Publishers SHOULD aggregate webhooks to reduce call volume when multiple media buys share: * Same webhook URL * Same reporting frequency * Same reporting period **Example**: Buyer has 100 active campaigns with daily reporting to the same endpoint. Publisher sends: * **Without aggregation**: 100 webhooks per day (inefficient) * **With aggregation**: 1 webhook per day containing all 100 campaigns (optimal) The `media_buy_deliveries` array may contain 1 to N media buys per webhook. Buyers should iterate through the array to process each campaign's data. **Aggregated webhook example:** ```json theme={null} { "notification_type": "scheduled", "reporting_period": { "start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z" }, "currency": "USD", "media_buy_deliveries": [ { "media_buy_id": "mb_001", "totals": { "impressions": 50000, "spend": 1750 }, ... }, { "media_buy_id": "mb_002", "totals": { "impressions": 48500, "spend": 1695 }, ... }, // ... 98 more media buys ] } ``` Buyers should iterate through the array and process each media buy independently. If aggregated totals are needed, calculate them from the individual media buy totals. #### Partial Failure Handling When aggregating multiple media buys into a single webhook, publishers must handle cases where some campaigns have data available while others don't. **Approach: Best-Effort Delivery with Status Indicators** Publishers SHOULD send aggregated webhooks containing all available data, using status fields to indicate partial availability: ```json theme={null} { "notification_type": "scheduled", "sequence_number": 5, "reporting_period": { "start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z" }, "currency": "USD", "media_buy_deliveries": [ { "media_buy_id": "mb_001", "status": "active", "totals": { "impressions": 50000, "spend": 1750 } }, { "media_buy_id": "mb_002", "status": "active", "totals": { "impressions": 48500, "spend": 1695 } }, { "media_buy_id": "mb_003", "status": "reporting_delayed", "message": "Reporting data temporarily unavailable for this campaign", "expected_availability": "2024-02-06T02:00:00Z" } ], "partial_data": true, "unavailable_count": 1 } ``` **Key Fields for Partial Failures:** * `partial_data`: Boolean indicating if any campaigns are missing data * `unavailable_count`: Number of campaigns with delayed/missing data * `status`: Per-campaign status (`"active"`, `"reporting_delayed"`, `"failed"`) * `expected_availability`: When delayed data is expected (if known) **When to Use Partial Delivery:** 1. **Upstream delays**: Some data sources are slower than others 2. **System degradation**: Partial system outage affects subset of campaigns 3. **Data quality issues**: Specific campaigns fail validation, others proceed 4. **Rate limiting**: API limits prevent fetching all campaign data **When NOT to Use Partial Delivery:** 1. **Complete system outage**: Send `"delayed"` notification instead 2. **All campaigns affected**: Use `notification_type: "delayed"` 3. **Buyer endpoint issues**: Circuit breaker handles this (don't send at all) **Buyer Processing Logic:** ```javascript theme={null} function processAggregatedWebhook(webhook) { if (webhook.partial_data) { console.warn(`Partial data: ${webhook.unavailable_count} campaigns delayed`); } for (const delivery of webhook.media_buy_deliveries) { if (delivery.status === 'reporting_delayed') { // Mark campaign as pending, retry via polling or wait for next webhook markCampaignPending(delivery.media_buy_id, delivery.expected_availability); } else if (delivery.status === 'active') { // Process normal delivery data processCampaignMetrics(delivery); } else { console.error(`Unexpected status for ${delivery.media_buy_id}: ${delivery.status}`); } } } ``` **Best Practices:** * Always include all campaigns in array, even if data unavailable (with status indicator) * Set `partial_data: true` flag when any campaigns are delayed/failed * Provide `expected_availability` timestamp if known * Don't retry the entire webhook - buyers can poll individual campaigns if needed * Track partial delivery rates in monitoring to detect systemic issues #### Privacy and Compliance ##### PII Scrubbing for GDPR/CCPA Publishers MUST scrub personally identifiable information (PII) from all webhook payloads to ensure GDPR and CCPA compliance. Reporting webhooks should contain only aggregated, anonymized metrics. **What to Scrub:** * User IDs, device IDs, IP addresses * Email addresses, phone numbers * Precise geolocation data (latitude/longitude) * Cookie IDs, advertising IDs (unless aggregated) * Any custom dimensions containing PII **What to Keep:** * Aggregated metrics (impressions, spend, clicks, etc.) * Coarse geography (city, state, country - not street address) * Device type categories (mobile, desktop, tablet) * Browser/OS categories * Time-based aggregations **Example - Before PII Scrubbing (❌ DO NOT SEND):** ```json theme={null} { "media_buy_id": "mb_001", "user_events": [ { "user_id": "user_12345", "ip_address": "192.168.1.100", "device_id": "abc-def-ghi", "impressions": 1, "lat": 40.7128, "lon": -74.0060 } ] } ``` **Example - After PII Scrubbing (✅ CORRECT):** ```json theme={null} { "media_buy_id": "mb_001", "totals": { "impressions": 125000, "spend": 5625.00, "clicks": 250 }, "by_package": [ { "package_id": "pkg_001", "impressions": 125000, "spend": 5625.00, "by_geo": [ { "geo_level": "region", "geo_code": "US-NY", "geo_name": "New York", "impressions": 45000, "spend": 2025.00 } ], "by_geo_truncated": false } ] } ``` **Publisher Responsibilities:** * Implement PII scrubbing at the data collection layer, not at webhook delivery * Ensure aggregation thresholds prevent re-identification (e.g., minimum 10 users per segment) * Document what data is collected vs. what is shared in webhooks * Provide data processing agreements (DPAs) for GDPR compliance * Support GDPR/CCPA data deletion requests **Buyer Responsibilities:** * Do not request PII in `requested_metrics` or custom dimensions * Understand that webhook data is aggregated and anonymized * Implement proper data retention policies * Include webhook data in privacy policies and user disclosures #### Implementation Best Practices 1. **Handle Arrays**: Always process `media_buy_deliveries` as an array, even if it contains one element 2. **Idempotent Handlers**: Process duplicate notifications safely (webhooks use at-least-once delivery) 3. **Sequence Tracking**: Use `sequence_number` to detect missing or out-of-order notifications 4. **Fallback Polling**: Continue periodic polling as backup if webhooks fail 5. **Timezone Awareness**: Store publisher's reporting timezone for accurate period calculation 6. **Validate Frequency**: Ensure requested frequency is in product's `available_reporting_frequencies` 7. **Validate Metrics**: Ensure requested metrics are in product's `available_metrics` 8. **PII Compliance**: Never include user-level data in webhook payloads #### Webhook Health Monitoring Webhook delivery status is tracked through **AdCP's global task management system** (see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle)). When a media buy is created with `reporting_webhook` configured, the publisher creates an ongoing task for webhook delivery. Buyers can monitor webhook health using standard task queries. **Benefits of using task management:** * Consistent status tracking across all AdCP operations * Standard polling/webhook notification patterns * Existing infrastructure for task status, history, and errors * No need for media-buy-specific webhook health endpoints If webhook delivery fails persistently (circuit breaker opens), publishers update the task status to indicate the issue. Buyers detect this through normal task monitoring. ### Offline-File-Delivery-Based Reporting **Example: Offline Delivery** Publisher pushes daily report files to buyer's cloud storage: ``` s3://buyer-reports/publisher_name/2024/02/05/media_buy_delivery.json.gz ``` File contains same structure as webhook payload but aggregated across all campaigns. Buyer processes files on their schedule. **When to Use Offline Delivery:** * \>100 active campaigns with same buyer * Hourly reporting requirements (24x cost reduction) * High data volume (detailed breakdowns, dimensional data) * Buyer has batch processing infrastructure Sellers declare their supported push-based delivery methods and protocols in `get_adcp_capabilities`. Polling via `get_media_buy_delivery` is always available — it is a required task for all `media_buy` sellers. ```json theme={null} { "media_buy": { "reporting_delivery_methods": ["webhook", "offline"], "offline_delivery_protocols": ["s3", "gcs"] } } ``` Buyers express a protocol preference when syncing accounts. The seller provisions the bucket using the preferred protocol if supported: ```json theme={null} { "accounts": [{ "brand": { "domain": "nova-brands.com" }, "operator": "pinnacle-media.com", "billing": "operator", "preferred_reporting_protocol": "s3" }] } ``` Products declare supported cadence and metrics in `reporting_capabilities`: ```json theme={null} { "reporting_capabilities": { "available_reporting_frequencies": ["daily"], "supports_webhooks": true, "available_metrics": ["impressions", "spend", "clicks"], "date_range_support": "date_range" } } ``` For offline delivery, the seller provisions storage per account and grants the buyer read access out-of-band. Sellers may use a dedicated bucket per account or a shared bucket with per-account `prefix` isolation — either way, the buyer can only access data under their account's path. When multiple buying platforms operate on the same brand, each gets a separate account (scoped by operator/agent), so their data is isolated by prefix. The bucket location appears on the account object returned by `sync_accounts`: ```json theme={null} { "account_id": "acc_pinnacle_001", "status": "active", "reporting_bucket": { "protocol": "s3", "bucket": "seller-reports", "prefix": "accounts/pinnacle/adcp", "region": "us-east-1", "format": "jsonl", "compression": "gzip", "file_retention_days": 30, "setup_instructions": "https://seller.example.com/docs/bucket-access" } } ``` Buyers read from the bucket on their own schedule. The seller pushes files at the product's reporting frequency. **Delivery method determines the reporting path:** * `get_media_buy_delivery` is a required task for all `media_buy` sellers. Polling is always available as a baseline, regardless of which push methods the seller supports. * When `reporting_delivery_methods` includes `offline` and a `reporting_bucket` is present on the account, the seller also pushes detailed delivery data to the bucket. Buyers with batch infrastructure should read from the bucket for efficiency. * Files in the bucket are retained for `file_retention_days` (declared on the `reporting_bucket`). Buyers must read files within this window. * `get_media_buys` always returns the media buy object with status, totals, and pacing snapshots. Use it for status checks, not detailed reporting. For offline file delivery, publishers can provide reporting data in JSON Lines (JSONL), CSV, Parquet, Avro, or ORC format. All formats preserve the nested JSON structure from webhook payloads, making them ideal for batch processing. JSONL and CSV files can be compressed with gzip (`.jsonl.gz`, `.csv.gz`) to reduce storage and transfer costs. Parquet, Avro, and ORC use internal compression — the top-level `compression` field is ignored for these formats. #### JSON Lines (JSONL) One media buy delivery per line (newline-delimited JSON). Each line contains a single media buy delivery object with its reporting period and package-level data. Preserves full nested structure. Easy to parse line-by-line for streaming processing. **Example JSONL file:** ```jsonl theme={null} {"notification_type": "scheduled", "sequence_number": 5, "next_expected_at": "2024-02-06T08:00:00Z", "reporting_period": {"start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z"}, "currency": "USD", "media_buy_id": "mb_001", "status": "active", "totals": {"impressions": 50000, "spend": 1750.00, "clicks": 100, "ctr": 0.002}, "by_package": [{"package_id": "pkg_001", "impressions": 30000, "spend": 1050.00, "pacing_index": 0.95, "pricing_model": "cpm", "rate": 0.035, "currency": "USD"}, {"package_id": "pkg_002", "impressions": 20000, "spend": 700.00, "pacing_index": 0.98, "pricing_model": "cpm", "rate": 0.035, "currency": "USD"}]} {"notification_type": "scheduled", "sequence_number": 5, "next_expected_at": "2024-02-06T08:00:00Z", "reporting_period": {"start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z"}, "currency": "USD", "media_buy_id": "mb_002", "status": "active", "totals": {"impressions": 200000, "spend": 9000.00, "clicks": 400, "ctr": 0.002}, "by_package": [{"package_id": "pkg_003", "impressions": 200000, "spend": 9000.00, "pacing_index": 1.02, "pricing_model": "cpm", "rate": 45.00, "currency": "USD"}]} {"notification_type": "scheduled", "sequence_number": 5, "next_expected_at": "2024-02-06T08:00:00Z", "reporting_period": {"start": "2024-02-05T00:00:00Z", "end": "2024-02-05T23:59:59Z"}, "currency": "USD", "media_buy_id": "mb_003", "status": "active", "totals": {"impressions": 75000, "spend": 3375.00, "clicks": 150, "ctr": 0.002}, "by_package": [{"package_id": "pkg_004", "impressions": 75000, "spend": 3375.00, "pacing_index": 0.96, "pricing_model": "cpcv", "rate": 0.045, "currency": "USD"}]} ``` #### CSV **For tabular analysis** CSV files require unnesting nested arrays. Each record should be unnested to the `by_package` level, meaning one row per package with parent-level data (reporting period, media buy info, totals) duplicated. **Example CSV structure:** ```csv theme={null} notification_type,sequence_number,next_expected_at,reporting_period_start,reporting_period_end,currency,media_buy_id,status,totals_impressions,totals_spend,totals_clicks,totals_ctr,by_package_package_id,by_package_impressions,by_package_spend,by_package_clicks,by_package_pacing_index,by_package_pricing_model,by_package_rate,by_package_currency scheduled,5,2024-02-06T08:00:00Z,2024-02-05T00:00:00Z,2024-02-05T23:59:59Z,USD,mb_001,active,50000,1750.00,100,0.002,pkg_001,30000,1050.00,60,0.95,cpm,0.035,USD scheduled,5,2024-02-06T08:00:00Z,2024-02-05T00:00:00Z,2024-02-05T23:59:59Z,USD,mb_001,active,50000,1750.00,100,0.002,pkg_002,20000,700.00,40,0.98,cpm,0.035,USD scheduled,5,2024-02-06T08:00:00Z,2024-02-05T00:00:00Z,2024-02-05T23:59:59Z,USD,mb_002,active,200000,9000.00,400,0.002,pkg_003,200000,9000.00,400,1.02,cpm,45.00,USD scheduled,5,2024-02-06T08:00:00Z,2024-02-05T00:00:00Z,2024-02-05T23:59:59Z,USD,mb_003,active,75000,3375.00,150,0.002,pkg_004,75000,3375.00,150,0.96,cpcv,0.045,USD ``` #### Parquet **For high-volume analytics** Columnar format optimized for analytics workloads. Excellent compression ratios. Supports nested structures natively. Best for data warehouses and big data processing. **Example Parquet schema:** ```json theme={null} { "type": "record", "name": "MediaBuyDelivery", "fields": [ {"name": "notification_type", "type": "string"}, {"name": "sequence_number", "type": "int"}, {"name": "next_expected_at", "type": "string"}, {"name": "reporting_period", "type": { "type": "record", "name": "ReportingPeriod", "fields": [ {"name": "start", "type": "string"}, {"name": "end", "type": "string"} ] }}, {"name": "currency", "type": "string"}, {"name": "media_buy_id", "type": "string"}, {"name": "status", "type": "string"}, {"name": "totals", "type": { "type": "record", "name": "Totals", "fields": [ {"name": "impressions", "type": "long"}, {"name": "spend", "type": "double"}, {"name": "clicks", "type": "long"}, {"name": "ctr", "type": "double"} ] }}, {"name": "by_package", "type": { "type": "array", "items": { "type": "record", "name": "PackageDelivery", "fields": [ {"name": "package_id", "type": "string"}, {"name": "impressions", "type": "long"}, {"name": "spend", "type": "double"}, {"name": "pacing_index", "type": "double"}, {"name": "pricing_model", "type": "string"}, {"name": "rate", "type": "double"}, {"name": "currency", "type": "string"} ] } }} ] } ``` #### Avro **For schema-rich streaming pipelines** Row-oriented format with embedded schema. Self-describing — readers don't need external schema files. Handles schema evolution (adding/removing fields) gracefully. Common in Kafka and Hadoop ecosystems. Uses internal compression (snappy, deflate, or zstd). **Example Avro schema:** ```json theme={null} { "type": "record", "name": "MediaBuyDelivery", "namespace": "org.example.reporting", "fields": [ {"name": "notification_type", "type": "string"}, {"name": "sequence_number", "type": "int"}, {"name": "next_expected_at", "type": "string"}, {"name": "reporting_period", "type": { "type": "record", "name": "ReportingPeriod", "fields": [ {"name": "start", "type": "string"}, {"name": "end", "type": "string"} ] }}, {"name": "currency", "type": "string"}, {"name": "media_buy_id", "type": "string"}, {"name": "status", "type": "string"}, {"name": "totals", "type": { "type": "record", "name": "Totals", "fields": [ {"name": "impressions", "type": "long"}, {"name": "spend", "type": "double"}, {"name": "clicks", "type": "long"}, {"name": "ctr", "type": "double"} ] }}, {"name": "by_package", "type": { "type": "array", "items": { "type": "record", "name": "PackageDelivery", "fields": [ {"name": "package_id", "type": "string"}, {"name": "impressions", "type": "long"}, {"name": "spend", "type": "double"}, {"name": "pacing_index", "type": "double"}, {"name": "pricing_model", "type": "string"}, {"name": "rate", "type": "double"}, {"name": "currency", "type": "string"} ] } }} ] } ``` #### ORC **For Hive/Spark analytics** Columnar format optimized for read-heavy analytics on Hadoop-ecosystem tools (Hive, Spark, Presto). Predicate pushdown, built-in indexes, and lightweight compression (snappy, zlib, or zstd) reduce I/O. Supports nested structures via struct and array types. ORC uses the same logical schema as Parquet. Choose ORC when your data warehouse is Hive-native; choose Parquet for broader tool compatibility. **File Structure:** Each file contains one media buy delivery per line (JSONL), row (CSV/Parquet/ORC), or record (Avro). Files may contain: * Multiple media buy deliveries (one per line/row) * Multiple reporting periods for the same media buy (separate rows) * Multiple media buys (each with its own rows) **Processing Recommendations:** * Process files in chronological order using file timestamps * Handle duplicate files gracefully (idempotent processing) * Validate file integrity using checksums if provided * Monitor for missing files and alert on gaps ### Security considerations for offline delivery Offline files sit at rest for `file_retention_days`, so a misconfigured IAM policy leaks historical reporting across tenants. The [general security controls](/dist/docs/3.1.20/building/by-layer/L1/security) apply; the offline-specific requirements: * **Scope access at the IAM layer, not by obscurity.** Buyer read access MUST be scoped to `{bucket}/{prefix}/*` (S3 bucket policy condition, GCS conditional IAM binding on `resource.name.startsWith(...)`, or Azure SAS scoped to the prefix). A bucket-wide read grant is non-compliant even when the seller only writes under one prefix per account. * **Scope listing as well as reads.** Prefix scoping MUST cover both object-level operations (`s3:GetObject`) and listing (`s3:ListBucket` with an `s3:prefix` condition). A policy that scopes `GetObject` to the prefix but leaves `ListBucket` unscoped lets a buyer enumerate other tenants' prefix names — a cross-tenant isolation failure even without read access to their objects. The same applies to GCS `storage.objects.list` and Azure `list` SAS permissions. * **Revoke access when the account closes.** When the seller emits an `account.status` transition to `inactive`, `suspended`, or `closed`, the seller MUST stop honoring the associated credentials, and buyers SHOULD treat that status change as the trigger to remove matching IAM trust on their end. A seller IAM role left granted to a decommissioned bucket is a lateral-movement risk. PII scrubbing requirements (see [above](#pii-scrubbing-for-gdpr-ccpa)) apply to offline files identically — scrub at the collection layer, not at delivery, because the files accumulate at rest. `setup_instructions` is a seller-provided URL. It is operator-facing documentation, not agent-consumable content. Buyer agents MUST NOT auto-fetch the URL; they SHOULD surface it to a human operator. If an implementation chooses to fetch it (for example, to preview the target before showing it to the operator), apply [webhook URL SSRF validation](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-url-validation-ssrf), and the fetched content MUST NOT be passed into an LLM context without indirect-prompt-injection guarding — seller-controlled text in this field can contain instructions to rotate credentials, change billing, or alter downstream agent behavior. ## Data Reconciliation **The `get_media_buy_delivery` API is the authoritative source of truth for all campaign metrics.** Polling is always available as a baseline. When the seller also supports push-based delivery (webhooks or offline buckets), those methods provide timely data but `get_media_buy_delivery` remains the reconciliation path. Reconciliation is important for **any reporting delivery method** because: * **Webhooks**: May be missed due to network failures or circuit breaker drops * **Offline files**: May be delayed, corrupted, or fail to process * **Polling**: May miss data during API outages * **Late-arriving data**: Impressions can arrive 24-48+ hours after initial reporting (all methods) #### Reconciliation Process Buyers SHOULD periodically reconcile delivered data against API to ensure accuracy: **Recommended Reconciliation Schedule:** * **Hourly delivery**: Reconcile via API daily * **Daily delivery**: Reconcile via API weekly * **Monthly delivery**: Reconcile via API at month end + 7 days * **Campaign close**: Always reconcile after campaign\_end + attribution\_window **Reconciliation Logic:** ```javascript theme={null} async function reconcileWebhookData(mediaBuyId, startDate, endDate) { // Get authoritative data from API const apiData = await adcp.getMediaBuyDelivery({ media_buy_id: mediaBuyId, date_range: { start: startDate, end: endDate } }); // Compare with webhook data in local database const webhookData = await db.getWebhookTotals(mediaBuyId, startDate, endDate); const discrepancy = { impressions: apiData.totals.impressions - webhookData.impressions, spend: apiData.totals.spend - webhookData.spend, clicks: apiData.totals.clicks - webhookData.clicks }; // Acceptable discrepancy thresholds const impressionVariance = Math.abs(discrepancy.impressions) / apiData.totals.impressions; const spendVariance = Math.abs(discrepancy.spend) / apiData.totals.spend; if (impressionVariance > 0.02 || spendVariance > 0.01) { // Significant discrepancy (>2% impressions or >1% spend) console.warn(`Reconciliation discrepancy for ${mediaBuyId}:`, discrepancy); // Update local database with authoritative API data await db.updateCampaignTotals(mediaBuyId, apiData.totals); // Alert if discrepancy is unusually large if (impressionVariance > 0.10 || spendVariance > 0.05) { await alertOps(`Large reconciliation discrepancy detected`, { media_buy_id: mediaBuyId, webhook_totals: webhookData, api_totals: apiData.totals, discrepancy }); } } return { status: impressionVariance < 0.02 ? 'reconciled' : 'discrepancy_found', api_data: apiData.totals, webhook_data: webhookData, discrepancy }; } ``` **Why Discrepancies Occur:** 1. **Delivery failures**: Webhooks missed, offline files corrupted, API timeouts during polling 2. **Late-arriving data**: Impressions attributed after initial reporting (all delivery methods) 3. **Data corrections**: Publisher adjusts metrics after initial reporting 4. **Processing errors**: Buyer-side failures to process delivered data 5. **Timezone differences**: Period boundaries may differ between delivery and API query **Source of Truth Rules:** * **For billing**: Always use `get_media_buy_delivery` API at campaign end + attribution window * **For real-time decisions**: Use delivered data (webhook/file/poll) for speed, reconcile later * **For discrepancies**: API data wins, update local records accordingly * **For audits**: API provides complete historical data, delivered data is ephemeral **Best Practices:** * Store webhook `sequence_number` to detect missed notifications * Run automated reconciliation daily for active campaigns * Alert on discrepancies >2% for impressions or >1% for spend * Use API data for all financial reporting and invoicing * Document reconciliation process for audit compliance #### Late-Arriving Impressions Ad serving data often arrives with delays due to attribution windows, offline tracking, and pipeline latency. Publishers declare `expected_delay_minutes` in `reporting_capabilities`: * **Display/Video**: Typically 4-6 hours * **Audio**: Typically 8-12 hours * **CTV**: May be 24+ hours This represents when **most** data is available, not **all** data. #### Handling Late Arrivals When late data arrives for a previously reported period, **resend that period** with `is_adjusted: true`: ```json theme={null} { "notification_type": "adjusted", "reporting_period": { "start": "2024-02-01T00:00:00Z", "end": "2024-02-01T23:59:59Z" }, "media_buy_deliveries": [{ "media_buy_id": "mb_001", "is_adjusted": true, "totals": { "impressions": 51000, // Updated total (was 50000) "spend": 1785 // Updated spend (was 1750) } }] } ``` **Buyer Processing:** ```javascript theme={null} function processWebhook(webhook) { for (const delivery of webhook.media_buy_deliveries) { if (delivery.is_adjusted) { // Replace entire period with updated totals db.replaceCampaignPeriod( delivery.media_buy_id, webhook.reporting_period, delivery.totals ); } else { // Normal new period data db.insertCampaignPeriod(delivery.media_buy_id, webhook.reporting_period, delivery.totals); } } } ``` **When to send adjusted periods:** * Significant data changes (>2% impression variance or >1% spend variance) * Final reconciliation at campaign\_end + attribution\_window * Data quality corrections With polling-only, buyers detect adjustments through reconciliation by comparing API results over time. #### Webhook Reliability Reporting webhooks follow AdCP's standard webhook reliability patterns: * **At-least-once delivery**: Same notification may be delivered multiple times * **Best-effort ordering**: Notifications may arrive out of order * **Timeout and retry**: Limited retry attempts on delivery failure See [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for detailed implementation guidance. ## Optimization Strategies ### Conversion optimization Set optimization goals on media buy packages to direct delivery toward specific outcomes — target CPC, CPV, ROAS, or CPA. Metric goals (clicks, views) work without event setup. Event goals require configured event sources and conversion data. See [Conversion Tracking](/dist/docs/3.1.20/media-buy/conversion-tracking/) for the complete setup flow, and [Optimization Goals](/dist/docs/3.1.20/media-buy/conversion-tracking/#optimization-goals) for the `optimization_goals` array reference. ### Budget optimization * **Reallocation** between high and low performing packages via [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) * **Pacing adjustments** — switch between `even`, `asap`, or `front_loaded` delivery * **Spend efficiency** — compare cost per acquisition across packages and shift budget to the best performers ### Creative optimization * **Performance analysis** by creative asset via [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) with by-creative breakdowns * **A/B testing** — assign multiple creatives with weights via `creative_assignments` * **Refresh strategies** — swap creatives via [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for library-backed sellers, or inline `packages[].creatives` on [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) for inline-only sellers, to prevent fatigue ### Targeting refinement * **Geographic optimization** — adjust `targeting_overlay` based on delivery data by region * **Frequency management** — tune `frequency_cap` (suppress cooldown or max\_impressions/per/window cap) based on delivery patterns ## Performance Feedback Loop The performance feedback system enables AI-driven optimization by feeding back business outcomes to publishers. See [`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) for detailed API documentation. ### Performance Index Concept A normalized score indicating relative performance: * `0.0` = No measurable value or impact * `1.0` = Baseline/expected performance * `> 1.0` = Above average (e.g., 1.45 = 45% better) * `< 1.0` = Below average (e.g., 0.8 = 20% worse) ### Sharing Performance Data Buyers can voluntarily share performance outcomes using the [`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) task: ```json theme={null} { "media_buy_id": "gam_1234567890", "measurement_period": { "start": "2024-01-15T00:00:00Z", "end": "2024-01-21T23:59:59Z" }, "performance_index": 1.35, "metric_type": "conversion_rate" } ``` ### Supported Metrics * **overall\_performance**: General campaign success * **conversion\_rate**: Post-click or post-view conversions * **brand\_lift**: Brand awareness or consideration lift * **click\_through\_rate**: Engagement with creative * **completion\_rate**: Video or audio completion rates * **viewability**: Viewable impression rate * **brand\_safety**: Brand safety compliance * **cost\_efficiency**: Cost per desired outcome ### How Publishers Use Performance Data Publishers can leverage performance indices to: 1. **Optimize Delivery**: Shift impressions to high-performing segments 2. **Adjust Pricing**: Update CPMs based on proven value 3. **Improve Products**: Refine product definitions based on performance patterns 4. **Enhance Algorithms**: Train ML models on actual business outcomes ### Privacy and Data Sharing * Performance feedback sharing is voluntary and controlled by the buyer * Aggregate performance patterns may be used to improve overall platform performance * Individual campaign details remain confidential to the buyer-publisher relationship ### Dimension breakdowns Delivery data can be broken down across multiple dimensions within each package. Buyers request specific breakdowns via the `reporting_dimensions` parameter on `get_media_buy_delivery`. Each breakdown appears as a `by_*` array within `by_package` items, following the same composition pattern as `by_creative`. | Dimension | Breakdown field | Required fields | Additional fields | Capability declaration | | --------------- | -------------------- | -------------------------------------------------------- | ------------------------------------ | ------------------------------------ | | Geography | `by_geo` | `geo_level`, `geo_code`, `impressions`, `spend` | `system`, `country`, `geo_name` | `supports_geo_breakdown` | | Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` | | Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` | | Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` | | Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` | Each breakdown entry inherits all fields from `delivery-metrics` (clicks, conversions, and other optional metrics) plus its dimension-specific fields. Every entry requires the fields shown in the required fields column. Check `reporting_capabilities` on the product to discover which dimensions are available; product-level capabilities are authoritative because different products from the same seller may support different breakdowns. `supports_geo_breakdown` is an object that declares available levels and systems; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility. Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain`, the publisher namespace from the product's `placements[]` entry, so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace. Breakdowns are opt-in — no dimension data is returned unless explicitly requested. Sellers that don't support a requested dimension silently omit it. Each breakdown array has a sibling `by_*_truncated` boolean indicating whether additional rows exist beyond the requested `limit`. ## Targeting Consistency Reporting aligns with AdCP's [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) approach, enabling: * **Consistent analysis** across campaign lifecycle * **Granular breakdowns** by targeting parameters * **Cross-campaign insights** for portfolio optimization ### Target → Measure → Optimize The power of consistent targeting and reporting creates a virtuous cycle: 1. **Target**: Define your audience using briefs and overlays (e.g., "Mobile users in major metros") 2. **Measure**: Report on the same attributes (Track performance by device type and geography) 3. **Optimize**: Feed performance back to improve delivery (Shift budget to high-performing segments) ## Standard Metrics All platforms must support these core metrics: * **impressions**: Number of ad views * **spend**: Amount spent in currency * **clicks**: Number of clicks (if applicable) * **ctr**: Click-through rate (clicks/impressions) Optional standard metrics: * **conversions**: Post-click/view conversions * **viewability**: Percentage of viewable impressions * **completion\_rate**: Video/audio completion percentage * **engagement\_rate**: Platform-specific engagement metric ## Platform-Specific Considerations Different platforms offer varying reporting and optimization capabilities: ### Google Ad Manager * Comprehensive dimensional reporting, real-time and historical data, advanced viewability metrics ### Kevel * Real-time reporting API, custom metric support, flexible aggregation options ### Triton Digital * Audio-specific metrics (completion rates, skip rates), station-level performance data, daypart analysis ## Advanced Analytics ### Cross-Campaign Analysis * **Portfolio performance** across multiple campaigns * **Audience overlap** and frequency management * **Budget allocation** optimization across campaigns ### Predictive Insights * **Performance forecasting** based on historical data * **Optimization recommendations** from AI analysis * **Trend prediction** for proactive adjustments ## Response Times Optimization operations have predictable timing: * **Delivery reports**: \~60 seconds (data aggregation) * **Campaign updates**: Minutes to days (depending on changes) * **Performance analysis**: \~1 second (cached metrics) ## Best Practices 1. **Report Frequently**: Regular reporting improves optimization opportunities 2. **Track Pacing**: Monitor delivery against targets to avoid under/over-delivery 3. **Analyze Patterns**: Look for performance trends across dimensions 4. **Consider Latency**: Some metrics may have attribution delays 5. **Normalize Metrics**: Use consistent baselines for performance comparison ## Integration with Media Buy Lifecycle Optimization and reporting is the ongoing phase that runs throughout active campaigns: * **Connects to Creation**: Use learnings to improve future campaign setup * **Guides Updates**: Data-driven decisions for campaign modifications * **Enables Scale**: Proven strategies can be applied to similar campaigns * **Feeds AI**: Performance data improves automated optimization ## Related Documentation * **[`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery)** - Retrieve delivery reports * **[`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy)** - Modify campaigns based on performance * **[Media Buy Lifecycle](/dist/docs/3.1.20/media-buy/media-buys)** - Complete campaign management workflow * **[Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting)** - Brief-based targeting and overlays # Brief Expectations Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/product-discovery/brief-expectations AdCP brief expectations — how to write campaign briefs for get_products. Required fields, optional details, and implementation guidance for publishers and buyer agents. A comprehensive brief is essential for effective media buying through AdCP. This document defines the expectations and requirements for briefs in the `get_products` specification, providing implementation guidance for publishers and clear expectations for buyers. ## Overview A brief in AdCP is a natural language description of campaign requirements that helps publishers understand and fulfill media buying requests. While briefs can be as simple or detailed as needed, complete briefs enable better product recommendations and more efficient campaign execution. ## Required Components Every `get_products` and `create_media_buy` request MUST include: ### Brand The `brand` field is **required** in all requests. It identifies the advertiser brand: ```json theme={null} { "brand": { "domain": "acmecorp.com" } } ``` This enables publishers to: * Apply policy restrictions (age-gated, prohibited categories, etc.) * Verify brand identity * Enforce brand safety standards ### Brief Field The `brief` field describes **what is being promoted** and **campaign requirements**: ```json theme={null} { "buying_mode": "brief", "brief": "Nike Air Max 2024 - the latest innovation in cushioning technology featuring sustainable materials, targeting runners and fitness enthusiasts" } ``` ## When Briefs Are Optional The `brief` field is **optional only when `buying_mode` is `"wholesale"`**. For catalog discovery without publisher curation, set `buying_mode: "wholesale"` explicitly: ### Wholesale Buying Mode When the buyer will apply their own audience targeting and does not want publisher curation: ```json theme={null} { "brand": { "domain": "acmecorp.com" }, "buying_mode": "wholesale", "filters": { "delivery_type": "non_guaranteed", "standard_formats_only": true } } ``` `buying_mode: "wholesale"` and `brief` are mutually exclusive — providing both is an error. If `buying_mode: "brief"` is set explicitly, `brief` is required. When a publisher receives `buying_mode: "wholesale"`: 1. Returns products that support buyer-directed targeting 2. Does not apply AI curation or personalization 3. Does not return proposals 4. Buyer applies their own audiences, e.g. through [TMP](/dist/docs/3.1.20/trusted-match) ## Core Brief Components When a `brief` IS provided, it should include these essential elements: ### 1. Business Objectives **What you're trying to achieve** with the campaign: * **Awareness**: Build brand recognition or product awareness * **Consideration**: Drive interest and research * **Conversion**: Generate sales or sign-ups * **Retention**: Re-engage existing customers * **App installs**: Drive mobile app downloads * **Lead generation**: Capture contact information * **Traffic**: Drive website or store visits Example in brief: *"Drive awareness for our new product launch among young professionals"* ### 2. Success Metrics **How you'll measure success** of the campaign: * **CTR** (Click-Through Rate): Engagement measurement * **CPA** (Cost Per Acquisition): Conversion efficiency * **ROAS** (Return on Ad Spend): Revenue generation * **Brand lift**: Awareness and perception improvement * **Video completion rate**: Content engagement * **Conversion rate**: Action completion * **Reach and frequency**: Audience coverage Example in brief: *"Success measured by achieving 2% CTR and \$50 CPA"* ### 3. Flight Dates **When the campaign should run**: * **Start date**: Campaign launch date * **End date**: Campaign completion date * **Specific periods**: Holiday seasons, events, promotions * **Blackout dates**: Dates to avoid * **Dayparting requirements**: Time-of-day preferences Example in brief: *"Run from March 1-31, focusing on weekday morning commutes"* ## Optional Brief Components These elements enhance brief quality and enable better recommendations: ### Target Audience **Who you want to reach**: #### Demographics * Age ranges (e.g., 25-34, 35-44) * Gender identity * Household income levels * Education level * Parental status * Employment status #### Psychographics * Interests and hobbies * Lifestyle attributes * Values and beliefs * Purchase behaviors * Media consumption habits * Technology adoption #### Behavioral Signals * Past purchase behavior * Website visit history * App usage patterns * Content engagement * Shopping cart abandonment Example in brief: *"Target pet owners aged 25-45 with household income over \$75K who have shown interest in premium pet products"* ### Budget Information **Spending parameters**: * **Total budget**: Overall campaign spend * **Daily budget**: Maximum daily spend * **Budget flexibility**: Ability to adjust * **Cost constraints**: CPM limits, efficiency requirements * **Budget allocation**: Split across products or time periods Example in brief: *"\$50,000 total budget with flexibility to increase by 20% for high-performing inventory"* ### Geographic Markets **Where ads should appear**: * **Countries**: Target nations * **Regions/States**: Specific areas within countries * **Cities/DMAs**: Metropolitan areas * **Postal codes**: Hyperlocal targeting * **Exclusions**: Areas to avoid Example in brief: *"Focus on California and New York, specifically Los Angeles and New York City metros"* ### Creative Constraints **Format and content requirements**: * **Available formats**: Video, audio, display, native * **Creative variations**: Number of versions available * **Language versions**: Multilingual capabilities * **Technical limitations**: File sizes, durations * **Brand guidelines**: Color, logo, messaging requirements Example in brief: *"We have 30-second and 15-second video creatives in English and Spanish"* ### Brand Safety Requirements **Content to avoid**: * **Blocked categories**: Content types to exclude * **Sensitive topics**: Subjects to avoid * **Competitor separation**: Competing brands to avoid * **Quality standards**: Viewability, fraud prevention * **Certification requirements**: Industry certifications and standards Example in brief: *"Avoid news, political content, and competitive automotive brands"* ## Brief Quality Levels Publishers should handle briefs at different completeness levels: ### Wholesale Product Feed ```json theme={null} { "brand": {"domain": "acmecorp.com"}, "buying_mode": "wholesale", "filters": { "delivery_type": "non_guaranteed", "standard_formats_only": true } } ``` **Publisher Response**: Return wholesale products (broad reach inventory optimized for scale). No AI curation, no proposals. Buyer applies their own targeting. ### Minimal Brief ```json theme={null} { "brand": {"domain": "acmecorp.com"}, "buying_mode": "brief", "brief": "Reach business decision makers" } ``` **Publisher Response**: Request clarification on budget, timing, and specific objectives. ### Standard Brief ```json theme={null} { "brand": {"domain": "acmecorp.com"}, "buying_mode": "brief", "brief": "Acme Corp project management software - cloud-based solution for remote teams. Reach IT decision makers in tech companies with 50-500 employees, $25K budget for Q1, focusing on driving free trial signups" } ``` **Publisher Response**: Provide relevant product recommendations with clear rationale. ### Comprehensive Brief ```json theme={null} { "brand": {"domain": "acmecorp.com"}, "buying_mode": "brief", "brief": "Acme Corp project management software - cloud-based solution for remote teams with AI-powered automation. Drive 500 free trial signups from IT decision makers and project managers at tech companies (50-500 employees) in SF Bay Area and NYC. $25K budget for March 1-31, measured by $50 CPA. We have video and display creatives. Avoid competitor content and news sites." } ``` **Publisher Response**: Provide optimized product mix with detailed performance projections. ## Delivery Preferences Buyers can signal delivery type preference without excluding inventory. `preferred_delivery_types` is an ordered array that tells the publisher what the buyer wants most, while still allowing the publisher to include alternatives when they match the brief well. ```json theme={null} { "buying_mode": "brief", "brief": "Exclusive podcast sponsorship for a fintech brand — we want guaranteed placement on business shows", "preferred_delivery_types": ["guaranteed"] } ``` This differs from `filters.delivery_type`, which is a hard filter: | Field | Effect | | -------------------------- | ------------------------------------------------------------ | | `filters.delivery_type` | Excludes products that don't match | | `preferred_delivery_types` | Signals preference — publisher may still include other types | When both are present, `filters.delivery_type` takes precedence (it removes non-matching products before preference is applied). ### For publishers When a buyer includes `preferred_delivery_types`, rank preferred types first in results but include strong matches of other types lower in the response. If a buyer prefers guaranteed but you have a non-guaranteed product that matches the brief well, include it with a clear `brief_relevance` explanation of why it's worth considering. ## Implementation Guidelines ### For Publishers 1. **Parse Brief Elements**: Extract key components programmatically 2. **Handle Incompleteness**: Gracefully request missing critical information 3. **Provide Guidance**: Suggest what additional information would help 4. **Match Intelligently**: Use AI to interpret natural language and match products 5. **Explain Relevance**: Always provide `brief_relevance` field in responses ### For Buyers 1. **Be Specific**: More detail enables better recommendations 2. **Prioritize Goals**: Clearly state primary vs secondary objectives 3. **Provide Context**: Include market conditions or competitive landscape 4. **Update Iteratively**: Refine brief based on publisher feedback 5. **Maintain Consistency**: Ensure brief aligns with campaign objectives ## Brief Processing Flow ```mermaid theme={null} graph TD A[Receive Request] --> B{Request Valid?} B -->|No| C[Return Policy Error] B -->|Yes| W{Wholesale Mode?} W -->|Yes| M[Return All Products Matching Filters] W -->|No| D{Brief Provided?} D -->|No| F[Request Clarification] D -->|Yes| E{Brief Complete?} E -->|No| F E -->|Yes| G[Process Requirements] F --> H[Provide Specific Questions] G --> I[Match Products] I --> J{Products Found?} J -->|No| K[Suggest Alternatives] J -->|Yes| L[Return Recommendations] L --> N[Include Relevance Explanation] ``` ## Clarification Handling When briefs need clarification, publishers should: 1. **Ask Specific Questions**: Target missing critical information 2. **Provide Examples**: Show what complete information looks like 3. **Maintain Context**: Remember previous brief elements 4. **Suggest Defaults**: Offer reasonable assumptions 5. **Progressive Disclosure**: Don't overwhelm with all questions at once Example clarification response: ```json theme={null} { "message": "I'd be happy to help find the right products for your campaign. To provide the best recommendations, could you share:\n\n• What's your campaign budget?\n• When do you want the campaign to run?\n• Which geographic markets are you targeting?\n• What are your success metrics (awareness, conversions, etc.)?", "clarification_needed": true } ``` ## Natural Language Processing Publishers should implement NLP to extract: * **Temporal expressions**: "next quarter", "holiday season", "ASAP" * **Budget indicators**: "\$50K", "low budget", "premium spend" * **Audience descriptors**: "millennials", "high-income", "parents" * **Geographic references**: "west coast", "major cities", "nationwide" * **Objective keywords**: "awareness", "drive sales", "generate leads" ## Best Practices ### DO: * ✅ Include both advertiser and product in brand and brief * ✅ Specify measurable success criteria * ✅ Provide clear timing requirements * ✅ Describe target audience in detail * ✅ Mention creative format availability * ✅ State budget or budget constraints * ✅ Include brand safety requirements ### DON'T: * ❌ Provide vague objectives like "good performance" * ❌ Omit timing without expecting clarification requests * ❌ Use undefined abbreviations or jargon * ❌ Contradict between brief and brand * ❌ Include sensitive or confidential information * ❌ Assume publisher knowledge of your business ## Examples ### Wholesale Buying ```json theme={null} { "brand": {"domain": "acmecorp.com"}, "buying_mode": "wholesale", "filters": { "delivery_type": "non_guaranteed", "channels": ["display", "ctv"] } } ``` **Use Case**: Buyer has sophisticated audience segments in their DMP/CDP and will apply targeting through [TMP](/dist/docs/3.1.20/trusted-match). They need raw inventory without publisher curation. ### E-commerce Brief ``` "Launch our new sustainable fashion line targeting environmentally conscious millennials in urban markets. $75K budget for April, focused on driving online sales with a target ROAS of 4:1. We have video and carousel creatives showcasing the manufacturing process." ``` ### B2B Software Brief ``` "Generate qualified leads for our enterprise CRM solution among sales leaders at companies with 500+ employees. Q2 campaign with $100K budget, targeting 2% conversion rate from landing page visits. Display and native formats available." ``` ### Local Services Brief ``` "Drive appointment bookings for our dental practice in Chicago suburbs. $5K monthly budget targeting families with children within 10 miles of our locations. Focus on Saturday availability." ``` ## Conclusion Briefs serve different purposes for different buyers: * **Discovery-focused buyers** benefit from detailed briefs that help publishers recommend the best products * **Targeting-focused buyers** may skip briefs entirely, using filters to get broad inventory and applying their own targeting * **Hybrid approaches** can use minimal briefs to narrow the field while retaining control over targeting Publishers should implement robust brief processing to handle all these scenarios, from no brief at all to comprehensive campaign descriptions, while maintaining a conversational, helpful approach when clarification would add value. # Collections and installments Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments AdCP collections and installments — model podcast, CTV, and streaming content as dimensions on media products. Enables collection-level targeting and installment-specific ad placement. A [product](/dist/docs/3.1.20/media-buy/product-discovery/media-products) describes inventory along three independent axes: * **Publisher properties** — WHERE the ad runs (youtube.com, spotify.com) * **Collections / installments** — WHAT CONTENT the ad runs in or around (a specific series and its installments) * **Placements** — WHAT POSITION the ad appears in (pre-roll, mid-roll, host read) Collections and placements are parallel dimensions, not hierarchical. "Pre-roll" is a position. "Pinnacle Challenge" is content. A product combines them: "pre-roll on Pinnacle Challenge on Acme Streaming." ## Channel mapping The collection/installment model maps to familiar concepts across media channels: | Channel | Collection = | Installment = | Example | | ---------------- | -------------- | ---------------- | -------------------------------------------- | | Podcast | Program | Episode | "Serial" → "Chapter 1" | | Linear TV / CTV | Series | Episode / Airing | "Monday Night Football" → "Week 12" | | Print / Magazine | Publication | Issue | "Vogue Germany" → "May 2026" | | Newsletter | Publication | Edition | "Money Stuff" → "Tuesday March 24" | | YouTube / Social | Series | Video / Post | "Hot Ones" → "Gordon Ramsay" | | DOOH | Network / Loop | Rotation | "Times Square Loop" → "March Rotation" | | Influencer | Campaign | Post / Drop | "Spring Collection" → "Launch Post" | | Cinema | Run | Screening | "Summer Blockbuster Run" → "Opening Weekend" | | Radio | Program | Broadcast | "Morning Drive" → "March 24 Broadcast" | The `kind` field on each collection indicates how to interpret it and its installments. ## The collection object A collection is a persistent content program that produces installments over time. Collections work like properties — publishers declare them in their `adagents.json`, and products reference them via `collections` selectors with `publisher_domain` and `collection_ids`. | Field | Type | Required | Description | | --------------------- | --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collection_id` | string | Yes | Publisher-assigned identifier. Declared in the publisher's `adagents.json`. Products reference collections via `collections` selectors. Use distribution identifiers for cross-seller matching. | | `kind` | string | No | What kind of content program: `series` (TV/podcast), `publication` (print issues), `event_series` (live events), `rotation` (DOOH). Defaults to `series`. | | `name` | string | Yes | Human-readable collection name | | `description` | string | No | What the collection is about | | `genre` | string\[] | No | Genre tags. When `genre_taxonomy` is present, values are taxonomy IDs (e.g., IAB Content Taxonomy 3.0). Otherwise free-form. | | `genre_taxonomy` | string | No | Taxonomy system for genre values (e.g., `iab_content_3.0`). Recommended for machine-readable brand safety. | | `language` | string | No | Primary language (BCP 47) | | `content_rating` | object | No | Baseline rating: `system` + `rating`. Systems: `tv_parental`, `mpaa`, `podcast`, `esrb`, `bbfc`, `fsk`, `acb`, `custom`. Episodes can override. | | `cadence` | string | No | `daily`, `weekly`, `seasonal`, `event`, `irregular` | | `season` | string | No | Current or most recent season identifier (e.g., `"3"`, `"2026"`) | | `status` | string | No | `active`, `hiatus`, `ended`, `upcoming` | | `production_quality` | string | No | `professional`, `prosumer`, `ugc`. Seller-declared. Maps to OpenRTB `content.prodq`. | | `talent` | array | No | Hosts, recurring cast, creators. Each entry has `role`, `name`, and optional `brand_url` linking to [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json). | | `special` | object | No | When present, this collection is a special — content anchored to a real-world event or occasion. See [specials and limited series](#specials-and-limited-series). | | `limited_series` | object | No | When present, this collection is a limited series — a bounded run with a defined arc and end date. See [specials and limited series](#specials-and-limited-series). | | `distribution` | array | No | Where this collection is distributed, with platform-specific identifiers per publisher. | | `deadline_policy` | object | No | Default deadline rules for installments. Agents compute absolute deadlines from `scheduled_at` minus `lead_days`. Episodes with explicit `deadlines` override. See [deadline policy](#deadline-policy). | | `related_collections` | array | No | Relationships to other collections: `spinoff`, `companion`, `sequel`, `prequel`, `crossover`. Each entry has `collection_id` + `relationship`. References are scoped to the same publisher's `adagents.json`. Symmetric types (`companion`, `crossover`) do not require both collections to declare. | ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/collection.json", "collection_id": "pinnacle_challenge", "name": "The Pinnacle Challenge", "description": "Competition reality collection with extreme physical and mental challenges", "genre": ["IAB1", "IAB1-6"], "genre_taxonomy": "iab_content_3.0", "language": "en", "content_rating": { "system": "tv_parental", "rating": "TV-PG" }, "cadence": "weekly", "season": "1", "status": "active", "production_quality": "professional", "talent": [ { "role": "host", "name": "Jordan Vega", "brand_url": "https://jordanvega.example.com/brand.json" } ], "distribution": [ { "publisher_domain": "youtube.com", "identifiers": [ { "type": "youtube_channel_id", "value": "UCexample123456" } ] }, { "publisher_domain": "acmestreaming.example.com", "identifiers": [ { "type": "imdb_id", "value": "tt9876543" } ] } ] } ``` ## The installment object An installment is a specific installment of a collection. Not all installments will be known in advance — a weekly podcast may only have next week's installment scheduled, and some installments may be tentative (a playoff Game 7 depends on Game 6). | Field | Type | Required | Description | | -------------------- | --------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `installment_id` | string | Yes | Unique identifier within the collection | | `collection_id` | string | When needed | Parent collection. Required when the product spans multiple collections. | | `name` | string | No | Episode title | | `season` | string | No | Season identifier (e.g., `"1"`, `"2026"`) | | `installment_number` | string | No | Episode number within the season | | `scheduled_at` | datetime | No | When the installment airs or publishes | | `status` | string | No | `scheduled`, `tentative`, `live`, `postponed`, `cancelled`, `aired`, `published` | | `duration_seconds` | integer | No | Expected duration | | `flexible_end` | boolean | No | Whether end time is approximate (live events) | | `valid_until` | datetime | No | When this data expires. Agents should re-query before committing budget to tentative installments. | | `content_rating` | object | No | Overrides the collection's baseline when present | | `topics` | string\[] | No | Installment-level content topics for brand safety. Uses collection's `genre_taxonomy` when present. | | `special` | object | No | Installment-specific event context. When present, this installment is anchored to a real-world event. Overrides the collection-level `special`. | | `guest_talent` | array | No | Installment-specific guests. Additive to the collection's recurring `talent`. | | `ad_inventory` | object | No | Break-based ad inventory configuration | | `deadlines` | object | No | Booking, cancellation, and material submission deadlines for this installment. See [installment deadlines](#installment-deadlines). | | `derivative_of` | object | No | When this installment is a clip, highlight, or recap derived from a full installment. Has `installment_id` + `type` (`clip`, `highlight`, `recap`, `trailer`, `bonus`). Source `installment_id` must be in the same response. | ### Episode status lifecycle | Status | Meaning | | ----------- | --------------------------------------------------------- | | `scheduled` | Confirmed, will happen | | `tentative` | May not happen (depends on external conditions) | | `live` | Currently airing or streaming right now | | `postponed` | Was scheduled but delayed to a future date | | `cancelled` | Will not happen | | `aired` | Already broadcast — for back-catalog and replay inventory | | `published` | Already released — for on-demand catch-up inventory | Expected transitions: `scheduled` or `tentative` → `live` → `aired` or `published`. A `scheduled` installment may become `postponed` (delayed) or `cancelled`. A `postponed` installment returns to `scheduled` when rescheduled. A `tentative` installment resolves to `scheduled`, `cancelled`, or `postponed`. ### Inheritance from collections Episodes inherit collection-level fields they do not override: * **`content_rating`**: Episode value overrides collection baseline. When absent, the collection's rating applies. * **`special`**: Episode value overrides collection-level special. When absent, the collection's special applies. A regular collection can have event-anchored installments (e.g., a daily news collection with an election night special). * **`guest_talent`**: Additive to the collection's recurring `talent` — does not replace it. * **`topics`**: Additive context for brand safety, not a replacement for the collection's `genre`. Buyer agents evaluate both levels: the collection baseline provides the default safety profile, and installment fields refine it for specific installments. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/installment.json", "installment_id": "s1e03_the_wall", "collection_id": "pinnacle_challenge", "name": "The Wall", "season": "1", "installment_number": "3", "scheduled_at": "2026-04-07T20:00:00Z", "status": "scheduled", "duration_seconds": 3600, "valid_until": "2026-04-06T20:00:00Z", "content_rating": { "system": "tv_parental", "rating": "TV-14" }, "topics": ["IAB17-18"], "guest_talent": [ { "role": "guest", "name": "Samira Okafor", "brand_url": "https://samiraokafor.example.com/brand.json" } ], "ad_inventory": { "expected_breaks": 4, "total_ad_seconds": 480, "max_ad_duration_seconds": 120, "unplanned_breaks": false, "supported_formats": ["video", "audio"] } } ``` ## Installment deadlines Episodes can carry booking, cancellation, and material submission deadlines. These apply to any channel where inventory is tied to a scheduled unit — print issues, podcast installments, influencer posts, linear TV airings, DOOH rotations. | Field | Type | Description | | ----------------------- | -------- | ------------------------------------------------------ | | `booking_deadline` | datetime | Last date/time to book a placement in this installment | | `cancellation_deadline` | datetime | Last date/time to cancel without penalty | | `material_deadlines` | array | Ordered stages for creative material submission | Deadlines MUST be chronologically ordered: `booking_deadline` ≤ `cancellation_deadline` ≤ each `material_deadlines[n].due_at` (ascending by array index) ≤ the installment's `scheduled_at`. Buyer agents SHOULD reject installments where deadlines violate this ordering. Each material deadline has: | Field | Type | Required | Description | | -------- | -------- | -------- | ---------------------------------------------------------------------------- | | `stage` | string | Yes | Stage identifier (`draft`, `final`, or seller-defined) | | `due_at` | datetime | Yes | When materials for this stage are due | | `label` | string | No | What the seller needs (e.g., "Talking points", "Press-ready PDF with bleed") | The two-stage pattern — draft then final — covers a wide range of channels: | Channel | Draft stage | Final stage | | ------------------- | --------------------------------- | ----------------------------- | | Print | Raw artwork for review | Press-ready PDF with bleed | | Podcast (host read) | Talking points and brief | Approved script | | Influencer | Brand guidelines and key messages | Approved post content | | Linear TV | Rough cut | Encoded broadcast spot | | DOOH | Draft creative for review | Final assets per screen specs | ### Podcast with deadlines ```json theme={null} { "installment_id": "ep47", "name": "The future of autonomous supply chains", "scheduled_at": "2026-04-07T10:00:00Z", "status": "scheduled", "guest_talent": [ { "role": "guest", "name": "Kai Tanaka" } ], "deadlines": { "booking_deadline": "2026-03-28T17:00:00Z", "cancellation_deadline": "2026-03-31T17:00:00Z", "material_deadlines": [ { "stage": "draft", "due_at": "2026-04-01T17:00:00Z", "label": "Talking points and brand guidelines for host read" }, { "stage": "final", "due_at": "2026-04-04T17:00:00Z", "label": "Approved script" } ] } } ``` ### Print issue with deadlines ```json theme={null} { "installment_id": "2026-05", "name": "Mai 2026", "season": "2026", "installment_number": "5", "scheduled_at": "2026-05-01T00:00:00+02:00", "status": "scheduled", "deadlines": { "booking_deadline": "2026-03-15T17:00:00+01:00", "cancellation_deadline": "2026-03-22T17:00:00+01:00", "material_deadlines": [ { "stage": "draft", "due_at": "2026-03-29T17:00:00+01:00", "label": "Raw artwork for review and color proofing" }, { "stage": "final", "due_at": "2026-04-05T17:00:00+02:00", "label": "Press-ready PDF/X-4, CMYK, 300 DPI, 3mm bleed" } ] } } ``` ### Influencer post with deadlines ```json theme={null} { "installment_id": "post_2026_04_10", "name": "Spring campaign post", "scheduled_at": "2026-04-10T12:00:00Z", "status": "scheduled", "deadlines": { "cancellation_deadline": "2026-04-03T17:00:00Z", "material_deadlines": [ { "stage": "draft", "due_at": "2026-04-05T17:00:00Z", "label": "Key messages, product images, and brand guidelines" }, { "stage": "final", "due_at": "2026-04-08T17:00:00Z", "label": "Approved post content and caption" } ] } } ``` Deadlines are optional. Run-of-collection digital products typically omit them. The pattern is most valuable for guaranteed inventory with advance material requirements. ## Deadline policy High-frequency collections (daily newspapers, weekly podcasts) would generate large payloads if every installment carried explicit deadlines. The `deadline_policy` on a collection declares lead-time rules that agents use to compute deadlines from each installment's `scheduled_at`. | Field | Type | Description | | ------------------------ | ------- | ---------------------------------------------------------------------------------- | | `booking_lead_days` | integer | Days before `scheduled_at` by which the placement must be booked | | `cancellation_lead_days` | integer | Days before `scheduled_at` by which cancellation is penalty-free | | `material_stages` | array | Default material submission stages with `stage`, `lead_days`, and optional `label` | | `business_days_only` | boolean | When true, lead\_days counts Mon-Fri only. Defaults to false. | ### Daily newspaper with policy Instead of enumerating deadlines for every issue, the collection declares the rule once: ```json theme={null} { "collection_id": "bergedorfer_zeitung", "name": "Bergedorfer Zeitung", "kind": "publication", "cadence": "daily", "status": "active", "deadline_policy": { "booking_lead_days": 4, "cancellation_lead_days": 3, "material_stages": [ { "stage": "final", "lead_days": 2, "label": "Druckfertige PDF" } ], "business_days_only": true } } ``` For the April 1 issue (`scheduled_at: "2026-04-01"`), an agent computes: * **Booking deadline**: 4 business days before = March 26 * **Cancellation deadline**: 3 business days before = March 27 * **Material due**: 2 business days before = March 28 Episodes with explicit `deadlines` override the policy. A special edition with tighter turnaround declares its own deadlines; regular issues inherit from the policy. ### Weekly podcast with policy ```json theme={null} { "collection_id": "wonderstruck_weekly", "name": "Wonderstruck Weekly", "kind": "series", "cadence": "weekly", "deadline_policy": { "booking_lead_days": 10, "cancellation_lead_days": 7, "material_stages": [ { "stage": "draft", "lead_days": 5, "label": "Talking points and brand guidelines" }, { "stage": "final", "lead_days": 3, "label": "Approved script for host read" } ] } } ``` ### Policy vs explicit deadlines | Scenario | Use | | ---------------------------------------------- | ---------------------------------------------------------------- | | Daily newspaper, consistent deadlines | `deadline_policy` on collection | | Weekly podcast, consistent deadlines | `deadline_policy` on collection | | Monthly magazine, varying lead times per issue | Explicit `deadlines` on each installment | | One-off special with tight turnaround | Explicit `deadlines` on that installment, policy covers the rest | When both are present, explicit installment `deadlines` take precedence. An agent SHOULD NOT compute deadlines from the policy for installments that have their own. ## Specials and limited series Collections can carry optional annotations that signal their nature to buyer agents. These are composable — a collection can be both a special and a limited series (e.g., a 4-installment Olympics documentary). ### Specials A special is content anchored to a real-world event or occasion. The `special` object can appear on both collections and installments. On a collection, it means the entire collection is event-anchored. On an installment, it means that specific installment is event-anchored (and overrides the collection-level special when present, following the same inheritance pattern as `content_rating`). | Field | Type | Required | Description | | ---------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | string | Yes | Name of the event (e.g., "Olympics 2028", "Super Bowl LXI") | | `category` | string | No | `awards`, `championship`, `concert`, `conference`, `election`, `festival`, `gala`, `holiday`, `premiere`, `product_launch`, `reunion`, `tribute` | | `starts` | datetime | No | When the event starts | | `ends` | datetime | No | When the event ends. Omit for single-day events. | ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/collection.json", "collection_id": "apex_finals_2026", "name": "Apex Championship Finals 2026", "genre": ["IAB17", "IAB17-12"], "genre_taxonomy": "iab_content_3.0", "cadence": "event", "status": "upcoming", "special": { "name": "Apex Championship Finals 2026", "category": "championship", "starts": "2026-05-18T19:00:00Z", "ends": "2026-05-25T23:00:00Z" }, "talent": [ { "role": "host", "name": "Deshawn Moreaux" } ] } ``` The `special` field is distinct from `cadence`. Cadence describes release frequency (`event` = one-time or occasional). The `special` object describes what real-world event anchors the content and when it happens — information a buyer agent needs to evaluate timing relevance and premium pricing. ### Limited series A limited series is a bounded content run with a defined arc. Unlike ongoing series, limited series have a planned end. | Field | Type | Required | Description | | -------------------- | -------- | -------- | ------------------------------ | | `total_installments` | integer | No | Planned number of installments | | `starts` | datetime | No | When the series begins | | `ends` | datetime | No | When the series ends | ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/collection.json", "collection_id": "ember_s3", "name": "The Ember", "description": "Award-winning drama following a chef navigating the pressure of running a top restaurant", "genre": ["IAB1", "IAB1-7"], "genre_taxonomy": "iab_content_3.0", "cadence": "weekly", "status": "active", "limited_series": { "total_installments": 8, "starts": "2026-02-28T21:00:00Z", "ends": "2026-04-18T21:00:00Z" } } ``` This tells buyer agents that The Ember is a finite opportunity — 8 installments over 7 weeks. The `limited_series` field is distinct from `season`: a collection can have seasons without being limited (ongoing series have seasons too), and a limited series is a structural commitment that the collection has a planned end. ## How products reference collections Products reference collections via `collections` — an array of `{publisher_domain, collection_ids}` selectors that point to collections declared in the publisher's `adagents.json`. This is the same pattern as `publisher_properties`. Buyers resolve full collection objects from the publisher's `adagents.json`. Episodes are listed per-product in the `installments` array, since different products may scope different installments of the same collection. ### Run-of-collection (no specific installments) A product can reference a collection without listing installments. This means "inventory across this collection, whatever installments air during the flight dates": ```json theme={null} { "product_id": "pinnacle_run_of_collection", "name": "Pinnacle Challenge Run of Show", "collections": [{ "publisher_domain": "acmestreaming.example.com", "collection_ids": ["pinnacle_challenge"] }], "placements": [ { "kind": "seller_inline", "placement_id": "pre_roll", "name": "Pre-roll", "mode": "targetable" } ], "delivery_type": "non_guaranteed", "pricing_options": [ { "pricing_option_id": "cpm", "pricing_model": "cpm", "floor_price": 25.00, "currency": "USD" } ] } ``` ### Specific installments For premium or guaranteed buys, the seller scopes to specific installments: ```json theme={null} { "product_id": "pinnacle_finale", "name": "Pinnacle Challenge Season Finale Sponsorship", "collections": [{ "publisher_domain": "acmestreaming.example.com", "collection_ids": ["pinnacle_challenge"] }], "installments": [ { "installment_id": "s1e10_finale", "name": "The Grand Finale", "scheduled_at": "2026-06-02T20:00:00Z", "status": "scheduled", "ad_inventory": { "expected_breaks": 6, "total_ad_seconds": 720, "unplanned_breaks": false } } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "flat", "pricing_model": "flat_rate", "fixed_price": 2000000, "currency": "USD" } ] } ``` ## get\_products response structure Products reference collections via `collections` selectors — the same pattern as `publisher_properties`. Each selector has a `publisher_domain` and `collection_ids` array pointing to collections declared in that publisher's `adagents.json`. Buyers resolve full collection objects from `adagents.json`. ```json theme={null} { "products": [ { "product_id": "pinnacle_april_bundle", "name": "Pinnacle Challenge April Sponsorship", "collections": [{ "publisher_domain": "acmestreaming.example.com", "collection_ids": ["pinnacle_challenge"] }], "installments": [ { "installment_id": "s1e03", "name": "The Wall", "scheduled_at": "2026-04-07T20:00:00Z", "status": "scheduled", "content_rating": { "system": "tv_parental", "rating": "TV-14" }, "guest_talent": [{ "role": "guest", "name": "Samira Okafor" }], "valid_until": "2026-04-06T20:00:00Z" }, { "installment_id": "s1e04", "name": "TBD", "scheduled_at": "2026-04-14T20:00:00Z", "status": "tentative", "valid_until": "2026-04-07T20:00:00Z" } ], "placements": [ { "kind": "seller_inline", "placement_id": "pre_roll", "name": "Pre-roll", "mode": "targetable" }, { "kind": "seller_inline", "placement_id": "mid_roll", "name": "Mid-roll", "mode": "targetable" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "flat", "pricing_model": "flat_rate", "fixed_price": 500000, "currency": "USD" } ] } ] } ``` ## Canonical collection identity A collection's identity is **`{publisher_domain, collection_id}`** — scoped to the publisher that declares it in their `adagents.json`. This means any collection creator can serve as their own canonical registry. ### Creator as canonical publisher A creator like MrBeast declares collections in `mrbeast.com/adagents.json`. Any seller packaging that creator's inventory — a YouTube sales house, a CTV distributor, the creator's own team — references the same canonical source: ```json theme={null} { "products": [ { "product_id": "beast_games_youtube", "name": "Beast Games - YouTube Pre-roll", "collections": [ { "publisher_domain": "mrbeast.com", "collection_ids": ["beast_games"] } ], "publisher_properties": [ { "publisher_domain": "youtube.com", "property_ids": ["UCX6OQ3DkcsbYNE6H8uQQuVA"] } ], "delivery_type": "non_guaranteed", "pricing_options": [ { "pricing_option_id": "cpm", "pricing_model": "cpm", "floor_price": 18.00, "currency": "USD" } ] } ] } ``` The buyer sees `mrbeast.com` + `beast_games` regardless of which seller or platform the inventory comes from. No cross-referencing of IMDb IDs or distribution identifiers needed — the creator's domain **is** the registry. ### When creators don't publish adagents.json Not every collection has a creator-owned domain. A streaming platform's original series may only exist in that platform's `adagents.json`. In that case, the platform is the canonical publisher and `collections` points to their domain. Distribution identifiers on the collection object handle cross-seller matching when the same collection appears on multiple platforms without a single canonical source. ### Identity resolution priority Buyer agents should resolve collection identity in this order: 1. **Canonical publisher** — `publisher_domain` + `collection_id` from the creator's own `adagents.json`. Strongest signal. 2. **Platform-independent identifiers** — `imdb_id`, `gracenote_id`, `eidr_id` from the collection's `distribution` array. Reliable cross-reference when no canonical publisher exists. 3. **Platform-specific identifiers** — Spotify, Apple, YouTube IDs. Useful within a platform but not universal. ## Discovery examples ### CTV collection with installments A streaming platform selling sponsorships against a known collection with upcoming installments: ```json theme={null} { "products": [ { "product_id": "nova_kitchen_april", "name": "Nova Kitchen - April Episodes", "collections": [{ "publisher_domain": "novastreaming.example.com", "collection_ids": ["nova_kitchen"] }], "publisher_properties": [{ "publisher_domain": "novastreaming.example.com", "selection_type": "all" }], "installments": [ { "installment_id": "s3e09", "name": "Fire and Ice", "scheduled_at": "2026-04-05T21:00:00Z", "status": "scheduled", "duration_seconds": 2700, "ad_inventory": { "expected_breaks": 3, "total_ad_seconds": 360, "max_ad_duration_seconds": 30, "unplanned_breaks": false, "supported_formats": ["video"] } }, { "installment_id": "s3e10", "scheduled_at": "2026-04-12T21:00:00Z", "status": "tentative", "valid_until": "2026-04-06T00:00:00Z" } ], "placements": [ { "kind": "seller_inline", "placement_id": "pre_roll", "name": "Pre-roll (15s)", "mode": "targetable" }, { "kind": "seller_inline", "placement_id": "mid_roll", "name": "Mid-roll (30s)", "mode": "targetable" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "cpm_fixed", "pricing_model": "cpm", "fixed_price": 38.00, "currency": "USD" } ] } ] } ``` ### Related collections and derivative content A streaming platform offering a main collection and its companion after-collection, plus highlight clips: ```json theme={null} { "products": [ { "product_id": "nova_highlights", "name": "Nova Kitchen Highlights Package", "collections": [{ "publisher_domain": "novastreaming.example.com", "collection_ids": ["nova_kitchen"] }], "installments": [ { "installment_id": "s3e09", "name": "Fire and Ice", "status": "aired", "duration_seconds": 2700 }, { "installment_id": "s3e09_highlights", "name": "Fire and Ice - Best Moments", "status": "published", "duration_seconds": 180, "derivative_of": { "installment_id": "s3e09", "type": "highlight" } } ], "placements": [ { "kind": "seller_inline", "placement_id": "pre_roll", "name": "Pre-roll (15s)", "mode": "targetable" } ], "delivery_type": "non_guaranteed", "pricing_options": [ { "pricing_option_id": "cpm", "pricing_model": "cpm", "floor_price": 12.00, "currency": "USD" } ] } ] } ``` The highlight clip references its source installment via `derivative_of`. The companion after-collection is linked via `related_collections` on the main collection — buyers targeting Nova Kitchen can discover the after-collection as an additional reach opportunity. ### Podcast with distribution A podcast network selling across multiple distribution platforms. The collection's `distribution` array in `adagents.json` captures the full distribution footprint; buyers use distribution identifiers for cross-seller matching: ```json theme={null} { "products": [ { "product_id": "wonderstruck_april", "name": "Wonderstruck Weekly - April Episodes", "collections": [{ "publisher_domain": "wonderstruck.example.com", "collection_ids": ["wonderstruck_weekly"] }], "installments": [ { "installment_id": "ep47", "name": "The future of autonomous supply chains", "scheduled_at": "2026-04-07T10:00:00Z", "status": "scheduled", "guest_talent": [ { "role": "guest", "name": "Kai Tanaka", "brand_url": "https://kaitanaka.example.com/brand.json" } ] }, { "installment_id": "ep48", "scheduled_at": "2026-04-14T10:00:00Z", "status": "tentative" }, { "installment_id": "ep49", "scheduled_at": "2026-04-21T10:00:00Z", "status": "tentative" } ], "placements": [ { "kind": "seller_inline", "placement_id": "pre_roll", "name": "Pre-roll (30s)", "mode": "targetable" }, { "kind": "seller_inline", "placement_id": "mid_roll", "name": "Mid-roll host read (60s)", "mode": "targetable" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "flat_monthly", "pricing_model": "flat_rate", "fixed_price": 15000, "currency": "USD" }, { "pricing_option_id": "per_episode", "pricing_model": "flat_rate", "fixed_price": 5000, "currency": "USD" } ] } ] } ``` Tentative installments (ep48, ep49) have no names or guest info — they have not been produced yet. The buyer evaluates based on the collection's baseline profile and the known details of ep47. ### Live event with tentative installments A sports league selling sponsorships against a live event series. Live broadcasts use `flexible_end` because game duration is unpredictable, and `unplanned_breaks` because ad breaks follow game flow (timeouts, period breaks) rather than a fixed schedule. The tentative Game 5 depends on the series outcome — it only happens if neither team wins in four games. ```json theme={null} { "products": [ { "product_id": "apex_finals_sponsorship", "name": "Apex Championship Finals — Category Sponsorship", "collections": [{ "publisher_domain": "apexleague.example.com", "collection_ids": ["apex_championship_2026"] }], "installments": [ { "installment_id": "finals_game4", "name": "Game 4: Titan City vs Coastal FC", "scheduled_at": "2026-05-18T19:00:00Z", "status": "scheduled", "flexible_end": true, "ad_inventory": { "expected_breaks": 8, "total_ad_seconds": 960, "max_ad_duration_seconds": 30, "unplanned_breaks": true, "supported_formats": ["video"] } }, { "installment_id": "finals_game5", "name": "Game 5 — If necessary", "scheduled_at": "2026-05-21T19:00:00Z", "status": "tentative", "flexible_end": true, "valid_until": "2026-05-19T06:00:00Z", "ad_inventory": { "expected_breaks": 8, "total_ad_seconds": 960, "max_ad_duration_seconds": 30, "unplanned_breaks": true, "supported_formats": ["video"] } } ], "placements": [ { "kind": "seller_inline", "placement_id": "in_game_overlay", "name": "In-game overlay (10s)", "mode": "targetable" }, { "kind": "seller_inline", "placement_id": "halftime", "name": "Halftime feature (60s)", "mode": "targetable" } ], "delivery_type": "guaranteed", "exclusivity": "category", "pricing_options": [ { "pricing_option_id": "per_game", "pricing_model": "flat_rate", "fixed_price": 750000, "currency": "USD" } ] } ] } ``` This pattern combines several features designed for live inventory: `cadence: "event"` signals a non-recurring series, `flexible_end` tells buyers the broadcast length is approximate, and `unplanned_breaks: true` indicates ad breaks follow game flow rather than a predetermined schedule. The tentative Game 5 includes `valid_until` so buyer agents know when to re-query — if the series ends in four games, that installment will resolve to `cancelled`. Buyers should always check `valid_until` on tentative installments before committing budget. ### Discovering collections Buyers discover collections through the standard `get_products` workflow. Natural language briefs drive collection selection: ```json theme={null} { "buying_mode": "brief", "brief": "Podcast sponsorships for technology collections reaching startup founders in April", "filters": { "channels": ["podcast"], "start_date": "2026-04-01", "end_date": "2026-04-30" } } ``` The seller returns products with `collections` selectors. The buyer resolves full collection objects from each publisher's [adagents.json](/dist/docs/3.1.20/governance/property/adagents). Registry clients can also bootstrap and look up the crawled collection catalog through `/api/registry/catalog/collections/sync` and `/api/registry/catalog/collections/distribution`, which is useful for cross-referencing platform aliases such as YouTube channel IDs back to the publisher-declared collection. For community-maintained entries, registry moderators can seed a publisher-scoped collection with `PUT /api/registry/catalog/collections/{publisher_domain}/{collection_id}`. This creates a contributed registry record and emits the same `collection.created` / `collection.updated` feed events. It does not replace publisher-origin proof: once the publisher declares the collection in their own `adagents.json`, the authoritative declaration wins. ## Brand safety Collections provide a two-level brand safety model: a collection baseline and per-installment overrides. ### Collection baseline A collection's default brand safety profile comes from: * `content_rating` — the collection's declared rating system and value * `genre` — content categories (ideally using `genre_taxonomy` for machine-readable evaluation) * `talent` — hosts and recurring cast, with optional [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json) references for deeper evaluation This is what buyers evaluate when individual installment content is not yet known. ### Installment overrides When installment details are available, they can shift the safety profile: * A `content_rating` that differs from the collection baseline (this week is TV-14 instead of TV-PG) * `guest_talent` that changes the talent profile (a controversial guest) * `topics` that add installment-specific content signals ### What is not modeled AdCP does not predict content safety for unknown future installments. A buyer who commits to "all April installments" buys based on the collection's baseline profile, accepting variation. The seller's content standards and the collection's track record are the buyer's basis for that decision. ## Distribution identifiers Each collection's `distribution` array maps it to specific publisher platforms with platform-specific identifiers. This enables cross-seller matching: when two different sellers both offer products for the same collection, a buyer agent can match them via shared identifiers. ### Cross-seller matching Platform-independent identifiers are the most reliable for deduplication: | Type | Example | Notes | | -------------- | ------------------------------------ | ---------------------------------- | | `imdb_id` | `tt1234567` | Universal cross-platform reference | | `gracenote_id` | `EP012345678` | Industry standard for TV metadata | | `eidr_id` | `10.5240/XXXX-XXXX-XXXX-XXXX-XXXX-C` | ISO 10528 for audiovisual content | Shows SHOULD include at least one platform-independent identifier when available. ### Platform-specific identifiers ```json Podcast theme={null} { "distribution": [ { "publisher_domain": "spotify.com", "identifiers": [{ "type": "spotify_collection_id", "value": "4rOoJ6Egrf8K2IrywzwOMk" }] }, { "publisher_domain": "apple.com", "identifiers": [{ "type": "apple_podcast_id", "value": "1234567890" }] }, { "publisher_domain": "feeds.example.com", "identifiers": [{ "type": "rss_url", "value": "https://feeds.example.com/collection.xml" }] } ] } ``` ```json Video / CTV theme={null} { "distribution": [ { "publisher_domain": "youtube.com", "identifiers": [{ "type": "youtube_channel_id", "value": "UCexample123456" }] }, { "publisher_domain": "acmestreaming.example.com", "identifiers": [{ "type": "amazon_title_id", "value": "B0DFBT5GBP" }] } ] } ``` Available podcast types: `apple_podcast_id`, `spotify_collection_id`, `rss_url`, `podcast_guid`, `amazon_music_id`, `iheart_id`, `podcast_index_id`. Available video/CTV types: `youtube_channel_id`, `youtube_channel_handle`, `youtube_channel_url`, `youtube_playlist_id`, `amazon_title_id`, `roku_channel_id`, `pluto_channel_id`, `tubi_id`, `peacock_id`, `tiktok_id`, `twitch_channel`. Other: `domain`, `substack_id`. For YouTube channels, `youtube_channel_id` is the preferred canonical identifier; handles and URLs are aliases for discovery and matching when the channel ID is not yet resolved. ## Ad inventory Episodes declare break-based ad inventory in the `ad_inventory` object: | Field | Type | Description | | ------------------------- | --------- | ------------------------------------------------------------------------------------------------------- | | `expected_breaks` | integer | Number of planned ad breaks | | `total_ad_seconds` | integer | Total seconds of ad time across all breaks | | `max_ad_duration_seconds` | integer | Maximum duration for a single ad within a break | | `unplanned_breaks` | boolean | `false`: all breaks pre-defined. `true`: breaks driven by live conditions (sports timeouts, live news). | | `supported_formats` | string\[] | Format types supported in breaks (e.g., `"video"`, `"audio"`) | For non-break ad formats like host reads, custom integrations, or sponsorships, use product [placements](/dist/docs/3.1.20/media-buy/product-discovery/media-products#placements) instead. A podcast product might have a placement for "mid-roll host read (60s)" — that is a placement on the product, not part of `ad_inventory`. ## Relationship to LEAP The installment model aligns with IAB Tech Lab's LEAP Forecasting API for live streaming events: | LEAP concept | AdCP equivalent | | ------------------------ | -------------------------------------------------------- | | UpcomingEvent | Episode (`scheduled_at` + `status`) | | Content (AdCOM 1.0) | Show metadata (`genre`, `content_rating`, `talent`) | | AdInventoryConfiguration | Episode `ad_inventory` + product placements | | Event status | Episode `status` (`scheduled`, `tentative`, `cancelled`) | | Unplanned breaks | `ad_inventory.unplanned_breaks` | | Flexible end time | Episode `flexible_end` | LEAP targets SSP-to-DSP plumbing. AdCP operates at the agent-to-agent negotiation layer where buying decisions happen. ## Collection targeting Products with multiple collections default to bundles — the buyer gets all listed collections. Sellers can set `collection_targeting_allowed: true` to let buyers target a subset, the same pattern as `property_targeting_allowed` for properties. | `collection_targeting_allowed` | Meaning | | ------------------------------ | ------------------------------------------------------ | | `false` (default) | Bundle — buyer gets all collections | | `true` | Buyer can select specific collections in the media buy | ## Multi-collection bundles A single product can span multiple collections by listing multiple collection IDs in `collections`. When a product has multiple collections, each installment MUST include `collection_id` so the buyer agent knows which collection each installment belongs to. ```json theme={null} { "products": [ { "product_id": "technet_april_bundle", "name": "TechNet Podcast Bundle - April", "collections": [{ "publisher_domain": "technet.example.com", "collection_ids": ["tech_weekly", "startup_hour"] }], "installments": [ { "installment_id": "tw_ep12", "collection_id": "tech_weekly", "scheduled_at": "2026-04-07T10:00:00Z", "status": "scheduled" }, { "installment_id": "tw_ep13", "collection_id": "tech_weekly", "scheduled_at": "2026-04-14T10:00:00Z", "status": "tentative" }, { "installment_id": "sh_ep30", "collection_id": "startup_hour", "scheduled_at": "2026-04-09T14:00:00Z", "status": "scheduled" }, { "installment_id": "sh_ep31", "collection_id": "startup_hour", "scheduled_at": "2026-04-16T14:00:00Z", "status": "tentative" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "bundle", "pricing_model": "flat_rate", "fixed_price": 25000, "currency": "USD" } ] } ] } ``` ### Print publication with issues A magazine publisher selling display ads across upcoming issues. Each issue is an installment with deadlines for booking, cancellation, and material delivery: ```json theme={null} { "products": [ { "product_id": "vogue_de_display_q2", "name": "Vogue Germany — Display Ads, Q2 2026", "channels": ["print"], "collections": [{ "publisher_domain": "vogue.de", "collection_ids": ["vogue_de"] }], "installments": [ { "installment_id": "2026-04", "name": "April 2026", "season": "2026", "installment_number": "4", "scheduled_at": "2026-04-01T00:00:00+02:00", "status": "scheduled", "deadlines": { "booking_deadline": "2026-02-15T17:00:00+01:00", "cancellation_deadline": "2026-02-22T17:00:00+01:00", "material_deadlines": [ { "stage": "draft", "due_at": "2026-03-01T17:00:00+01:00", "label": "Artwork for color proofing" }, { "stage": "final", "due_at": "2026-03-08T17:00:00+01:00", "label": "Press-ready PDF/X-4, CMYK, 300 DPI" } ] } }, { "installment_id": "2026-05", "name": "Mai 2026", "season": "2026", "installment_number": "5", "scheduled_at": "2026-05-01T00:00:00+02:00", "status": "scheduled", "deadlines": { "booking_deadline": "2026-03-15T17:00:00+01:00", "cancellation_deadline": "2026-03-22T17:00:00+01:00", "material_deadlines": [ { "stage": "draft", "due_at": "2026-03-29T17:00:00+01:00", "label": "Artwork for color proofing" }, { "stage": "final", "due_at": "2026-04-05T17:00:00+02:00", "label": "Press-ready PDF/X-4, CMYK, 300 DPI" } ] } } ], "placements": [ { "kind": "seller_inline", "placement_id": "full_page", "name": "Full Page", "mode": "targetable" }, { "kind": "seller_inline", "placement_id": "dps", "name": "Double Page Spread", "mode": "targetable" }, { "kind": "seller_inline", "placement_id": "ifc", "name": "Inside Front Cover", "mode": "targetable" } ], "delivery_type": "guaranteed", "delivery_measurement": { "provider": "IVW", "notes": "Verified circulation, updated quarterly" }, "pricing_options": [ { "pricing_option_id": "full_page", "pricing_model": "flat_rate", "fixed_price": 28000, "currency": "EUR" }, { "pricing_option_id": "dps", "pricing_model": "flat_rate", "fixed_price": 48000, "currency": "EUR" } ] } ] } ``` The collection/installment model works identically for print publications and audio/video content. The installment's `deadlines` object replaces what the German OBS system handles through separate message exchanges — booking, cancellation, and material delivery are all visible upfront. ## See also * [Media products](/dist/docs/3.1.20/media-buy/product-discovery/media-products) — the full product model * [Print ads](/dist/docs/3.1.20/creative/channels/print) — print-specific creative formats, physical dimensions, bleed, and DPI * [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json) — talent identity and brand safety evaluation * [Product discovery](/dist/docs/3.1.20/media-buy/product-discovery) — how buyers discover inventory via `get_products` # Example Campaign Briefs Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/product-discovery/example-briefs AdCP example campaign briefs — annotated examples from minimal to advanced showing how natural language briefs drive product discovery in get_products. These annotated examples demonstrate how natural language briefs work in AdCP, describing target customers and campaign objectives to help publishers recommend appropriate media products. ## 1. Minimal Brief: Essential Elements ### Local Service Business ```json theme={null} { "buying_mode": "brief", "brief": "Mike's Plumbing Services needs to reach homeowners in the Denver, Colorado area who might need plumbing services. We have $8,000 USD to spend from October 15-31, 2024. Looking for display and native formats to drive phone calls." } ``` **Why This Works:** * ✅ **Clear business** - Mike's Plumbing Services * ✅ **Customer description** - Homeowners needing plumbing * ✅ **Geographic market** - Denver, Colorado (USA implied) * ✅ **Format preferences** - Display and native * ✅ **Budget and timing** - \$8,000 USD over 2 weeks * ✅ **Business outcome** - Phone calls **Publisher Interpretation:** Publishers can suggest targeting approaches like: * Homeownership signals * Home improvement interest * Local service searchers * Emergency service needs *** ## 2. Standard Brief: Customer Story ### E-commerce Product Launch ```json theme={null} { "buying_mode": "brief", "brief": "TechGear Pro is launching premium wireless headphones in the United States. Our customers are typically young professionals who commute, work out regularly, and value high-quality audio for both music and calls. They're willing to pay more for products that last longer and perform better. We need video and display formats to drive online sales during our launch November 1-14, 2024. Budget is $25,000 USD with a target of acquiring customers at $45-55 each." } ``` **Why This Works:** * ✅ **Customer profile** - Young professionals with active lifestyles * ✅ **Customer values** - Quality, durability, performance * ✅ **Geographic market** - United States * ✅ **Format needs** - Video and display * ✅ **Clear economics** - \$45-55 customer acquisition cost * ✅ **Natural description** - Lets publishers suggest targeting **Publisher Suggestions Might Include:** * Commuter targeting * Fitness enthusiast segments * Premium brand affinity * Audio equipment researchers * Professional demographic overlays *** ## 3. Comprehensive Brief: B2B Customer Description ### Enterprise Software Campaign ```json theme={null} { "buying_mode": "brief", "brief": "CloudSync Solutions helps companies manage data across multiple cloud platforms. Our ideal customers are growing businesses in the United States, Canada, United Kingdom, and Germany that have recently adopted cloud services and are struggling to keep data synchronized. These companies typically have distributed teams, use multiple SaaS tools, and are concerned about data security and compliance. The decision makers are usually technical leaders who report directly to the C-suite and are tasked with modernizing their company's infrastructure. We're looking for native content and display formats to generate qualified leads at $200-250 per lead. Q4 2024 campaign with $90,000 USD total budget." } ``` **Why This Works:** * ✅ **Customer context** - Growing businesses with cloud challenges * ✅ **Customer pain points** - Data sync, security, compliance * ✅ **Decision maker profile** - Technical leaders near C-suite * ✅ **Multiple countries** - US, CA, UK, DE specified * ✅ **Format preferences** - Native content and display * ✅ **Flexible targeting** - Publishers can interpret signals **Publisher Interpretation Opportunities:** * Cloud adoption signals * Company growth indicators * Technology stack analysis * Title and seniority matching * Industry compliance needs *** ## 4. Advanced Brief: Multi-Audience Campaign ### Automotive Launch ```json theme={null} { "buying_mode": "brief", "brief": "EcoMotion is launching our new hybrid SUV in the United States, specifically California, Pacific Northwest, and Northeast regions. We have three distinct customer groups we want to reach with video, Connected TV, and display formats: 1. Eco-conscious families who currently drive older SUVs and are concerned about their environmental impact but need the space for kids and activities. They research extensively and value safety ratings and environmental certifications. 2. Tech-forward professionals who see their vehicle as an extension of their digital lifestyle. They're early adopters who want the latest features and are willing to pay premium prices for innovation. 3. Current owners of competitor vehicles (Toyota Highlander, Honda Pilot) who might be in market for their next vehicle. They value reliability and total cost of ownership. Campaign runs October-December 2024 with $450,000 USD budget. Success means driving dealership visits and test drive appointments." } ``` **Why This Works:** * ✅ **Three distinct customer stories** - Each with different motivations * ✅ **Regional focus** - Specific US regions identified * ✅ **Format strategy** - Video, CTV, and display * ✅ **Competitive context** - Without being prescriptive * ✅ **Customer journey insights** - Research behavior, values * ✅ **Clear success metrics** - Dealership engagement **Publisher Value-Add:** * Suggest family-oriented contexts * Identify early adopter signals * Find competitive conquesting opportunities * Layer in environmental interest data * Apply automotive shopping behaviors *** ## Industry-Specific Examples ### Financial Services - United States ```json theme={null} { "buying_mode": "brief", "brief": "NextGen Banking is promoting our high-yield savings account across the United States. Our target customers are professionals who have accumulated some savings but keep it in traditional banks earning minimal interest. They're financially responsible but not necessarily investment-savvy, and they value security and ease of use over complex features. Looking for display and native formats to acquire 5,000 new accounts in January 2025 with $400,000 USD budget." } ``` ### Healthcare - Regional US ```json theme={null} { "buying_mode": "brief", "brief": "HealthFirst Urgent Care serves families in Ohio who need convenient, affordable healthcare. Our patients typically have insurance but want to avoid emergency room costs and wait times. They're parents with young children, working professionals who can't take time off for appointments, and seniors who need accessible care close to home. We need display and video formats to drive appointment bookings. $20,000 USD monthly budget." } ``` ### Streaming Service - North America ```json theme={null} { "buying_mode": "brief", "brief": "StreamPlus is expanding in the United States and Canada. Our subscribers love live sports but have cut the cord on traditional cable. They're social viewers who watch games with friends and family, follow multiple teams, and want access to both local and national broadcasts. We need Connected TV, video, and display formats for our Q4 2024 campaign with $2M USD budget to drive free trial sign-ups." } ``` ### Broadcast TV - Regional Automotive ```json theme={null} { "buying_mode": "brief", "brief": "Nova Motors is launching the Volta EV across the top 10 US DMAs. We need primetime :30 spots on major network affiliates (ABC, NBC, CBS, FOX) and late fringe :15 spots for frequency. Adults 25-54, $400K budget, 4-week flight in Q4 2026. We want to guarantee against C7 ratings with VideoAmp as the measurement vendor." } ``` This brief includes broadcast-specific concepts: DMAs (designated market areas), spot lengths (:30 and :15), dayparts (primetime, late fringe), and a measurement window preference (C7). The seller interprets these against their station schedule and available avails. ### Mobile Gaming - Global English Markets ```json theme={null} { "buying_mode": "brief", "brief": "GameStudio is launching our puzzle game in the United States, United Kingdom, Canada, and Australia. Our players are typically adults who play mobile games during commutes, breaks, and before bed. They've played games like Candy Crush or Wordle and enjoy mental challenges that don't require long time commitments. Looking for video and display formats to acquire 50,000 players at $3.50 each in January 2025 with $175,000 USD budget." } ``` *** ## Brief Writing Best Practices ### Describe Your Customer Naturally Instead of prescribing targeting tactics, describe your customer's: * **Situation**: What's happening in their life/business? * **Challenges**: What problems do they face? * **Values**: What matters to them? * **Behaviors**: How do they research and buy? * **Context**: When and why do they need you? ### Always Include Geographic Scope * Specify countries explicitly * Include currency (USD, EUR, GBP, etc.) * Note regional focuses within countries * Consider time zones for global campaigns ### Specify Format Preferences Include format types to indicate channel strategy: * **Display**: Standard web advertising * **Video**: In-stream and out-stream video * **Connected TV**: Television streaming ads * **Audio**: Podcast and music streaming * **Native**: Content-style advertising ### Let Publishers Add Value Good briefs leave room for publisher expertise: * Describe customers, not targeting parameters * Share context, not just demographics * Explain the "why" behind your campaign * Allow for creative targeting suggestions *** ## What's Different About AdCP Briefs ### ✅ DO Include: * Customer descriptions and stories * Geographic markets and currencies * Format preferences (indicates channels) * Business objectives and KPIs * Budget and timing * Competitive context ### ❌ DON'T Prescribe: * Specific targeting parameters * Exact audience segments * Technical implementations * Frequency caps or bid strategies * Attribution methodologies ### 🤝 Let Publishers: * Suggest targeting approaches * Recommend audience strategies * Optimize based on their data * Apply their platform expertise * Test and learn what works *** ## Brief Evaluation Checklist ### Essential Elements * [ ] Clear advertiser/brand identification * [ ] Geographic markets specified * [ ] Currency indicated * [ ] Format preferences stated * [ ] Budget and timing included * [ ] Business objective defined ### Customer Description * [ ] Customer situation/context * [ ] Problems they're solving * [ ] How they make decisions * [ ] What they value * [ ] Natural, conversational tone ### Campaign Context * [ ] Why this campaign now? * [ ] Success metrics defined * [ ] Competitive landscape mentioned * [ ] Flexibility for publisher input *** ## Related Documentation * [Brief Expectations](/dist/docs/3.1.20/media-buy/product-discovery/brief-expectations) - How publishers process briefs * [Creative Formats](/dist/docs/3.1.20/creative/formats) - Understanding format specifications and discovery * [Media Buy Lifecycle](/dist/docs/3.1.20/media-buy/media-buys) - Campaign execution workflow * [Product Discovery](/dist/docs/3.1.20/media-buy/product-discovery) - How briefs influence product selection # Product Discovery Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/product-discovery/index AdCP product discovery — use natural language campaign briefs to find advertising inventory across publishers. Covers brief writing, product structures, and refinement. Product discovery is the foundation of AdCP media buying. Use natural language to describe your campaign goals and discover relevant advertising inventory that matches your requirements. AdCP's product discovery revolutionizes how advertising inventory is found and evaluated: * **Natural Language First**: Describe campaigns in plain English instead of navigating complex catalogs * **AI-Powered Matching**: Advanced algorithms match briefs to relevant inventory * **Format-Aware Discovery**: Products include creative format compatibility * **Account-Specific Results**: See inventory based on your account's access and negotiated deals ## The Discovery Process ### 1. Write Your Brief Start with a natural language description of your campaign objectives: *"Mike's Plumbing Services needs to reach homeowners in the Denver, Colorado area who might need plumbing services. We have \$8,000 USD to spend from October 15-31, 2024. Looking for display and native formats to drive phone calls."* ### 2. Discover Products Use [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) to find matching inventory based on your brief and brand. ### 3. Evaluate Results Review returned products for: * **Audience alignment** with your target customers * **Format compatibility** with your creative assets * **Pricing model** (fixed CPM vs auction-based) * **Delivery type** (guaranteed vs non-guaranteed) * **Delivery forecasts** on proposals to estimate expected impressions, reach, and spend ### 4. Refine and Iterate Use `buying_mode: "refine"` with the `refine` array of change requests to iterate on products and proposals — include, omit, or find similar products, and request adjustments to proposals before committing to a buy. See [Refinement](/dist/docs/3.1.20/media-buy/product-discovery/refinement). ### 5. Work with Proposals Publishers may return **proposals** alongside products—structured media plans with budget allocations that can be executed directly. See [Proposals](/dist/docs/3.1.20/media-buy/product-discovery/media-products#proposals). ## Key Concepts ### Natural Language Briefs AdCP accepts campaign descriptions in conversational English rather than requiring: * ❌ Product catalog navigation * ❌ Technical targeting syntax * ❌ Platform-specific terminology Instead, describe your campaign naturally: * ✅ "Premium sports fans for energy drink launch" * ✅ "Local restaurant targeting dinner rush commuters" * ✅ "B2B software for marketing managers" Learn more in [Brief Expectations](/dist/docs/3.1.20/media-buy/product-discovery/brief-expectations). ### Product Model Products describe sellable inventory along three independent dimensions: * **Publisher properties** — WHERE the ad runs (the website, app, or platform) * **Collections and installments** — WHAT CONTENT the ad runs in or around (a series, podcast, or live event) * **Placements** — WHAT POSITION the ad appears in (pre-roll, mid-roll, host read) Each product also includes audience targeting, creative format requirements, pricing, and delivery characteristics. Buyers see the products the seller chooses to return for the request; sales-agent routing decisions such as direct-only, wholesale, or storefront availability stay internal to the seller. When products reference collections or registered placements, buyers resolve full metadata from the publisher's `adagents.json`. Understand the complete product structure in [Media Products](/dist/docs/3.1.20/media-buy/product-discovery/media-products). For collection-centric inventory like podcasts, CTV series, and live events, see [Collections and Installments](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments). ### Catalog-driven discovery For catalog-driven campaigns (retail media, job boards, travel), pass a `catalog` on `get_products` to find products that match your catalog items. Products declare which catalog types they support via `catalog_types`, and the response includes `catalog_match` with matched item counts. See [Catalogs](/dist/docs/3.1.20/creative/catalogs#catalogs-in-the-media-buy-lifecycle). ### Property Governance Filtering For compliance-filtered discovery, pass a `property_list` on `get_products` to restrict results to properties that meet governance requirements (COPPA certification, sustainability scores, brand safety ratings). Property lists are created via a [property governance agent](/dist/docs/3.1.20/governance/property/index). See [`get_products` — Property Governance](/dist/docs/3.1.20/media-buy/task-reference/get_products#request-parameters) for details. ### Format Discovery Integration Product discovery works hand-in-hand with creative planning: 1. **Products return `format_ids`, `format_options`, or both** for required creative specifications 2. **Use [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats)** for legacy named-format details, and use product `format_options[]` directly for 3.1+ canonical format-option declarations 3. **Plan creative production** based on discovered format needs ## Brief Examples & Patterns Real-world examples of effective briefs for different campaign types: * **Local Business**: Service area, customer demographics, business outcomes * **E-commerce**: Product categories, shopping behaviors, conversion goals * **B2B**: Job titles, company characteristics, lead generation * **Brand Awareness**: Lifestyle attributes, media consumption, reach objectives Explore comprehensive examples in [Example Briefs](/dist/docs/3.1.20/media-buy/product-discovery/example-briefs). ## Discovery Best Practices ### Effective Brief Writing * **Be specific about your business** and what you're promoting * **Describe your ideal customer** rather than demographic codes * **Include geographic scope** and any location relevance * **Mention format preferences** if you have creative constraints * **State business objectives** (calls, visits, sales, awareness) ### Iterative Discovery * Start with a broad brief to explore available inventory * Use structured filters to narrow results by delivery type or pricing * [Refine](/dist/docs/3.1.20/media-buy/product-discovery/refinement) specific products with the `refine` array before committing * Experiment with different customer descriptions to find new opportunities ### Working with Results * Review all returned products for unexpected opportunities * Check format requirements before creative production * Consider mix of guaranteed and non-guaranteed inventory * Evaluate pricing guidance for budget planning ## Response Times Product discovery operations: * **[`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products)**: \~60 seconds (AI processing) * **[`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats)**: \~1 second (database lookup) ## Next Steps After discovering products: 1. **[Create Media Buy](/dist/docs/3.1.20/media-buy/media-buys/)** - Build campaigns from selected products 2. **[Creative Planning](/dist/docs/3.1.20/media-buy/creatives/)** - Prepare assets matching format requirements 3. **[Task Reference](/dist/docs/3.1.20/media-buy/task-reference/)** - Detailed API documentation for implementation ## Related Documentation * **[Brief Expectations](/dist/docs/3.1.20/media-buy/product-discovery/brief-expectations)** - Comprehensive guide to brief structure * **[Example Briefs](/dist/docs/3.1.20/media-buy/product-discovery/example-briefs)** - Real-world campaign brief patterns * **[Media Products](/dist/docs/3.1.20/media-buy/product-discovery/media-products)** - Understanding product model and attributes * **[Refinement](/dist/docs/3.1.20/media-buy/product-discovery/refinement)** - Iterating on products and proposals with change requests * **[Proposals](/dist/docs/3.1.20/media-buy/product-discovery/media-products#proposals)** - Structured media plans with budget allocations * **[`get_products` Task](/dist/docs/3.1.20/media-buy/task-reference/get_products)** - Complete API reference # Media Products Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/product-discovery/media-products AdCP media products — the core sellable unit in the protocol. Covers product structure, pricing options, delivery types, format references, and catalog-driven inventory. A **Product** is the core sellable unit in AdCP. This document details the Product model, including its pricing and delivery types, and how products are discovered and structured in the system. **Pricing Models** Products declare which pricing models they support. Buyers select a specific pricing option when creating media buys. See the complete [Pricing Models Guide](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models) for details on CPM, CPCV, CPP, CPC, CPA, vCPM, flat rate, and time-based pricing. ## The Product Model * `product_id` (string, required) * `name` (string, required) * `description` (string, required) * `publisher_properties` (list\[PublisherPropertySelector], required): Publisher properties covered by this product. See [Property Targeting](#property-targeting). * `channels` (list\[string], optional): Advertising channels this product is sold as (e.g., `["retail_media"]`, `["display", "olv"]`). Sellers SHOULD declare `channels` on products that span non-obvious channels, particularly retail media, CTV/OLV, and multi-channel bundles. Product channels SHOULD be a subset of the union of their properties' `supported_channels`. See [Media Channel Taxonomy](/dist/docs/3.1.20/reference/media-channel-taxonomy). * `video_placement_types` (list\[string], optional): Declared video placement types that may be included in this product, using IAB Tech Lab/OpenRTB 2.6 `video.plcmt` definitions with AdCP-native names: `instream`, `accompanying_content`, `interstitial`, and `standalone`. Aggregate products and ad-network products may include multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context. * `audio_distribution_types` (list\[string], optional): Declared audio distribution types that may be included in this product, using IAB Tech Lab/OpenRTB 2.6 `audio.feed` definitions with AdCP-native names: `music_streaming_service`, `fm_am_broadcast`, `podcast`, `catch_up_radio`, `web_radio`, `video_game`, and `text_to_speech`. Aggregate products and ad-network products may include multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context. * `sponsored_placement_types` (list\[string], optional): Declared sponsored-placement types for catalog-driven retail-media products: `sponsored_search`, `sponsored_display`, and `sponsored_native`. Aggregate products and ad-network products may include multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context. * `social_placement_surfaces` (list\[string], optional): Declared social-placement surfaces for social products: `feed`, `stories`, `short_video`, `explore`, and `search`. Aggregate products and ad-network products may include multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context. * `format_ids` (list\[FormatID], conditional): Legacy named-format references. Products must include `format_ids`, `format_options`, or both. See [Creative Formats](/dist/docs/3.1.20/creative/formats). * `format_options` (list\[ProductFormatDeclaration], conditional): 3.1+ canonical format-option declarations accepted by this product. Buyers prefer `format_options` when both format fields are present. Product-level formats are the upper bound for the sellable product; placement-level formats can narrow this set but cannot add formats the product does not accept. Buyer `FormatOptionRef` selectors use `{scope: "publisher", publisher_domain, format_option_id}` for publisher-declared options and `{scope: "product", format_option_id}` for product-local options. * `placements` (list\[Placement], optional): Specific public ad placements within this product. Each placement declares `kind` (`publisher_ref` or `seller_inline`) and `mode` (`targetable` or `included`); seller-private delivery objects are not exposed here. See [Placements](#placements). * `shows` (list\[CollectionSelector], optional): Shows covered by this product, grouped by publisher. Each entry has `publisher_domain` and `collection_ids` referencing shows in the publisher's `adagents.json`. See [Collections and installments](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments). * `episodes` (list\[Episode], optional): Specific episodes available within this product. See [Collections and installments](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments). * `delivery_type` (string, required): Either `"guaranteed"` or `"non_guaranteed"`. * `exclusivity` (string, optional): Whether this product offers exclusive access. `"none"` (default when absent) — multiple advertisers can buy simultaneously. `"category"` — one advertiser per industry category. `"exclusive"` — sole sponsorship. Most relevant for guaranteed products tied to specific shows or placements. * `pricing_options` (list\[PricingOption], required): Array of available pricing models for this product. See [Pricing Models](#pricing-models). * `delivery_measurement` (object, optional): Who measures ad delivery — the ad server and viewability vendor used to count impressions. New implementations populate `vendors` as a structured `BrandRef` array (e.g., `[{ "domain": "googleadmanager.com" }, { "domain": "integralads.com" }]`); the legacy `provider` string is deprecated. When absent, buyers should apply their own measurement defaults. See [Delivery Measurement](#delivery-measurement). * `outcome_measurement` (OutcomeMeasurement, **deprecated**): Legacy field for declaring business outcome measurement (lift, brand lift, foot traffic). New implementations declare outcome metrics via `reporting_capabilities.available_metrics` and pin attribution methodology + window via the `qualifier` slot on `committed_metrics`. Retained for one-minor backwards compatibility; removed at the next major. See [Commerce Media](/dist/docs/3.1.20/media-buy/commerce-media) for the migration pattern. * `creative_policy` (CreativePolicy, optional): Creative requirements and restrictions. * `is_custom` (bool, optional): `true` if the product was generated for a specific brief. * `expires_at` (datetime, optional): If `is_custom`, the time the product is no longer valid. * `property_targeting_allowed` (bool, optional, default: false): Whether buyers can filter this product to a subset of its `publisher_properties`. When `false` (default), the product is "all or nothing" - buyers must accept all properties or the product is excluded from `property_list` filtering results. See [Property Targeting](#property-targeting). * `collection_targeting_allowed` (bool, optional, default: false): Whether buyers can target a subset of this product's `shows`. When `false` (default), the product is a bundle — buyers get all listed shows. When `true`, buyers can select specific shows in the media buy. * `data_provider_signals` (list\[DataProviderSignalSelector], **deprecated**): Legacy/non-selectable metadata for data-provider signals already bundled into or associated with this product. New implementations should use `included_signals`. * `included_signals` (list\[SignalListing], optional): Non-selectable signal metadata for signals already included in, bundled with, or planned into this product. These describe what the product is; buyers do not select them in package `signal_targeting_groups`. * `signal_targeting_allowed` (bool, optional, default: false): Whether this product has a package-level signal targeting surface. Editability is controlled by `signal_targeting_rules`. * `signal_targeting_options` (list\[ProductSignalTargetingOption], optional): Inline seller-offered signals that may be buyer-selected or seller-applied for this product. These may be product-local signal options or data-provider signals the seller is authorized to apply. See [Signal Targeting](#signal-targeting). * `signal_targeting_rules` (SignalTargetingRules, optional): Composition rules such as single/multi-select limits for the selectable signals. * `catalog_types` (list\[string], optional): Catalog types this product supports for catalog-driven campaigns. A sponsored product listing declares `["product"]`, a job board declares `["job", "offering"]`. Buyers match synced catalogs to products via this field. See [Catalogs](/dist/docs/3.1.20/creative/catalogs). * `catalog_match` (object, optional): When the buyer provides a `catalog` on `get_products`, indicates which catalog items are eligible for this product. Contains `matched_gtins` (cross-retailer GTIN matches), `matched_ids` (generic item ID matches), `matched_count`, and `submitted_count`. * `metric_optimization` (object, optional): Metric optimization capabilities for this product. Presence indicates the product supports `optimization_goals` with `kind: "metric"`. See [Metric optimization](#metric-optimization). * `max_optimization_goals` (integer, optional): Maximum number of `optimization_goals` this product accepts on a package. When absent, no limit is declared. Most social platforms accept only 1. * `conversion_tracking` (object, optional): Conversion event tracking capabilities. Presence indicates the product supports `optimization_goals` with `kind: "event"`. See [Conversion tracking](#conversion-tracking-1). * `product_card` (object, optional): Visual card definition for displaying this product in user interfaces. See [Product Cards](#product-cards). ### Metric optimization Products that support `optimization_goals` with `kind: "metric"` declare their capabilities in `metric_optimization`. No event source or conversion tracking setup is required for metric goals — the seller tracks these metrics natively. ```json theme={null} { "metric_optimization": { "supported_metrics": ["clicks", "views", "completed_views", "engagements"], "supported_view_durations": [2, 6, 15], "supported_targets": ["cost_per", "threshold_rate"] } } ``` | Field | Type | Required | Description | | -------------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supported_metrics` | string\[] | Yes | Metric kinds this product can optimize for. Buyers should only request metric goals for kinds listed here. | | `supported_view_durations` | number\[] | No | Video view duration thresholds (in seconds) supported for `completed_views` goals. When absent, the seller uses their platform default. | | `supported_targets` | string\[] | No | Target kinds available: `cost_per`, `threshold_rate`. Values match `target.kind` on the optimization goal. Only listed kinds are accepted. When omitted, buyers can set target-less metric goals (maximize volume) but cannot set specific targets. | ### Conversion tracking Products that support `optimization_goals` with `kind: "event"` declare their capabilities in `conversion_tracking`. Seller-level capabilities (supported event types, UID types, attribution windows) are declared in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities). ```json theme={null} { "conversion_tracking": { "action_sources": ["website", "app"], "supported_targets": ["cost_per", "per_ad_spend", "maximize_value"], "platform_managed": false } } ``` | Field | Type | Required | Description | | ------------------- | --------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `action_sources` | string\[] | No | Action sources relevant to this product (e.g., a retail media product might have `in_store` and `website`). | | `supported_targets` | string\[] | No | Target kinds available for event goals: `cost_per`, `per_ad_spend`, `maximize_value`. Values match `target.kind` on the optimization goal. Only listed kinds are accepted. When omitted, buyers can still set target-less event goals. | | `platform_managed` | boolean | No | Whether the seller provides always-on measurement (e.g., retailer purchase attribution). When true, `sync_event_sources` returns seller-managed event sources. | See [Conversion Tracking & Optimization Goals](/dist/docs/3.1.20/media-buy/conversion-tracking) for the full optimization goals reference. ### Pricing Models Publishers declare which pricing models they support for each product. Buyers select from the available options when creating a media buy. This approach supports: * **Multiple pricing models per product** - Publishers can offer the same inventory via different pricing structures * **Multi-currency support** - Publishers declare supported currencies per `pricing_options` entry; buyers can use `filters.pricing_currencies` during discovery to narrow media product transaction currencies and must use a supported currency at buy time * **Flexible pricing** - Support for CPM, CPCV, CPP (GRP-based), CPA, and more #### Supported Pricing Models * **CPM** (Cost Per Mille) - Cost per 1,000 impressions (traditional display) * **CPC** (Cost Per Click) - Cost per click on the ad * **CPCV** (Cost Per Completed View) - Cost per 100% video/audio completion * **CPV** (Cost Per View) - Cost per view at publisher-defined threshold * **CPA** (Cost Per Acquisition) - Cost per conversion event (purchase, lead, signup, etc.) * **CPP** (Cost Per Point) - Cost per Gross Rating Point (TV/audio) * **Flat Rate** - Fixed cost regardless of delivery volume * **Time** - Cost per time unit (day, week, month) that scales with campaign duration #### PricingOption Structure Each pricing option includes: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpcv-option.json", "pricing_option_id": "cpcv_usd_guaranteed", "pricing_model": "cpcv", "fixed_price": 0.15, "currency": "USD", "min_spend_per_package": 5000 } ``` For auction-based pricing (no `fixed_price`), use `floor_price` for minimum bid constraints and optional `price_guidance` for percentile hints. Bid-based auction models (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`) may also include `max_bid` as a boolean signal that `bid_price` switches from exact honored price to buyer ceiling mode: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpm-option.json", "pricing_option_id": "cpm_usd_auction", "pricing_model": "cpm", "currency": "USD", "floor_price": 10.00, "max_bid": true, "price_guidance": { "p25": 12.50, "p50": 15.00, "p75": 18.00, "p90": 22.00 } } ``` #### Delivery Measurement Products SHOULD declare their measurement provider when available: ```json theme={null} { "delivery_measurement": { "provider": "Google Ad Manager with IAS viewability verification", "notes": "MRC-accredited viewability. 50% in-view for 1s display / 2s video." } } ``` Common provider examples: * `"Google Ad Manager with IAS viewability"` * `"Nielsen DAR for P18-49 demographic measurement"` * `"Geopath DOOH traffic counts updated monthly"` * `"Comscore vCE for video completion tracking"` * `"Self-reported impressions from proprietary ad server"` Guaranteed products can also declare `performance_standards`, `measurement_terms`, and `cancellation_policy` that define accountability obligations at the buy level. See [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability). ### Outcome Measurement Object For products that include outcome measurement (common in retail media): ```json theme={null} { "type": "incremental_sales_lift", "attribution": "deterministic_purchase", "window": { "interval": 30, "unit": "days" }, "reporting": "weekly_dashboard" } ``` ### CreativePolicy Object Defines creative requirements and restrictions: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/creative-policy.json", "co_branding": "required", "landing_page": "retailer_site_only", "templates_available": true } ``` ### Placements Products can optionally declare specific public ad placements within their inventory. Placement IDs are publisher-scoped. When a placement exists in a publisher's `adagents.json` placement declarations, the product placement references that entry with `kind: "publisher_ref"`, `publisher_domain`, and `placement_id`. The product may omit `name` in that case because the publisher declaration resolves the name and other public metadata. When no public publisher declaration exists, the sales agent defines an inline placement with `kind: "seller_inline"` and buyer-facing fields such as `name`, `description`, formats, and tags; its `placement_id` is still interpreted in the publisher namespace, or in the seller agent's own publisher namespace when `publisher_domain` is omitted. Whether a publisher reference resolved from publisher-hosted adagents.json or a community-maintained fallback file is resolver metadata, not a separate placement kind. * **`kind: "publisher_ref"`** - Publisher-scoped placement reference resolved from the named publisher's `adagents.json`; requires `publisher_domain` * **`kind: "seller_inline"`** - Public buyer-facing placement metadata defined inline by the sales agent; requires `name` * **`publisher_domain`** - Domain whose `adagents.json` defines the referenced placement. New multi-publisher products SHOULD include it so the placement namespace is explicit. * **`placement_id`** - Placement ID in the publisher namespace. Buyers reference it with `publisher_domain` in `creative_assignments[].placement_refs`; legacy `placement_ids` strings are only unambiguous in single-publisher contexts. * **`mode: "targetable"`** - The buyer may reference this publisher-scoped placement when assigning creatives or otherwise selecting placements within the product * **`mode: "included"`** - The public placement is part of the product's described composition, but the buyer cannot cherry-pick it by `placement_id` * **`video_placement_types`** - Declared video placement types for OLV and other video placements. Concrete placements usually declare one value; aggregate placements may declare multiple. * **`audio_distribution_types`** - Declared audio distribution types for radio, streaming-audio, podcast, gaming, and other audio placements. Concrete placements usually declare one value; aggregate placements may declare multiple. * **`sponsored_placement_types`** - Declared sponsored-placement types for catalog-driven retail-media placements. Concrete placements usually declare one value; aggregate placements may declare multiple. * **`social_placement_surfaces`** - Declared social-placement surfaces for social placements. Concrete placements usually declare one value; aggregate placements may declare multiple. * **Publisher reference rule** - Publisher-referenced product placements resolve to `{publisher_domain, placement_id}` in the publisher's `adagents.json` * **Private inventory rule** - Seller-private delivery objects, ad-server mappings, and source/origin details must stay out of `get_products` * **Creative assignment** - Different creatives can be assigned to targetable placements * **Omitting placement targeting** - Creatives without `placement_refs` or legacy `placement_ids` run on all buyer-targetable placements in the package, and the seller still controls included-only delivery composition * **Use registered IDs when available** - If the publisher declares canonical `placements` in `adagents.json`, product placements SHOULD use the catalog ID as `placement_id` * **Preserve registry semantics** - When a product references a registered placement, it is referring to that same placement. The product may narrow `format_ids` or `format_options`, or add operational detail, but it should not change the placement's meaning incompatibly * **Tags stay useful at product level** - Product placements can carry `tags` for grouping and should align with registry tags when the placement comes from the publisher registry `mode` is required for new senders as of May 25, 2026. During the six-month migration window ending November 25, 2026, buyers may tolerate legacy products where `placements[]` entries omit `mode` and treat those placements as targetable for creative assignment. After November 25, 2026, buyers should fail closed on missing `mode`. Publishers can authorize agents for specific placements in `adagents.json` with `authorized_agents[].placement_ids` or `authorized_agents[].placement_tags`. Sales agents should only return publisher-referenced placements they are authorized to sell. #### Video placement types Video products can declare `video_placement_types` at the product level, placement level, or both. The vocabulary follows the IAB Tech Lab/OpenRTB 2.6 `video.plcmt` definitions, but AdCP uses readable field and value names rather than the OpenRTB wire name: | Value | Meaning | | ---------------------- | --------------------------------------------------------------------------------------------- | | `instream` | Video ads before, during, or after streaming video content requested by the user | | `accompanying_content` | Video ads in a player with accompanying video content associated with the page or app content | | `interstitial` | Video ads shown as an interstitial experience, typically between content or app states | | `standalone` | Video ads shown without associated video content, also called no-content or standalone video | The field is an array because a sellable product can aggregate multiple placement types. For example, an OLV network product might include both `instream` and `accompanying_content`, while individual targetable placements narrow to one type. When both product and placement declarations are present, the product-level array should be the union of the video placement types the seller may deliver under that product. This is a discovery signal, not a verification claim. Buyers can filter for products that can satisfy a requested type with `get_products.filters.video_placement_types`, but sellers should not return mixed, non-targetable bundles for an instream-only filter unless they can constrain delivery to instream inventory during planning or purchase. #### Audio distribution types Audio products can declare `audio_distribution_types` at the product level, placement level, or both. The vocabulary follows the IAB Tech Lab/OpenRTB 2.6 `audio.feed` definitions, but AdCP uses readable field and value names rather than the OpenRTB numeric codes: | Value | Meaning | | ------------------------- | ------------------------------------------------------------------------------------------------------ | | `music_streaming_service` | Music streaming service | | `fm_am_broadcast` | FM/AM broadcast, including live content broadcast over the air and also available via online streaming | | `podcast` | Original, pre-recorded content distributed as episodes in a series | | `catch_up_radio` | Recorded segment of a radio show that was originally broadcast live | | `web_radio` | Live audio content only available via online streaming, not as FM/AM broadcast | | `video_game` | Background audio in video games | | `text_to_speech` | Text-to-speech audio such as audio books or website/plugin article narration | This field describes execution and distribution details inside the buyer-facing `radio`, `streaming_audio`, `podcast`, or `gaming` channel. It does not rename channels and it does not change `adagents.json` `property_type` semantics, which remain tied to authorizable inventory surfaces. The field is an array because a sellable product can aggregate multiple audio distribution types. When both product and placement declarations are present, the product-level array should be the union of the audio distribution types the seller may deliver under that product. This is a discovery signal, not a verification claim. Buyers can filter for products that can satisfy a requested type with `get_products.filters.audio_distribution_types`, but sellers should not return mixed, non-targetable bundles unless they can constrain delivery to the requested audio distribution type during planning or purchase. #### Sponsored placement types Catalog-driven retail-media products can declare `sponsored_placement_types` at the product level, placement level, or both, distinguishing where the sponsored placement renders on the retailer surface: | Value | Meaning | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `sponsored_search` | Sponsored placements rendered against retailer on-site search results, keyed to the shopper's query | | `sponsored_display` | Sponsored placements rendered in display slots across browse, category, and product-detail pages outside the search results stream | | `sponsored_native` | Sponsored placements rendered using the retailer's native in-grid product template, blending into the organic listing | These values describe placement surfaces for products under the catalog-driven `sponsored_placement` canonical, whose `source_catalog` slot is required; an off-site value is intentionally excluded because off-site placements are not catalog-keyed. The field is an array because a sellable product can aggregate multiple surfaces; when both product and placement declarations are present, the product-level array should be the union the seller may deliver under that product. This is a discovery signal, not a verification claim. Buyers can filter for products that can satisfy a requested type with `get_products.filters.sponsored_placement_types`, but sellers should not return mixed, non-targetable bundles unless they can constrain delivery to the requested type during planning or purchase. #### Social placement surfaces Social products can declare `social_placement_surfaces` at the product level, placement level, or both, distinguishing the in-app surface where the social placement renders: | Value | Meaning | | ------------- | ----------------------------------------------------------------------------------------------------------------- | | `feed` | The primary scrolling home or timeline surface | | `stories` | The full-screen, ephemeral tap-through story surface | | `short_video` | The full-screen vertical algorithmically-recommended short-video surface (branded Reels, Shorts, Spotlight, etc.) | | `explore` | The discovery or browse surface outside the followed graph | | `search` | The in-app search-results surface, keyed to the user's query | The field is an array because a sellable product can aggregate multiple surfaces; when both product and placement declarations are present, the product-level array should be the union the seller may deliver under that product. This is a discovery signal, not a verification claim. Buyers can filter for products that can satisfy a requested surface with `get_products.filters.social_placement_surfaces`, but sellers should not return mixed, non-targetable bundles unless they can constrain delivery to the requested surface during planning or purchase. #### Format precedence with placements Product-level `format_ids` and `format_options` define the creative formats accepted by the product as a whole. Placement-level `format_ids` or `format_options`, whether returned inline on the product placement or inherited from a public publisher placement declaration, only narrow that product-wide set for the specific placement. If both product and placement format declarations are present, buyers compute the effective accepted formats for that placement as their intersection. For legacy `format_ids`, first project named formats to canonical declarations through `canonical`, `v1_format_ref`, or the canonical mapping registry; do not compare raw `(agent_url, id)` values after projection. A legacy 300x250 display ID and a canonical `image` declaration with `width: 300` and `height: 250` are compatible. For 3.1+ `format_options`, match publisher-declared options by `{publisher_domain, format_option_id}`, match product-local options by `format_option_id` when `publisher_domain` is omitted, and otherwise match declarations with the same `format_kind` whose placement parameters narrow the product declaration. Product gating is stricter than equivalence matching. When the product or placement declares fixed constraints such as `width`, `height`, `duration_ms_exact`, or a duration range, the buyer's selected format or creative manifest must declare and satisfy those constraints. A broad request (`format_kind: "image"` with no dimensions, or `video_hosted` with no duration) does not satisfy a fixed-size or fixed-duration product. For ranges, satisfaction means containment: a range-based request satisfies a product only when every value it permits falls within the product's accepted range; overlap alone is insufficient. An exact duration satisfies a range when the exact value falls inside it. The reverse is allowed: a broad product declaration can accept a specific 300x250 image or 30-second video unless another product constraint excludes it. See [format matching vs product satisfaction](/dist/docs/3.1.20/creative/canonical-formats#format-matching-vs-product-satisfaction). If a placement has no format declaration, it inherits the product-level formats. A placement-only format that is absent from the product-level declaration is a seller conformance error, not an expansion of the product's creative contract; buyers should fail closed for that format and not treat it as accepted. #### Placement Object Structure ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/placement.json", "kind": "publisher_ref", "publisher_domain": "daily-pulse.example", "placement_id": "homepage_banner", "name": "Homepage Banner", "description": "Above-the-fold banner on the homepage", "mode": "targetable", "tags": ["homepage", "display", "premium"], "format_ids": [ {"agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90"}, {"agent_url": "https://creative.adcontextprotocol.org", "id": "display_970x250"} ] } ``` #### Example: Product with Placements ```json theme={null} { "product_id": "news_site_premium", "name": "News Site Premium Package", "description": "Premium placements across news site", "format_ids": [ {"agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90"}, {"agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250"} ], "placements": [ { "kind": "publisher_ref", "placement_id": "homepage_banner", "publisher_domain": "daily-pulse.example", "name": "Homepage Banner", "mode": "targetable", "tags": ["homepage", "display", "premium"], "format_ids": [{"agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90"}] }, { "kind": "publisher_ref", "placement_id": "article_sidebar", "publisher_domain": "daily-pulse.example", "name": "Article Sidebar", "mode": "targetable", "tags": ["article", "display"], "format_ids": [{"agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250"}] }, { "kind": "seller_inline", "placement_id": "premium_rotation", "name": "Premium rotation", "mode": "included", "tags": ["premium"] } ], "delivery_type": "guaranteed", "pricing_options": [...] } ``` When creating a media buy, buyers can assign different creatives to different placements: ```json theme={null} { "packages": [ { "product_id": "news_site_premium", "creative_assignments": [ { "creative_id": "creative_1", "placement_refs": [ { "publisher_domain": "daily-pulse.example", "placement_id": "homepage_banner" } ] }, { "creative_id": "creative_2", "placement_refs": [ { "publisher_domain": "daily-pulse.example", "placement_id": "article_sidebar" } ] } ] } ] } ``` See [Creative Assignment and Placement Targeting](/dist/docs/3.1.20/media-buy/media-buys/index.mdx#creative-assignment-and-placement-targeting) for more details. ### Collections and installments Shows are a third product dimension alongside formats and placements. While placements describe *where* an ad appears and formats describe *what* the ad looks like, shows describe the *content context* — the programming a viewer is watching. Products can declare `shows` and `episodes` so buyers can target specific shows or episodes when purchasing inventory. See [Collections and installments](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments) for the full model, examples, and targeting details. ### Exclusivity The `exclusivity` field indicates whether a product offers exclusive access to its inventory. Defaults to `"none"` when absent. | Value | Meaning | | ----------- | -------------------------------------------------------------------------------------- | | `none` | Multiple advertisers can buy this product simultaneously | | `category` | One advertiser per industry category (e.g., one auto brand per collection sponsorship) | | `exclusive` | Sole sponsorship — only one advertiser can buy this product | Exclusivity is most relevant for guaranteed products tied to specific shows or placements, where advertisers want brand separation or sole ownership of a content association. #### When to use each level * **`none`**: Programmatic inventory, run-of-network, open auction products. Multiple advertisers sharing the same inventory is expected. * **`category`**: Podcast or CTV sponsorships where competitive separation matters. One auto brand per collection, one fintech brand per installment — but multiple non-competing advertisers can buy simultaneously. * **`exclusive`**: Sole sponsorship of a single collection or event. The advertiser is the only brand associated with the content. Publishers SHOULD include `exclusivity` on guaranteed products with `shows`. The implicit default of `"none"` is ambiguous for collection-level inventory — buyers cannot tell whether the publisher intends shared inventory or simply omitted the field. #### Content sponsorship pattern A product combining `delivery_type: "guaranteed"`, `exclusivity: "exclusive"`, and `shows` represents a content sponsorship — the advertiser becomes the sole sponsor of specific content. This is the standard pattern for podcast title sponsorships, CTV collection sponsorships, and event-based takeovers. ```json theme={null} { "product_id": "signal_noise_sponsor", "name": "Signal & Noise — Exclusive Sponsorship", "description": "Sole sponsorship of Signal & Noise, a weekly technology podcast. Includes pre-roll and mid-roll placements across all episodes.", "publisher_properties": [ { "publisher_domain": "crestnetwork.example", "property_ids": ["crest_podcasts"] } ], "format_ids": [ { "agent_url": "https://ads.crestnetwork.example", "id": "audio_pre_roll_30s" }, { "agent_url": "https://ads.crestnetwork.example", "id": "audio_mid_roll_60s" } ], "collections": [{ "publisher_domain": "crestnetwork.example", "collection_ids": ["signal_noise"] }], "delivery_type": "guaranteed", "exclusivity": "exclusive", "pricing_options": [ { "pricing_option_id": "flat_monthly", "pricing_model": "flat_rate", "fixed_price": 25000, "currency": "USD" } ] } ``` Category exclusivity works for multi-collection bundles where the publisher separates competing brands across a network but still sells to multiple non-competing advertisers: ```json theme={null} { "product_id": "crest_business_bundle", "name": "Crest Business Podcast Bundle — Category Sponsorship", "description": "Sponsorship across three business podcasts. One advertiser per industry category across all shows.", "publisher_properties": [ { "publisher_domain": "crestnetwork.example", "property_ids": ["crest_podcasts"] } ], "format_ids": [ { "agent_url": "https://ads.crestnetwork.example", "id": "audio_pre_roll_30s" }, { "agent_url": "https://ads.crestnetwork.example", "id": "audio_mid_roll_60s" } ], "collections": [{ "publisher_domain": "crestnetwork.example", "collection_ids": ["signal_noise", "market_beat", "founder_stories"] }], "delivery_type": "guaranteed", "exclusivity": "category", "pricing_options": [ { "pricing_option_id": "flat_quarterly", "pricing_model": "flat_rate", "fixed_price": 60000, "currency": "USD" } ] } ``` ### Property Targeting The `property_targeting_allowed` flag indicates whether buyers can filter a product to a subset of its `publisher_properties` when using property list filtering via `get_products`. #### Behavior * **`property_targeting_allowed: false` (default)**: The product is "all or nothing." If the buyer's `property_list` doesn't include all of the product's properties, the product is excluded from results entirely. * **`property_targeting_allowed: true`**: Buyers can filter the product to properties matching their `property_list`. The product is included in results if there is any intersection between its properties and the buyer's list. #### Use Cases | Use Case | `property_targeting_allowed` | Why | | ------------------ | ---------------------------- | -------------------------------------------------- | | Run of Network | `false` | Buyers must accept the entire network | | Premium Bundles | `false` | Sports + News bundle sold together | | Flexible Inventory | `true` | Buyers can target specific sites within a category | #### Examples **All-or-nothing product** (`property_targeting_allowed: false`): ```json theme={null} { "product_id": "premium_news_bundle", "name": "Premium News Bundle", "publisher_properties": [ { "publisher_domain": "news.example.com", "property_ids": ["site_a", "site_b", "site_c"] } ], "property_targeting_allowed": false } ``` When a buyer calls `get_products` with a `property_list` containing only `site_a` and `site_b`, this product is **excluded** because the buyer's list doesn't include all properties (`site_c` is missing). **Flexible product** (`property_targeting_allowed: true`): ```json theme={null} { "product_id": "news_category_flexible", "name": "News Category - Flexible Targeting", "publisher_properties": [ { "publisher_domain": "news.example.com", "property_ids": ["tech", "sports", "finance", "politics"] } ], "property_targeting_allowed": true } ``` When a buyer calls `get_products` with a `property_list` containing only `tech` and `sports`, this product is **included** because there is an intersection. The buyer can then purchase this product and target only the matching properties via `targeting_overlay.property_list` in the package. ### Signal Targeting Products use three different signal fields with different meanings: * **`data_provider_signals`** is deprecated legacy/non-selectable metadata for data-provider signals already bundled into or associated with the product. New implementations should use `included_signals`. * **`included_signals`** is the structured, non-selectable surface. Use it when a signal is already bundled into, included in, or planned into the product. It has no per-product targeting price and no seller activation handle. * **`signal_targeting_options`** is the inline selectable/composable surface. Use it when a seller-offered signal can appear on a package and the product needs to expose a product-specific menu, price, activation handle, default/fixed choice, grouping hint, or brief/refine-selected subset. `Product.signal_targeting_allowed` and `signal_targeting_rules` are the canonical product contract for signal composition. `get_signals` is the canonical broad signal discovery surface; wholesale products can use `signal_targeting_allowed: true` with no inline `signal_targeting_options` to tell buyers to call `get_signals` for the selectable signal feed. Brief/refine responses can return inline `signal_targeting_options` for the relevant subset or product-specific overrides. Sellers do not need to expose product-local options through `get_signals` unless they also want those signals to be discoverable outside this product. Sellers MUST set `signal_targeting_allowed: true` whenever they return `signal_targeting_options` or `signal_targeting_rules`, and MUST use `included_signals` rather than `signal_targeting_options` for signals that are bundled but not represented as package signal groups. Signals are named targetable dimensions, similar to feature values. A signal definition declares `value_type` (`binary`, `categorical`, or `numeric`) and may carry richer metadata over time. Product signal listings use `signal_ref` with an explicit resolution scope: `{ "scope": "product", "signal_id": "..." }` refers to a product-local signal defined by the listing, `{ "scope": "data_provider", "data_provider_domain": "...", "signal_id": "..." }` refers to a signal defined in a data provider's published adagents.json `signals[]`, and `{ "scope": "signal_source", "signal_source_url": "...", "signal_id": "..." }` refers to a source-native signal. `signal_ref.scope` is the resolution path, not provenance, and authoritative enrichment lives on the seller, signal source, or data-provider signal definition. For `scope: "product"`, the product listing MUST include `name` and `value_type` because the product is the definition boundary. For `scope: "data_provider"` or `scope: "signal_source"`, `signal_ref` is sufficient and any inline name, description, value type, range, or methodology is contextual, not authoritative. For product-local signals exposed on both `get_signals` and `get_products`, `signal_ref.signal_id` MUST match the seller's `get_signals.signals[].signal_ref.signal_id` for the same signal. Signal targeting composition is declared on each product, not in seller-wide `get_adcp_capabilities`, because one seller can sell products backed by different ad servers or platforms with different include/exclude and grouping limits. Buyers should read any inline `signal_targeting_options` and `signal_targeting_rules` from the specific product they are composing. * **`signal_targeting_allowed: false` (default)**: Signals are bundled into the product terms and are not represented as package-level signal groups. The buyer buys the product as offered and does not select or receive echoed signal groups on the package. * **`signal_targeting_allowed: true`**: The product has a package-level signal targeting surface. In wholesale discovery, if inline `signal_targeting_options` is omitted, buyers use `get_signals` to discover the selectable signal feed. In brief/refine results, sellers may include inline `signal_targeting_options` for the relevant subset or product-specific overrides. * **`signal_targeting_rules`**: Optional storefront composition rules. Use `resolution_model`, `selection_mode`, `min_selected_signals`, `max_selected_signals`, `max_selected_per_group`, `selection_group_rules`, `max_signal_targeting_groups`, and `max_signals_per_targeting_group` when the seller needs to express optional, required, single-select, mutually exclusive, fixed, seller-planned, or grouped signal choices. `resolution_model: "direct_targeting"` means selected signals are applied as targeting predicates to the package inventory. `resolution_model: "seller_planned"` means selected signals are planning inputs that the seller resolves against product-specific inventory, timing, availability, reach, or pacing constraints; buyers should not attempt to decompose the signal selection into lower-level inventory or schedule decisions. `selection_mode: "required"` means at least `min_selected_signals`, or 1 when `min_selected_signals` is omitted. All explicit package-level signal selection uses the grouped expression shape: top-level `operator: "all"` with child groups using `operator: "any"` for include groups and `operator: "none"` for exclusion groups. If `selection_mode` is `fixed`, buyers render `default_selected` signals as read-only; sellers apply those signals even when the buyer omits `targeting_overlay.signal_targeting_groups`. When `selection_group_rules` are present, each child group MUST contain signals from exactly one `selection_group` and one targeting mode, and buyers MUST send at most one child group for each `(selection_group, targeting_mode)` pair. Sellers MUST reject duplicate, mixed, or collapsed child groups. * **`activation_status: "requires_activation"`**: `get_products` alone is not sufficient to select the signal unless the buyer already has an activation key the seller accepts. The product option MUST include `signal_agent_segment_id` so the buyer can activate the signal through `activate_signal`, then include the activation key if the seller requires it on the package. `allowed_targeting_modes` on each signal option controls which buy-time child group operators are valid: `"include"` maps to `operator: "any"`, and `"exclude"` maps to `operator: "none"`. `selection_group` is a product-defined composability bucket for limits like `max_selected_per_group` and `selection_group_rules`; it is not a pointer to a specific child group in the package's `signal_targeting_groups.groups[]`. Use the same `selection_group` when options are freely OR-combinable in one child group for a targeting mode. Use different `selection_group` values when options must be represented as separate ANDed clauses, such as a product backed by one ad server that exposes both audience segments and key-value targeting planes that cannot be collapsed into the same child expression. Different `selection_group` values by themselves are descriptive; sellers should publish `selection_group_rules` when group boundaries affect validation. ```json theme={null} { "product_id": "retail_video_premium", "name": "Retail Video Premium", "signal_targeting_allowed": true, "signal_targeting_rules": { "resolution_model": "direct_targeting", "selection_mode": "optional", "max_selected_signals": 2, "max_selected_per_group": 1, "selection_group_rules": [ { "selection_group": "retail_audience", "targeting_mode": "include", "selection_mode": "optional", "max_selected_signals": 1 } ], "max_signal_targeting_groups": 2, "max_signals_per_targeting_group": 3 }, "signal_targeting_options": [ { "signal_ref": { "scope": "data_provider", "data_provider_domain": "pinnacle-data.example", "signal_id": "auto_intenders" }, "selection_group": "retail_audience", "allowed_targeting_modes": ["include", "exclude"], "activation_status": "ready", "default_selected": false, "pricing_options": [ { "pricing_option_id": "signal_cpm_usd_250", "model": "cpm", "cpm": 2.50, "currency": "USD" } ] } ] } ``` A product can also disclose included, non-selectable signals without opening a package-level signal targeting surface: ```json theme={null} { "product_id": "broadcast_auto_intenders_weekly_reach", "name": "Broadcast Auto Intenders Weekly Reach", "signal_targeting_allowed": false, "included_signals": [ { "signal_ref": { "scope": "data_provider", "data_provider_domain": "pinnacle-data.example", "signal_id": "auto_intenders" } } ] } ``` Here the buyer can inspect or verify the Pinnacle Data signal definition through the referenced provider's adagents.json `signals[]`, but cannot add or remove that signal in `create_media_buy`. A wholesale product with a large or account-specific signal menu can point buyers to `get_signals` instead of inlining every option: ```json theme={null} { "product_id": "retail_display_open_exchange", "name": "Retail Display Open Exchange", "signal_targeting_allowed": true, "signal_targeting_rules": { "resolution_model": "direct_targeting", "selection_mode": "optional", "max_signal_targeting_groups": 2 } } ``` In that shape, because `signal_targeting_allowed` is true and no inline `signal_targeting_options` are returned, the buyer calls `get_signals` to discover candidate signals. Before committing spend, the buyer SHOULD confirm product eligibility by calling `get_products` for the target product or using `filters.signal_targeting` with the candidate `signal_ref` entries and intended include/exclude modes. The buyer then passes selected `signal_ref` entries in `packages[].targeting_overlay.signal_targeting_groups`. The seller validates that the selected signals are available for the product and account, but storefronts should use `get_products` first so unsupported signal combinations fail during product discovery rather than at `create_media_buy`. At buy time, package-level `signal_targeting_groups` carries the selected `signal_ref`, value expression, optional seller execution handle (`signal_agent_segment_id`), and signal `pricing_option_id` when the signal has its own price. A simple include-only selection is represented as one child group with `operator: "any"`; grouped inclusion/exclusion uses additional `any` and `none` groups, such as `(A OR B) AND NOT (C)`. This is separate from the package's `pricing_option_id`, which prices the media product itself. Product-scoped signal pricing in `signal_targeting_options[].pricing_options` is authoritative for that product; broader `get_signals` pricing is a default discovery view unless the product omits a price. Sellers MUST echo all applied package signal groups in the resulting package state, including fixed/default selections the seller applied without buyer edits. For provider-published signals, `signal_ref.data_provider_domain` identifies the upstream data provider and `signal_ref.signal_id` identifies the public signal definition in that provider's adagents.json `signals[]`. Buyers that need provenance verification can fetch that domain's `adagents.json` and confirm the seller appears in `authorized_agents` for that signal or its tags. For product-local signals, use `scope: "product"` and `signal_id`; the ID is meaningful only within the selected product/package context. Backend execution kind is not part of signal identity. A GAM-backed seller can expose an audience segment, a key-value rule, or another custom-targeting primitive as ordinary `signal_ref` options. If those signals can be combined by the platform in one OR clause, put them in the same `selection_group`; if they require separate clauses, put them in separate `selection_group`s and publish `selection_group_rules` so storefronts compose one child group per `(selection_group, targeting_mode)`. For seller-planned guaranteed products, such as linear broadcast audio schedules, the selected audience can be portable while the plan is not. In that case, publish the shared audience as a `scope: "data_provider"` signal if it appears across products, set `resolution_model: "seller_planned"` on products where the seller resolves that audience against time-based avails or reach goals, and use `selection_mode: "required"` or `selection_group_rules[].selection_mode: "required"` when an audience choice is mandatory for the package. For non-guaranteed products where selected signals are applied as ordinary targeting predicates, use the default `resolution_model: "direct_targeting"` and point the product to `get_signals` when the wholesale signal menu should not be duplicated inline. The `seller_planned` model applies to any supply type where inventory is scheduled in advance and constrained by time-bound availability rather than continuously auctioned: live sports, political programming, and tentpole broadcasts follow the same pattern as linear broadcast audio. ### Custom & Account-Specific Products A server can offer a general catalog, but it can also return: * **Account-Specific Products**: Products reserved for or negotiated with specific accounts (buyers, agencies, or brands) * **Custom Products**: Dynamically generated products with `is_custom: true` and an `expires_at` timestamp ## Product Examples ### Standard CTV Product (Multiple Pricing Options) ```json theme={null} { "product_id": "connected_tv_prime", "name": "Connected TV - Prime Time", "description": "Premium CTV inventory 8PM-11PM", "publisher_properties": [ { "publisher_domain": "streaming.example.com", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "cpm_usd_guaranteed", "pricing_model": "cpm", "fixed_price": 45.00, "currency": "USD", "min_spend_per_package": 10000 }, { "pricing_option_id": "cpcv_usd_guaranteed", "pricing_model": "cpcv", "fixed_price": 0.18, "currency": "USD", "min_spend_per_package": 10000 }, { "pricing_option_id": "cpp_usd_p18-49", "pricing_model": "cpp", "fixed_price": 250.00, "currency": "USD", "parameters": { "demographic": "P18-49", "min_points": 50 }, "min_spend_per_package": 12500 } ], "delivery_measurement": { "provider": "Nielsen DAR for P18-49 demographic measurement", "notes": "Panel-based measurement for GRP delivery. Impressions measured via Comscore vCE." } } ``` ### Auction-Based Display Product ```json theme={null} { "product_id": "custom_abc123", "name": "Custom - Gaming Enthusiasts", "description": "Custom audience package for gaming campaign", "publisher_properties": [ { "publisher_domain": "gaming.example.com", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90" } ], "delivery_type": "non_guaranteed", "pricing_options": [ { "pricing_option_id": "cpm_usd_auction", "pricing_model": "cpm", "currency": "USD", "floor_price": 5.00, "price_guidance": { "p50": 8.00, "p75": 12.00 } }, { "pricing_option_id": "cpc_usd_auction", "pricing_model": "cpc", "currency": "USD", "floor_price": 0.50, "price_guidance": { "p50": 1.20, "p75": 2.00 } } ], "delivery_measurement": { "provider": "Google Ad Manager with IAS viewability", "notes": "MRC-accredited viewability. 50% in-view for 1s display." }, "is_custom": true, "expires_at": "2025-02-15T00:00:00Z" } ``` ### Retail Media Product with Measurement ```json theme={null} { "product_id": "albertsons_pet_category_offsite", "name": "Pet Category Shoppers - Offsite Display & Video", "description": "Target Albertsons shoppers who have purchased pet products in the last 90 days. Reach them across premium display and video inventory.", "publisher_properties": [ { "publisher_domain": "groceryretail.example.com", "selection_type": "all" } ], "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "cpm_usd_guaranteed", "pricing_model": "cpm", "fixed_price": 13.50, "currency": "USD", "min_spend_per_package": 10000 } ], "delivery_measurement": { "provider": "Self-reported impressions from proprietary ad server", "notes": "Impressions counted per IAB guidelines. Viewability measured via IAS." }, "outcome_measurement": { "type": "incremental_sales_lift", "attribution": "deterministic_purchase", "window": { "interval": 30, "unit": "days" }, "reporting": "weekly_dashboard" }, "creative_policy": { "co_branding": "optional", "landing_page": "must_include_retailer", "templates_available": true } } ``` ## Product Cards Product cards provide visual representations of products for display in user interfaces. Publishers can optionally include card definitions that reference card formats and provide the assets needed to render attractive visual cards. ### Card Types Publishers should provide at least the standard card, and optionally a detailed card: **Standard Card** (`product_card`): * Compact 300x400px card for product grids and lists * Supports 2x density images for retina displays * Quick visual identification of products **Detailed Card** (`product_card_detailed`, optional): * Responsive layout with text description alongside hero carousel * Markdown specifications section below * Full product documentation similar to media kits ### Structure ```json theme={null} { "product_id": "ctv_premium", "name": "Premium CTV Inventory", // ... other product fields ... "product_card": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "product_card_standard" }, "manifest": { "display_name": "Premium CTV - Living Room Audiences", "hero_image_url": "https://cdn.example.com/products/ctv_hero.jpg", "brief_highlight": "Perfect for reaching cord-cutters and premium streaming audiences" } }, "product_card_detailed": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "product_card_detailed" }, "manifest": { "display_name": "Premium CTV - Living Room Audiences", "description": "Reach high-income households with premium CTV inventory during peak viewing hours...", "carousel_images": [ "https://cdn.example.com/products/ctv_context1.jpg", "https://cdn.example.com/products/ctv_context2.jpg" ], "specifications_markdown": "# Technical Specifications\n\n..." } } } ``` ### Rendering Cards Cards can be rendered in two ways: 1. **Via `preview_creative`**: Pass the card format and manifest to generate a rendered card 2. **Pre-rendered**: Publishers can pre-generate cards and serve them directly This flexibility allows publishers to choose between dynamic generation or static hosting based on their infrastructure. ### Standard Card Formats The AdCP reference creative agent defines two standard card formats: * **`product_card_standard`** (300x400px) - Compact card for product browsing * **`product_card_detailed`** (responsive) - Rich card with carousel and full specs Publishers can also define custom card formats to match their branding or highlight unique product attributes. **Note**: Standard card format definitions are maintained in the [creative-agent repository](https://github.com/adcontextprotocol/creative-agent), not in this protocol specification. ### When to Include Product Cards Product cards are optional but recommended for: * Products with strong visual identity (e.g., specific shows, events, publications) * Premium products where visual presentation enhances perceived value * Complex products where visual highlights help explain capabilities * Products targeting specific audiences that benefit from visual representation Use the detailed card variant when you want to provide comprehensive product documentation similar to media kit pages. ### Client Rendering Guidelines When displaying products in UIs, clients should follow this fallback order: 1. **If `product_card` exists** → Render card via `preview_creative` or display pre-rendered image 2. **If neither exists** → Render text-only representation (product name + description) 3. **If card rendering fails** → Gracefully fall back to text-only representation This ensures a consistent user experience regardless of what product metadata is available. ## Proposals Publishers can return **proposals** alongside products - structured media plans with budget allocations that buyers can execute through a defined path. Committed proposals execute directly; draft proposals must be finalized first. ### What Are Proposals? A proposal is a recommended buying strategy that groups products with suggested budget allocations. Proposals encode publisher expertise - the kind of media planning guidance that traditionally required human sales reps. Key characteristics: * **Actionable**: A returned proposal is buyable through its lifecycle. `proposal_status: "draft"` means finalize first; `proposal_status: "committed"` means execute via `create_media_buy` with `proposal_id` before `expires_at`. * **Budget-agnostic**: Allocations use percentages, allowing the same proposal to scale to any budget * **Forecast-equipped**: Proposals and allocations can include delivery forecasts to help buyers evaluate expected performance before purchase ### Proposal Structure ```json theme={null} { "proposal_id": "swiss_balanced_v1", "name": "Swiss Multi-Channel Plan", "description": "Balanced coverage across devices and language regions", "allocations": [ { "product_id": "ch_desktop_de", "allocation_percentage": 20, "pricing_option_id": "cpm_usd_fixed", "rationale": "Primary desktop audience in German Switzerland", "tags": ["desktop", "german"] }, { "product_id": "ch_desktop_fr", "allocation_percentage": 30, "tags": ["desktop", "french"] }, { "product_id": "ch_mobile_de", "allocation_percentage": 8, "tags": ["mobile", "german"] }, { "product_id": "ch_mobile_fr", "allocation_percentage": 12, "tags": ["mobile", "french"] }, { "product_id": "ch_inapp_de", "allocation_percentage": 12, "tags": ["in-app", "german"] }, { "product_id": "ch_inapp_fr", "allocation_percentage": 18, "tags": ["in-app", "french"] } ], "total_budget_guidance": { "min": 30000, "recommended": 50000, "currency": "USD" }, "brief_alignment": "Achieves 50/20/30 channel split (desktop/mobile/in-app) and 40/60 language split (German/French)", "forecast": { "points": [ { "budget": 50000, "metrics": { "impressions": { "low": 800000, "mid": 1200000, "high": 1500000 }, "reach": { "low": 400000, "mid": 600000, "high": 750000 }, "clicks": { "mid": 4800 } } } ], "method": "modeled", "currency": "USD", "valid_until": "2025-04-15T00:00:00Z" } } ``` The `tags` field enables grouping allocations by dimension: * **By channel**: desktop (50%) + mobile (20%) + in-app (30%) = 100% * **By language**: German (40%) + French (60%) = 100% ### Iterating on Proposals Proposals can be refined using `buying_mode: "refine"` with the `refine` array. Reference proposals by ID — the seller returns an updated proposal with revised allocations, forecasts, and pricing: ``` // Initial discovery get_products({ buying_mode: "brief", brief: "Swiss campaign, $50k, 50% desktop/20% mobile/30% in-app, 40% German/60% French" }) // Response includes proposal "swiss_balanced_v1" // Refine the proposal get_products({ buying_mode: "refine", refine: [ { scope: "product", product_id: "ch_desktop_de" }, { scope: "product", product_id: "ch_desktop_fr" }, { scope: "product", product_id: "ch_mobile_de" }, { scope: "product", product_id: "ch_mobile_fr" }, { scope: "product", product_id: "ch_inapp_de" }, { scope: "product", product_id: "ch_inapp_fr" }, { scope: "proposal", proposal_id: "swiss_balanced_v1", ask: "focus more on German speakers - try 60/40 instead of 40/60" } ] }) // Seller returns an updated proposal with revised allocations ``` See [`get_products` refinement](/dist/docs/3.1.20/media-buy/task-reference/get_products#refinement) for the full workflow and examples. ### Executing a Proposal To execute a committed proposal, provide the `proposal_id` and `total_budget` in `create_media_buy`: ```json theme={null} { "proposal_id": "swiss_balanced_v1", "total_budget": { "amount": 50000, "currency": "USD" }, "brand": { "domain": "acmecorp.com" }, "start_time": "2025-04-01T00:00:00Z", "end_time": "2025-04-30T23:59:59Z" } ``` The publisher converts the proposal's allocation percentages into packages: * `ch_desktop_de`: 20% × \$50,000 = \$10,000 Finalization is the seller commitment step: it firms pricing, terms, availability, and any inventory hold. It is not buyer acceptance. `create_media_buy(proposal_id)` is the acceptance/execution step. Sellers reject attempts to execute a draft proposal with `PROPOSAL_NOT_COMMITTED`; finalize with `get_products` in `refine` mode and `action: "finalize"` before retrying create. * `ch_desktop_fr`: 30% × \$50,000 = \$15,000 * etc. This approach simplifies complex multi-line-item campaigns to a single proposal execution. ### When Publishers Return Proposals Publishers include proposals when: * The brief requests specific allocation strategies (channel splits, language splits, etc.) * The publisher can provide strategic guidance based on campaign goals * Multiple products work better together than individually Publishers typically omit proposals in `wholesale` mode (the buyer is directing targeting and allocation themselves) or when the brief doesn't suggest a multi-product strategy. Proposals are optional — publishers may return only products if allocation guidance isn't applicable. In `refine` mode, sellers MAY return proposals alongside refined products even when the buyer did not include proposal entries. Proposals are a seller suggestion — allocation and campaign optimization are primarily orchestrator (buyer-side agent) responsibilities. ### Delivery Forecasts Publishers can attach delivery forecasts to proposals and individual allocations to help buyers evaluate expected performance before committing budget. Each forecast contains a `points` array of one or more ForecastPoints. For spend curves, each point pairs a budget level with metric ranges (low/mid/high) — multiple points at ascending budgets show how delivery scales with spend. For availability forecasts, points omit budget and express total available inventory for the requested targeting and dates. Metric keys come from two vocabularies: * **Delivery/engagement**: `forecastable-metric` enum values (impressions, reach, clicks, spend, views, completed\_views, grps, etc.) * **Outcomes**: `event-type` enum values (`purchase`, `lead`, `app_install`, `add_to_cart`, `subscribe` for paid subscriptions, `follow` for free durable opt-ins, etc.) This lets sellers forecast both delivery ("1.2M impressions") and outcomes ("1,800 purchases") in a single forecast. Each forecast declares its method: * **`estimate`** — rough approximation based on historical averages or heuristics * **`modeled`** — derived from predictive models or historical data * **`guaranteed`** — contractually committed delivery levels backed by reserved inventory Each metric value is a ForecastRange object. Provide `mid` for a point estimate, `low` and `high` for a range, or all three. At minimum, either `mid` or both `low` and `high` must be present. Forecast points can also carry `dimensions` when a seller needs to expose availability by country, region, placement, device, audience, signal value, or an intersection such as placement x country or product x signal. `dimensions` is an array; each item declares one `kind` (`geo`, `placement`, `device_type`, `device_platform`, `audience`, or `signal`) and uses the same canonical identifiers as targeting and delivery reporting, such as `geo_level`/`geo_code` for geography, `placement_ref` for placements, and `signal_ref` plus `signal_value`/`presence` for signal buckets. Metro rows use `system` values from `metro-system` (`nielsen_dma`, `uk_itl1`, `uk_itl2`, `eurostat_nuts2`, `custom`); native postal rows use `country` plus country-local `system` values from `postal-system` (for example, `US` / `zip`, `GB` / `outward`, `ZA` / `postal_code`), while deprecated country-fused postal systems such as `us_zip` remain accepted for compatibility; country and region rows omit `system`. When multiple dimension items appear on one point, the point represents the intersection of those constraints. Dimension order has no meaning; buyers normalize row identity from `(forecast_range_unit, budget if present, product_id if present, dimensions sorted by kind)`. Sellers MUST NOT repeat the same `kind` on one point. If a seller needs multiple geo, placement, audience, or signal slices, it should emit multiple points instead. This keeps dimensional availability inside one product or proposal instead of forcing sellers to create separate products for every country, placement, or signal value. Dimension rows are independent of `pricing_options`; the product's pricing options still describe how the product is bought. Forecast points may include measurement-aware forecasts alongside standard delivery metrics: * **`viewability`** mirrors the `get_media_buy_delivery` `viewability` block, but numeric values use ForecastRange objects. Sellers SHOULD only emit forecast viewability when the product can report corresponding delivery viewability. Forecast `standard` is required whenever forecast viewability values are present because MRC and GroupM rows are not interchangeable; delivery `viewability.standard` remains optional for 3.x compatibility but SHOULD be populated. * **`vendor_metric_values`** mirrors delivery reporting for vendor-defined metrics, but `value` and `measurable_impressions` use ForecastRange objects. Sellers SHOULD only forecast vendor metrics that the product declares in `reporting_capabilities.vendor_metrics`. #### Forecast Range Units The `forecast_range_unit` field tells consumers how to interpret the points array — what axis the curve represents: * **`spend`** (default) — points at ascending budget levels. Standard budget curve. * **`availability`** — each point represents total available inventory for the requested targeting and dates. Budget is omitted; use `metrics.spend` to express estimated cost. Typical for guaranteed and direct-sold inventory. * **`reach_freq`** — points at ascending reach/frequency targets. Used in broadcast planning where the publisher shows how cost scales with frequency goals. * **`weekly`** / **`daily`** — metrics are per-period values. Budget refers to total campaign spend. A frequency of 3.2 with `weekly` means 3.2 exposures per week. * **`clicks`** / **`conversions`** — points at ascending outcome targets. Used in goal-based planning (e.g., "tell me your conversion goal, I'll tell you the budget"). * **`package`** — each point represents a distinct inventory package (e.g., Good/Better/Best tiers). Points are separate products with different inventory compositions, not levels on a spend curve. Used by broadcast TV, audio, and DOOH sellers. A spend curve and a reach/frequency curve may contain identical data — the difference is the publisher's intent. A spend curve says "here's what different budgets buy." A reach/frequency curve says "here's what it costs to hit different frequency targets." Consumers can read either curve in either direction. Temporal units (`weekly`, `daily`) change how metrics are interpreted. Without a range unit (or with `spend`), a frequency of 3.2 means 3.2 total campaign exposures. With `weekly`, it means 3.2 exposures per week. Forecasts can appear at two levels: * **Proposal-level**: aggregate forecast for the entire media plan * **Allocation-level**: per-product forecast for individual line items Allocation-level forecasts may not sum to the proposal-level forecast due to audience overlap and frequency capping. When both are present, the proposal-level forecast is authoritative for total delivery estimation. For cross-channel planning, forecasts declare a `reach_unit` (individuals, households, devices, accounts, cookies) so buyers can compare reach across publishers. GRP-based forecasts (linear TV, radio) use `demographic_system` and `demographic` to specify the target demo, following the same pattern as CPP pricing. When a forecast is based on third-party measurement, the `measurement_source` field declares which provider's data was used to produce the numbers. This is distinct from `demographic_system`, which specifies demographic notation — `measurement_source` identifies whose data produced the forecast numbers. A forecast can use Nielsen demographic codes (`demographic_system: "nielsen"`) while the impression numbers come from VideoAmp (`measurement_source: "videoamp"`). Sellers whose forecasts are based on third-party measurement use `measured_impressions` to express delivery as counted by the `measurement_source` provider. This is distinct from `impressions`, which represents ad-server or first-party estimated delivery. The two metrics are independent of the guarantee — `measured_impressions` can appear on both guaranteed and non-guaranteed forecasts: * **Guaranteed broadcast**: `method: "guaranteed"` + `measured_impressions` + `measurement_source: "nielsen"` — the seller contractually commits to Nielsen-measured delivery * **Non-guaranteed CTV**: `method: "modeled"` + `measured_impressions` + `measurement_source: "videoamp"` — VideoAmp-measured estimate, no contractual commitment * **Programmatic display**: `method: "modeled"` + `impressions` — ad-server counts, no third-party currency needed Sellers may include both `measured_impressions` and `impressions` in the same point when the buyer needs both the third-party-measured figure and the ad-server estimate. Podcast sellers use `downloads` as their primary delivery currency per IAB Podcast Measurement guidelines, in place of or alongside `impressions`. #### Budget Curve Multiple forecast points at ascending budget levels show how metrics scale with spend, helping buyers find the optimal investment level: ```json theme={null} { "points": [ { "budget": 25000, "metrics": { "impressions": { "low": 400000, "mid": 500000, "high": 600000 }, "reach": { "mid": 180000 }, "clicks": { "mid": 2000 } } }, { "budget": 50000, "metrics": { "impressions": { "low": 850000, "mid": 1050000, "high": 1200000 }, "reach": { "mid": 320000 }, "clicks": { "mid": 4200 } } }, { "budget": 100000, "metrics": { "impressions": { "low": 1500000, "mid": 1900000, "high": 2200000 }, "reach": { "mid": 500000 }, "clicks": { "mid": 7600 } } } ], "method": "modeled", "currency": "USD", "reach_unit": "individuals" } ``` The curve reveals diminishing returns — doubling budget from \$50K to \$100K increases reach by \~56%, not 2x. Buyers can use this to negotiate or reallocate budget across publishers. #### Availability Forecast For guaranteed and direct-sold inventory, the forecast is an availability check — how much inventory exists on this placement with this targeting in this flight window. Budget is omitted because available inventory doesn't depend on how much the buyer wants to spend. The seller can include `metrics.spend` to express the estimated cost of the available inventory: ```json theme={null} { "points": [ { "metrics": { "impressions": { "low": 320000, "mid": 400000, "high": 480000 }, "reach": { "low": 200000, "mid": 260000, "high": 300000 }, "spend": { "low": 6400, "mid": 8000, "high": 9600 } } } ], "forecast_range_unit": "availability", "method": "guaranteed", "currency": "USD" } ``` The buyer agent can compare available impressions against budget requirements to identify underdelivery. If the buyer needs 500,000 impressions at a \$20 CPM to spend their full \$10K budget, and the forecast shows 400,000 mid available, the buyer knows \$2K of budget must be allocated elsewhere. #### Dimensional Availability and Measurement Sellers can expose availability by country, placement, signal value, placement x country, product x signal, or other dimensional intersections as forecast points on the same product. A point with multiple `dimensions` items represents the intersection of all listed constraints. Rows are comparable when they use the same dimension grain, such as placement x country rows for several countries or signal-value rows for one product baseline. Buyers MUST NOT sum rows unless the seller explicitly documents that the rows form a complete, non-overlapping partition. Within a single point, dimension items are ANDed, not ORed. Sellers MUST NOT emit more than one item per `kind`; buyers MUST NOT infer OR semantics from order or repetition. Repeated peer values such as two `geo` country rows in one point are a seller conformance issue. Only count or currency metrics, such as `impressions`, `clicks`, `spend`, `measured_impressions`, and `viewable_impressions`, are candidates for rollup, and only inside a declared complete, non-overlapping partition. Reach, frequency, rates, averages, `viewability.viewable_rate`, `viewability.viewed_seconds`, and `vendor_metric_values[].value` are non-additive unless the seller or measurement vendor publishes an explicit rollup method. Vendor metric coverage counts such as `vendor_metric_values[].measurable_impressions` follow the same partition rule as other counts. This avoids product-per-country or product-per-placement expansion while still giving buyer agents the planning rows they need: ```json theme={null} { "points": [ { "dimensions": [ { "kind": "placement", "placement_ref": { "publisher_domain": "publisher.example", "placement_id": "header_bidding" }, "placement_name": "Header bidding" }, { "kind": "geo", "geo_level": "country", "geo_code": "US", "geo_name": "United States" } ], "metrics": { "impressions": { "low": 900000, "mid": 1200000, "high": 1400000 }, "spend": { "mid": 24000 } }, "viewability": { "vendor": { "domain": "measurementvendor.example" }, "measurable_impressions": { "mid": 1050000 }, "viewable_rate": { "low": 0.68, "mid": 0.73, "high": 0.78 }, "standard": "mrc" }, "vendor_metric_values": [ { "vendor": { "domain": "attentionvendor.example" }, "metric_id": "attention_units", "value": { "low": 3.9, "mid": 4.4, "high": 4.9 }, "unit": "score", "measurable_impressions": { "mid": 980000 } } ] }, { "dimensions": [ { "kind": "placement", "placement_ref": { "publisher_domain": "publisher.example", "placement_id": "header_bidding" }, "placement_name": "Header bidding" }, { "kind": "geo", "geo_level": "country", "geo_code": "CA", "geo_name": "Canada" } ], "metrics": { "impressions": { "low": 250000, "mid": 320000, "high": 380000 }, "spend": { "mid": 9600 } }, "viewability": { "vendor": { "domain": "measurementvendor.example" }, "viewable_rate": { "mid": 0.81 }, "standard": "mrc" } } ], "forecast_range_unit": "availability", "method": "modeled", "currency": "USD" } ``` Each point is a row in the forecast, not a separate product. Buyers can compare country-by-placement avails, viewability expectations, and vendor measurement forecasts while still selecting from the product's normal pricing options when they create the buy. A buyer agent turns a chosen dimensional row into a buy through the existing buy-time surfaces: * `kind: "geo"` rows map to `packages[].targeting_overlay.geo_countries`, `geo_regions`, `geo_metros`, or `geo_postal_areas`, depending on `geo_level` and seller support. Country rows use ISO 3166-1 alpha-2 `geo_code`; region rows use ISO 3166-2 `geo_code`; metro rows include the corresponding targeting `system` enum; native postal rows include `country` plus the country-local `system`. * `kind: "device_type"` and `kind: "device_platform"` rows map to the corresponding targeting overlay fields when the seller supports device targeting. * `kind: "audience"` rows map to `audience_include` or signal targeting only when the audience is selectable for that product; informational audience rows are planning signals, not automatic targeting handles. * `kind: "placement"` rows map first to product refinement or seller-supported placement targeting for the package. `dimensions[].placement_ref` identifies the inventory slice the forecast row describes; it does not by itself narrow the purchased package and buyers SHOULD NOT treat it as a shortcut for `creative_assignments[].placement_refs`. If the buyer wants to buy only that placement, the product must expose the placement as `mode: "targetable"` or the buyer should request a refined product/proposal. `creative_assignments[].placement_refs` is only the creative-routing surface after the buy's inventory scope is established; it does not by itself narrow the purchased inventory. Proposal-level forecast points with placement dimensions should include `product_id` when the placement maps to one allocation's product; without product context, placement rows on proposal-level forecasts are informational planning rows, not directly executable choices. `kind: "signal"` rows use canonical `signal_ref` plus optional `signal_value` to describe a signal bucket. Use `presence: "present"` for rows where the signal is available with the supplied value, and `presence: "absent"` with `signal_value: null` for the explicit not-present bucket. `signal_id` is only a shorthand when the enclosing object already identifies the signal unambiguously, such as a coverage forecast nested directly under a single `get_signals` item. Product-level forecasts use `ForecastPoint.product_id` for product context; do not add a separate product dimension item. After purchase, buyers verify one-dimensional marginals with `get_media_buy_delivery.reporting_dimensions` (`geo`, `device_type`, `device_platform`, `audience`, or `placement`) and reconcile measurement expectations through the package's `committed_metrics`, `performance_standards`, and reported `viewability` / `vendor_metric_values`. Standard delivery reporting does not currently return cross-dimensional intersections such as placement x country or product x signal; sellers that need exact post-buy reconciliation for those intersections must expose a seller-specific reporting surface or a future standard intersection capability. #### CTV with GRP Demographics TV and audio forecasts use `demographic_system` and `demographic` to specify the target demo, and `measurement_source` to declare whose audience data the forecast is modeled against: ```json theme={null} { "points": [ { "budget": 75000, "metrics": { "grps": { "low": 45, "mid": 60, "high": 72 }, "impressions": { "mid": 3200000 }, "reach": { "low": 800000, "mid": 1100000, "high": 1300000 }, "frequency": { "mid": 2.9 } } } ], "method": "modeled", "measurement_source": "nielsen", "currency": "USD", "demographic_system": "nielsen", "demographic": "P18-49", "reach_unit": "households" } ``` The `measurement_source: "nielsen"` tells the buyer agent that the GRP and impression numbers are modeled against Nielsen data. The `reach_unit: "households"` tells buyers this CTV publisher counts reach by household, not individual. A display publisher reporting `reach_unit: "devices"` is measuring something different — buyers should not directly compare the two reach numbers. Note that `measurement_source` and `demographic_system` can differ. A CTV publisher might use Nielsen's demographic notation (`demographic_system: "nielsen"`, `demographic: "P18-49"`) while the underlying audience data comes from VideoAmp (`measurement_source: "videoamp"`). The demographic system specifies the notation; the measurement source specifies whose numbers produced the forecast. #### Retail Media with Outcome Forecasts Retail media publishers can forecast both delivery metrics and conversion outcomes. Outcome metric keys use `event-type` values: ```json theme={null} { "points": [ { "budget": 30000, "metrics": { "impressions": { "low": 600000, "mid": 750000, "high": 900000 }, "clicks": { "mid": 6000 }, "purchase": { "low": 1200, "mid": 1800, "high": 2400 }, "add_to_cart": { "mid": 4500 } } } ], "method": "modeled", "currency": "USD" } ``` Here `impressions` and `clicks` are `forecastable-metric` values while `purchase` and `add_to_cart` are `event-type` values. Both use ForecastRange (low/mid/high) and coexist in the same metrics map. #### Allocation-Level Forecasts When a proposal includes per-allocation forecasts, buyers can evaluate each product independently: ```json theme={null} { "proposal_id": "retail_holiday_v1", "name": "Holiday Retail Campaign", "allocations": [ { "product_id": "sponsored_search", "allocation_percentage": 40, "forecast": { "points": [ { "budget": 20000, "metrics": { "impressions": { "mid": 500000 }, "clicks": { "mid": 15000 }, "purchase": { "mid": 900 } } } ], "method": "modeled", "currency": "USD" } }, { "product_id": "offsite_display", "allocation_percentage": 60, "forecast": { "points": [ { "budget": 30000, "metrics": { "impressions": { "low": 1800000, "mid": 2200000, "high": 2600000 }, "reach": { "mid": 450000 }, "purchase": { "low": 400, "mid": 600, "high": 800 } } } ], "method": "modeled", "currency": "USD", "reach_unit": "accounts" } } ], "forecast": { "points": [ { "budget": 50000, "metrics": { "impressions": { "low": 2100000, "mid": 2700000, "high": 3100000 }, "reach": { "mid": 520000 }, "purchase": { "low": 1100, "mid": 1500, "high": 1900 } } } ], "method": "modeled", "currency": "USD", "reach_unit": "accounts" } } ``` Note that the allocation forecasts (900 + 600 = 1,500 purchases) happen to match the proposal forecast in this example, but they often won't — audience overlap and frequency capping mean the whole is typically less than the sum of its parts. The proposal-level forecast is authoritative for total delivery. #### Broadcast Audio Spot Plan Broadcast and audio publishers can return spot-plan proposals with `daypart_targets` on each allocation and weekly frequency projections via `forecast_range_unit: "weekly"`. This pattern lets the publisher solve the optimization problem — the buyer specifies frequency goals, and the publisher returns the plan that achieves them: ```json theme={null} { "proposal_id": "iheart_q4_audio", "name": "Q4 Audio - Adults 25-54", "allocations": [ { "product_id": "morning_drive_30s", "allocation_percentage": 50, "daypart_targets": [ { "days": ["monday", "tuesday", "wednesday", "thursday", "friday"], "start_hour": 6, "end_hour": 10, "label": "Morning Drive" } ], "rationale": "Morning drive delivers highest reach against P25-54 with 3x weekly frequency at 2 spots/day", "forecast": { "points": [ { "budget": 37500, "metrics": { "grps": { "mid": 42 }, "reach": { "low": 140000, "mid": 180000, "high": 210000 }, "frequency": { "mid": 3.2 }, "impressions": { "mid": 576000 } } } ], "forecast_range_unit": "weekly", "method": "modeled", "currency": "USD", "demographic_system": "nielsen", "demographic": "P25-54", "reach_unit": "individuals" } }, { "product_id": "afternoon_drive_30s", "allocation_percentage": 30, "daypart_targets": [ { "days": ["monday", "tuesday", "wednesday", "thursday", "friday"], "start_hour": 15, "end_hour": 19, "label": "Afternoon Drive" } ], "rationale": "Afternoon drive complements morning with incremental reach and frequency overlap", "forecast": { "points": [ { "budget": 22500, "metrics": { "grps": { "mid": 28 }, "reach": { "low": 95000, "mid": 120000, "high": 145000 }, "frequency": { "mid": 2.4 }, "impressions": { "mid": 288000 } } } ], "forecast_range_unit": "weekly", "method": "modeled", "currency": "USD", "demographic_system": "nielsen", "demographic": "P25-54", "reach_unit": "individuals" } }, { "product_id": "daytime_30s", "allocation_percentage": 20, "daypart_targets": [ { "days": ["monday", "tuesday", "wednesday", "thursday", "friday"], "start_hour": 10, "end_hour": 15, "label": "Daytime" } ], "rationale": "Daytime fill provides frequency reinforcement at lower CPP", "forecast": { "points": [ { "budget": 15000, "metrics": { "grps": { "mid": 18 }, "reach": { "low": 60000, "mid": 80000, "high": 95000 }, "frequency": { "mid": 1.8 }, "impressions": { "mid": 144000 } } } ], "forecast_range_unit": "weekly", "method": "modeled", "currency": "USD", "demographic_system": "nielsen", "demographic": "P25-54", "reach_unit": "individuals" } } ], "forecast": { "points": [ { "budget": 75000, "metrics": { "grps": { "mid": 82 }, "reach": { "low": 220000, "mid": 280000, "high": 330000 }, "frequency": { "mid": 4.1 }, "impressions": { "mid": 1008000 } } } ], "forecast_range_unit": "weekly", "method": "modeled", "currency": "USD", "demographic_system": "nielsen", "demographic": "P25-54", "reach_unit": "individuals" } } ``` The `forecast_range_unit: "weekly"` on each forecast tells the buyer that all metrics are per-week values — frequency of 3.2 means 3.2 exposures per week, not 3.2 over the entire campaign. Budget (\$75K) is total campaign spend. The `daypart_targets` on each allocation specify the publisher's recommended time windows. These are the same structure used in `targeting` for hard daypart constraints — here the publisher is prescribing the spot plan rather than the buyer constraining it. Note that allocation-level reach doesn't sum to the proposal level (180K + 120K + 80K > 280K) because of audience overlap across dayparts — the same listener may hear morning drive and afternoon drive spots. The proposal-level forecast accounts for this overlap. #### Broadcast TV Package Forecast Broadcast TV sellers offer distinct inventory packages rather than impressions at variable spend levels. The `forecast_range_unit: "package"` tells the buyer that each point is a separate package, not a position on a spend curve. Each point includes a `label` so the buyer agent can identify and reference individual packages. A broadcaster might offer a daytime rotator, a prime-access + daytime bundle, and a full prime package: ```json theme={null} { "points": [ { "label": "Daytime Rotator", "budget": 50000, "metrics": { "measured_impressions": { "mid": 800000 }, "grps": { "mid": 35 }, "reach": { "mid": 220000 }, "frequency": { "mid": 2.1 } } }, { "label": "Prime Access + Daytime", "budget": 85000, "metrics": { "measured_impressions": { "mid": 1400000 }, "grps": { "mid": 58 }, "reach": { "low": 290000, "high": 390000 }, "frequency": { "mid": 3.4 } } }, { "label": "Full Prime", "budget": 150000, "metrics": { "measured_impressions": { "mid": 2600000 }, "grps": { "mid": 95 }, "reach": { "low": 420000, "high": 540000 }, "frequency": { "mid": 5.2 } } } ], "forecast_range_unit": "package", "method": "modeled", "measurement_source": "nielsen", "currency": "USD", "demographic_system": "nielsen", "demographic": "P18-49", "reach_unit": "households" } ``` Each point represents a distinct package — different dayparts, unit types, and flight structures — not the same product at three spend levels. The `label` field lets buyer agents reference packages by name when negotiating or requesting specific options. The `measurement_source: "nielsen"` tells the buyer agent that the impression and GRP numbers are modeled against Nielsen data, not the broadcaster's own measurement. The `measured_impressions` metric expresses delivery as counted by Nielsen — paired with `method: "modeled"`, these are Nielsen-measured estimates. To make them contractual commitments, the seller would use `method: "guaranteed"` instead. Broadcast buying shape determines product shape: * **Network or rep-firm package**: model as one seller product when one seller commits the order and fulfills across multiple markets or affiliates. The affiliates are fulfillment surfaces, not independent AdCP hops. * **Local spot or station-group inventory**: model separate products or packages when inventory is sold by distinct stations, station groups, or market sellers. * **Market-by-market syndication**: model by the party that sells and commits the inventory. If multiple sellers commit separately, the buyer aggregates their delivery reports across media buys. Use `delivery_type: "guaranteed"` when spots, units, or inventory are reserved or scheduled. Use `forecast.method: "modeled"` for historical audience ranges and `forecast.method: "guaranteed"` only when the seller is contractually committing to make good delivery outside the stated range. Do not use `non_guaranteed` merely because final measured impressions depend on historical ranges or third-party measurement settlement. If packages share the same inventory pool and differ only in volume or mix, use `package` forecast points on one product. If they represent fundamentally different inventory (different shows, properties, or dayparts with no overlap), create separate products with their own forecasts. Sellers expressing the same inventory in multiple measurement currencies (e.g., both Nielsen and VideoAmp) should provide separate DeliveryForecast objects, one per `measurement_source`. ## Integration with Discovery Products are discovered through the [Product Discovery](/dist/docs/3.1.20/media-buy/product-discovery) process, which uses natural language to match campaign briefs with available inventory. Once products are identified, they can be purchased via `create_media_buy`. ## See Also * [Product Discovery](/dist/docs/3.1.20/media-buy/product-discovery) - How to discover products using natural language * [Media Buys](/dist/docs/3.1.20/media-buy/media-buys) - How to purchase products * [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) - Detailed targeting options * [Creative Formats](/dist/docs/3.1.20/creative/formats) - Understanding format specifications and discovery # Refinement Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/product-discovery/refinement AdCP product refinement — iterate on discovered products and proposals using refine mode in get_products. Adjust selections and request changes before creating a media buy. Refinement turns product discovery into a conversation. After an initial `brief` or `wholesale` discovery, use `buying_mode: "refine"` to iterate on specific products and proposals — adjusting the selection, requesting changes, and exploring alternatives — before committing to a [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy). ## The refinement lifecycle A typical media buying workflow follows this pattern: ``` discover → refine → refine → ... → buy ``` 1. **Discover** — Call `get_products` with `buying_mode: "brief"` or `"wholesale"` to find matching inventory. The seller returns products (and optionally proposals). 2. **Refine** — Call `get_products` with `buying_mode: "refine"` and a `refine` array of change requests. Each entry declares a scope and what the buyer is asking for. The seller returns updated products with revised pricing and configurations. 3. **Repeat** — Refine as many times as needed. Each call is self-contained and stateless. 4. **Buy** — When satisfied, execute the final selection via `create_media_buy`. Refinement is not required. Simple campaigns can go straight from discovery to purchase. But for campaigns involving multiple products, proposals with budget allocations, or iterative negotiation, refinement is where the value is. ## The refine array The `refine` array is a list of change requests. Each entry declares a `scope` and what the buyer is asking for: | Scope | Purpose | Required fields | | ---------- | -------------------------------------- | --------------- | | `request` | Direction for the selection as a whole | `ask` | | `product` | Action on a specific product | `product_id` | | `proposal` | Action on a specific proposal | `proposal_id` | The `refine` array requires at least one entry. The seller considers all entries together when composing the response, and replies to each one via `refinement_applied`. Each scope uses its own id field — `product_id` for product entries, `proposal_id` for proposal entries — matching the id naming convention AdCP uses everywhere else. `action` is optional on product and proposal entries and defaults to `"include"`. ### Product actions Product-scoped entries may declare an action. When omitted, the seller treats the entry as `"include"`: | Action | Behavior | `ask` | | ------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | | `include` (default) | Return this product with updated pricing and data | Optional — specific changes to request (e.g., "add 16:9 format") | | `omit` | Exclude this product from the response | Ignored | | `more_like_this` | Find additional products similar to this one. The original product is also returned. | Optional — what "similar" means (e.g., "same audience but video format") | ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium", "ask": "add 16:9 format option" }, { "scope": "product", "product_id": "prod_display_ros", "action": "omit" }, { "scope": "product", "product_id": "prod_native", "action": "more_like_this", "ask": "same audience but video format" } ] } ``` ### Request-level direction Use `scope: "request"` to describe what you want from the selection as a whole: ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "good selection but I want more video options and less display" }, { "scope": "product", "product_id": "prod_video_premium" }, { "scope": "product", "product_id": "prod_display_ros" }, { "scope": "product", "product_id": "prod_native" } ] } ``` The seller may add, remove, or rebalance products based on this direction. Products not referenced in the `refine` array may appear in the response if the seller determines they fit the direction. **Precedence**: Per-product actions take precedence over request-level direction. If the request-level ask says "less display" but a specific product carries an explicit action to include it, that product is returned regardless. ### Proposal refinement Reference proposals by `proposal_id` to request adjustments or remove them. Like product entries, `action` defaults to `"include"`: ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, { "scope": "product", "product_id": "prod_display_ros" }, { "scope": "proposal", "proposal_id": "prop_balanced_v1", "ask": "shift 20% from display to video" } ] } ``` ### Combining scopes All scopes work together. A single refinement call can set direction for the selection, act on specific products, and request changes to a proposal: ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "increase emphasis on video across the plan" }, { "scope": "product", "product_id": "prod_video_premium" }, { "scope": "product", "product_id": "prod_display_ros" }, { "scope": "product", "product_id": "prod_native", "action": "omit" }, { "scope": "product", "product_id": "prod_audio_spot" }, { "scope": "proposal", "proposal_id": "prop_awareness_q2", "ask": "reallocate native budget to video products" } ], "filters": { "budget_range": { "min": 200000, "max": 200000, "currency": "USD" } } } ``` ## Seller response When a buyer sends a `refine` array, the seller responds with `refinement_applied` — an array matched by position to the buyer's change requests. Each entry reports whether the ask was fulfilled: | Field | Type | Required | Description | | ------------- | ------ | -------------------------------- | -------------------------------------------------------------------------------------------------- | | `scope` | string | Yes | Echoes the scope (`"request"` / `"product"` / `"proposal"`) from the corresponding `refine` entry. | | `product_id` | string | Yes when `scope` is `"product"` | Echoes `product_id` from the corresponding refine entry. | | `proposal_id` | string | Yes when `scope` is `"proposal"` | Echoes `proposal_id` from the corresponding refine entry. | | `status` | string | Yes | `"applied"`: ask fulfilled. `"partial"`: partially fulfilled. `"unable"`: could not fulfill. | | `notes` | string | No | Seller explanation. Recommended when status is `"partial"` or `"unable"`. | ```json theme={null} { "products": ["..."], "proposals": ["..."], "refinement_applied": [ { "scope": "request", "status": "applied", "notes": "Added 3 video products. No CTV inventory for those dates." }, { "scope": "product", "product_id": "prod_video_premium", "status": "applied" }, { "scope": "product", "product_id": "prod_display_ros", "status": "applied" }, { "scope": "product", "product_id": "prod_native", "status": "applied" }, { "scope": "product", "product_id": "prod_audio_spot", "status": "partial", "notes": "16:9 not available for this placement — returning 4:3 and 1:1" }, { "scope": "proposal", "proposal_id": "prop_awareness_q2", "status": "applied", "notes": "Shifted 22% to video (nearest allocation boundary)" } ] } ``` The `refinement_applied` array MUST contain the same number of entries in the same order as the `refine` array. Each entry MUST echo `scope` and the matching id (`product_id` on product scope, `proposal_id` on proposal scope) so orchestrators can cross-validate alignment. The entire field is optional — sellers that don't track per-ask outcomes can omit it — but sellers that return it MUST return valid, position-matched entries. Orchestrators SHOULD cross-check entries by the echoed id rather than trusting positional order alone — a seller bug that reorders entries would otherwise silently mis-attribute each outcome. ## Common refinement patterns ### Find similar products Use `more_like_this` to discover products similar to ones you like. The seller returns the original product plus additional options matching its characteristics: ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium", "action": "more_like_this", "ask": "same premium audience but different formats" } ] } ``` ### Adjust filters Filters on a refine request represent the complete target state, not a delta. Always send the full filter set you want applied: ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, { "scope": "product", "product_id": "prod_display_ros" } ], "filters": { "start_date": "2026-04-01", "end_date": "2026-06-30", "budget_range": { "min": 150000, "max": 150000, "currency": "USD" } } } ``` ### Narrow or expand a proposal The product entries define which products the seller should consider for the proposal. Combined with proposal entries, this narrows or expands the proposal's product set: ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "product", "product_id": "prod_video_premium" }, { "scope": "product", "product_id": "prod_display_ros" }, { "scope": "proposal", "proposal_id": "prop_balanced_v1", "ask": "rebalance for just these two products" } ] } ``` ## Finalize is exclusive within `refine[]` `action: "finalize"` is seller commitment, not buyer acceptance — it transitions a draft proposal to committed with firm pricing and an expires\_at hold window. The buyer accepts/executes a committed proposal later via `create_media_buy(proposal_id)`. When a buyer wants to finalize, the spec requires the `refine[]` array contain **only** finalize entries: * If any entry has `action: "finalize"`, **all** entries in the array MUST be proposal-scoped with `action: "finalize"`. Mixing finalize with `include` / `omit` entries, or with request- or product-scoped entries, MUST be rejected by the seller with `INVALID_REQUEST`. * Buyers needing to refine *and* commit in close succession **sequence** the calls: first a refine call (no finalize), then a finalize call against the resulting `proposal_id`(s). The two intents are distinct decisions and the spec treats them as distinct calls. ```yaml theme={null} # ✅ Refine only — no finalize present, mixed scopes allowed refine: - { scope: proposal, proposal_id: p1, ask: "shift more to ctv" } - { scope: request, ask: "frequency cap 3/day across all products" } # ✅ Finalize only — multiple finalize entries against different proposals refine: - { scope: proposal, proposal_id: p1, action: finalize } - { scope: proposal, proposal_id: p2, action: finalize } # ❌ Rejected — finalize mixed with non-finalize refine: - { scope: proposal, proposal_id: p1, action: finalize } - { scope: proposal, proposal_id: p2, ask: "shift more to ctv" } ``` **Multi-finalize is atomic at the observation point.** When multiple finalize entries target different proposals in one call, the contract is: sellers MUST NOT return a success response unless every named proposal has both completed and been persisted as committed. Pre-commit validation runs before any side-effects (inventory pull, terms lock, governance attestation); if any proposal fails validation, the seller MUST reject the entire call without committing any. There is no `unfinalize` operation — atomicity runs on the pre-commit validation gate, not on post-commit reversal. Sellers that cannot guarantee atomic pre-commit validation MUST reject multi-finalize arrays with `MULTI_FINALIZE_UNSUPPORTED` (preferred — explicitly signals seller-side capability gap rather than client-side mistake) or `INVALID_REQUEST` (acceptable fallback for sellers on a pre-3.1 error catalog), and buyers SHOULD then sequence single-finalize calls. **Mid-commit failure (post-validation, pre-persist).** If a downstream system fails between commit one and commit two — e.g., the second ad server times out after the first has already locked inventory — the seller MUST return `INTERNAL_ERROR` with `refinement_applied[]` carrying per-position outcomes. The spec does NOT define a recovery path: buyers SHOULD treat the resulting state as undefined and re-read via `get_media_buys` / equivalent before retrying. Recovery from this case is operational, not protocol-defined. **Buyer intent caveat.** Buyers whose intent specifically requires atomic commit (e.g., budget-shared proposals where one finalizing without the other is incoherent) MUST be prepared to abandon the intent if the seller returns `MULTI_FINALIZE_UNSUPPORTED`. The fallback path — sequencing single-finalize calls — is a looser commit guarantee than the original atomic intent; there is no recovery for that loss of intent beyond accepting the looser guarantee or declining to commit at all. There is no capability flag for multi-finalize support — the failure response is the discovery surface, so buyers MUST NOT assume support without a successful first attempt. ## Proposals in refine mode Sellers MAY return proposals alongside refined products, even when the buyer did not include proposal entries. For example, a buyer refining three products may receive those products back with updated pricing *and* a proposal suggesting how to combine them. Key points: * **Proposals are not guaranteed.** Sellers are not required to generate proposals in refine mode. Allocation and campaign optimization are primarily orchestrator (buyer-side agent) responsibilities. * **Signal interest via a request-level ask.** Include `{ "scope": "request", "ask": "suggest how to combine these products" }` to indicate you'd welcome a proposal. * **Unsolicited proposals can be refined or ignored.** If a seller returns a proposal you didn't request, you can refine it in a follow-up call, or simply ignore it and build packages manually via `create_media_buy`. Publishers typically omit proposals in `wholesale` mode, where the buyer is directing targeting and allocation themselves. ## Statelessness Each `get_products` request with `buying_mode: "refine"` is self-contained. The `refine` array and `filters` on each request fully specify the refinement intent. Sales agents MUST NOT depend on transport-level session state (e.g., remembering what was sent in a previous request). Sellers still maintain their own product and proposal registries — "stateless" means the *protocol exchange* carries no implicit state between calls. This design enables: * **Stateless implementations** — sellers don't need to track refinement sessions * **Safe retries** — a failed refinement call can be retried with the same parameters * **Parallel exploration** — an orchestrator can explore multiple refinement paths simultaneously ## Client validation Orchestrators should validate refinement requests before sending: * **Non-empty refine** — The `refine` array requires at least one entry. An empty `[]` is rejected by schema validation. * **Valid entries** — Each product entry requires `scope` and `product_id`. Each proposal entry requires `scope` and `proposal_id`. Request-level entries require `scope` and `ask`. `action` is optional on product and proposal entries (defaults to `"include"`); valid values are `include` / `omit` / `more_like_this` for products and `include` / `omit` / `finalize` for proposals. * **Filters are absolute** — Send the full filter set you want applied, not a delta from the previous request. Client implementations should validate refinement requests against the [request schema](/dist/docs/3.1.20/building/schemas-and-sdks) before sending. ## Error handling | Error Code | When | Resolution | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `PRODUCT_NOT_FOUND` | One or more referenced product IDs are unknown or expired | Remove invalid IDs and retry, or re-discover with a `brief` request | | `PROPOSAL_EXPIRED` | A referenced proposal ID has passed its `expires_at` | Re-discover with a new `brief` or `wholesale` request | | `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal\_id | | `MULTI_FINALIZE_UNSUPPORTED` | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls (one finalize per `get_products` call) | | `INVALID_REQUEST` | `refine` provided in `brief` or `wholesale` mode, empty `refine` array, or missing required fields | Check `buying_mode` and required fields | ### Troubleshooting: "must NOT have additional properties" on `refine[].id` Each scope branch in `refine[]` is `additionalProperties: false`, which means a stray `id` field from the pre-3.0-rc refine shape is rejected — not silently ignored — with an error along the lines of: ``` /refine/0: must NOT have additional properties { additionalProperty: "id" } /refine/0: must match oneOf schema { required: ["product_id"] } ``` If you see this, an orchestrator is still constructing product or proposal refine entries with the generic `id` field. Rename to `product_id` under `scope: "product"` and `proposal_id` under `scope: "proposal"`. See the [task reference](/dist/docs/3.1.20/media-buy/task-reference/get_products#refine-array) for the current shape. The same rename applies to `refinement_applied[]` if you're echoing on the seller side. ### Seller migration Sellers returning `refinement_applied` have breaking work alongside buyers: * Each response entry MUST now carry `scope`, and for product/proposal scopes MUST echo `product_id` / `proposal_id`. Flat `{status, notes}` entries are rejected by the response schema. * Missing `action` on an incoming `refine[]` entry MUST be treated as `action: "include"`, not parsed as an error. * Seller conformance tests against the 3.0 request schema will reject any lingering orchestrator payloads that still use the generic `id` field — refresh your fixture corpus after upgrading. ## Normative requirements The [Media Buy Specification](/dist/docs/3.1.20/media-buy/specification#get_products) defines the following normative requirements for refinement: **Orchestrators:** * MUST include `refine` when `buying_mode` is `"refine"` * MUST NOT include `refine` when `buying_mode` is `"brief"` or `"wholesale"` * MUST provide `scope` and `product_id` for each product entry, and `scope` and `proposal_id` for each proposal entry * MAY omit `action` on product and proposal entries — sellers treat missing `action` as `"include"` * MUST NOT include multiple entries for the same product ID or proposal ID in a single `refine` array **Sales agents:** * MUST omit products with `action: "omit"` from the response * MUST omit proposals with `action: "omit"` from the response * MUST return products with `action: "include"`, with updated pricing * SHOULD fulfill the `ask` on product entries with `action: "include"` * SHOULD return additional products similar to those with `action: "more_like_this"`, plus the original product * SHOULD consider request-level asks when composing the response — this MAY result in additional products beyond those explicitly referenced. Per-product actions take precedence over request-level direction. * SHOULD fulfill the `ask` on proposal entries with `action: "include"` * SHOULD include `refinement_applied` in the response when the buyer provides `refine`, with one entry per change request matched by position * MAY return proposals even when the buyer did not include proposal entries ## See also * [`get_products` task reference](/dist/docs/3.1.20/media-buy/task-reference/get_products#refinement) — API reference with request/response schemas * [Media Products](/dist/docs/3.1.20/media-buy/product-discovery/media-products) — product model and proposal structure * [Media Buy Specification](/dist/docs/3.1.20/media-buy/specification#get_products) — normative requirements * [Orchestrator Design](/dist/docs/3.1.20/building/operating/orchestrator-design) — building buyer-side agents # Protocol architecture Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/architecture AdCP protocol architecture: identity layer (brand, registry, accounts), transaction domains (media buy, creative, signals, sponsored intelligence), and cross-cutting governance. # Protocol architecture AdCP operates at multiple layers, providing a clean separation between business roles, orchestration, and technical execution. ## Protocol domain map Three-layer architecture diagram showing identity layer (brand, registry, accounts) at top, transaction domains (media buy, creative, signals, sponsored intelligence) in the middle, and governance as a cross-cutting layer connecting to all transaction domains ### Identity layer Three protocol domains establish who the parties are before any transaction occurs. **Brand Protocol** defines organization identity through `brand.json` files hosted at `/.well-known/brand.json`. Buyers use it for advertiser identity and authorized operators; sellers use it for operator identity, sales-agent discovery, signing-key discovery, and property relationship declarations. Any domain can be resolved to a canonical brand identity. See [Brand Protocol](/dist/docs/3.1.20/brand-protocol). **Registry** provides a public REST API for entity resolution and agent discovery. Resolve a brand domain, find which agents are authorized to sell a publisher's inventory, or discover agents by capability. See [Registry API](/dist/docs/3.1.20/registry). **Accounts** establishes the commercial relationship between buyers and sellers. Every AdCP transaction happens within an account that defines billing terms, operator authorization, and usage reporting. Accounts are grounded in brand identity from the Brand Protocol. See [Accounts Protocol](/dist/docs/3.1.20/accounts/overview). ### Transaction domains Four protocol domains handle core advertising operations. The [Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match) serves as the execution layer, connecting planning-time decisions to real-time activation through a four-phase lifecycle: 1. **Planning** — `get_products` and `create_media_buy` establish packages, budgets, and targeting criteria. 2. **Execution** — TMP Context Match determines content fit; TMP Identity Match checks user eligibility. Both operations run at serve time with structural privacy separation. 3. **Engagement** — Sponsored Intelligence sessions deliver conversational brand experiences. 4. **Reporting** — `get_media_buy_delivery` aggregates delivery data across sellers. **Media Buy** covers inventory discovery (`get_products`), campaign creation (`create_media_buy`), and delivery reporting (`get_media_buy_delivery`). Publishers return structured media products with pricing, targeting options, and delivery forecasts. Buyers can request proposals — structured media plans that encode publisher expertise. See [Media Buy](/dist/docs/3.1.20/media-buy). **Creative** handles format discovery (`list_creative_formats`), AI-powered creative generation (`build_creative`), catalog synchronization (`sync_catalogs`), and creative delivery tracking. Creative agents resolve brand identity from the Brand Protocol to generate on-brand assets. See [Creative](/dist/docs/3.1.20/creative). **Signals** enables audience and targeting data discovery (`get_signals`) and activation (`activate_signal`). Data providers publish signal definitions that buyers can discover with natural language queries, then activate on decisioning platforms. See [Signals](/dist/docs/3.1.20/signals/overview). **Sponsored Intelligence** defines conversational brand experiences in AI assistants. When a user expresses interest in a brand, the host initiates a consent-first session where the brand's agent engages conversationally with text, voice, UI components, or commerce handoffs. See [Sponsored Intelligence](/dist/docs/3.1.20/sponsored-intelligence/overview). ### Governance (cross-cutting) **Governance** operates across all transaction domains. Governance agents manage property lists (curated sets of properties for targeting or exclusion), content standards (brand suitability policies), and creative governance (security scanning, content categorization). Governance data flows into media buy decisions, creative validation, and signal activation. See [Governance Protocol](/dist/docs/3.1.20/governance/overview). Human-in-the-loop enters the protocol through two mechanisms: any mutation may be taken async for human review via the task lifecycle, and campaign governance provides a declarative buyer-side review channel via `sync_plans` and `check_governance`. See [How human-in-the-loop enters the protocol](/dist/docs/3.1.20/governance/embedded-human-judgment#how-human-in-the-loop-enters-the-protocol). This is not a real-time protocol: operations may take minutes to days when human approval is required. ### Privacy posture across domains AdCP's privacy posture is not uniform. TMP is the only domain that enforces privacy **structurally** — through separated code paths, schema-level prohibitions on combining identity with context, and independently verifiable decorrelation. Every other domain relies on **contractual confidentiality or per-session consent** — the parties exchanging data are bound by the account's terms or the user's consent, not by protocol-level separation. Governance gating (via campaign governance) is orthogonal: it can require human approval on budget, policy, or brand-safety grounds, but it does not change the privacy mechanism of the underlying domain. | Domain | Privacy mechanism | Notes | | --------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Trusted Match Protocol | **Structural separation** | Context Match and Identity Match operate on separated code paths; schemas prohibit crossover. See [TMP privacy architecture](/dist/docs/3.1.20/trusted-match/privacy-architecture). | | Media Buy | Contractual | Buyer and seller exchange full plan context under account terms. | | Creative | Contractual | Creative assets and targeting signals pass through the creative agent under account terms. | | Signals | Contractual | Audience and signal data exchanged under account terms and signal-provider agreements. | | Brand / Registry / Accounts | Public or contractual | Brand identity is public (`brand.json`); commercial terms are account-scoped. | | Sponsored Intelligence | Consent-first | The user consents per session; the brand agent sees conversation content, and identity only when the user shares it. Networks that route sessions may see routing metadata — see [Networks](/dist/docs/3.1.20/sponsored-intelligence/networks). | | Governance | Contractual | Governance agents receive the context needed to evaluate policies under account terms. | "Structural" means the protocol — not the operator's policy — prevents the combination of sensitive data. If you need that property outside TMP, build it yourself or compose with TMP. *** ## Ecosystem layers The protocol domain map above shows how AdCP tasks relate to each other. The diagram below shows how these map to real-world roles and systems. Four-tier ecosystem diagram showing business parties at top, orchestration layer with specialized agents in the middle, technical execution below, and governance with human oversight spanning all layers ### Business parties **Buy side** — advertisers, agencies, retail media networks, and curators packaging inventory and data for specific use cases. **Media seller** — publishers, sales houses, rep firms, SSPs, and ad networks. These parties exchange impressions and money through the orchestration layer. ### Orchestration layer **Media orchestration platform** — evaluates sellers and audiences, executes buying strategies. Communicates with specialized agents via MCP. **Signals agent** — MCP servers exposing audience and targeting data discovery and activation. **Sales agent** — MCP servers exposing media product discovery and campaign execution. **Creative agent** — MCP servers exposing format discovery and AI-powered creative generation. ### Technical execution **Trusted Match Protocol (TMP)** — real-time execution layer that determines which pre-negotiated packages should activate for a given impression. Two structurally separated operations — Context Match (content fit) and Identity Match (user eligibility) — connect planning-time media buys to serve-time decisions across web, mobile, CTV, AI assistants, and retail media. See [TMP documentation](/dist/docs/3.1.20/trusted-match). **Agentic eXecution Engine (AXE)** — deprecated predecessor to TMP. See [AXE documentation](/dist/docs/3.1.20/media-buy/advanced-topics/agentic-execution-engine). **Decisioning platform** — the infrastructure that selects which ad to serve, via direct campaigns or programmatic (RTB). Examples include DSPs, SSPs, and ad servers. ### Governance and human oversight **Governance agents** provide compliance and quality control across all layers: property lists, brand suitability scoring, quality measurement (MFA score, ad density), and privacy compliance (COPPA, TCF, GDPR). They operate at setup time, real-time, and post-bid. **Human-in-the-loop** — manual approval at decision points. See [How human-in-the-loop enters the protocol](/dist/docs/3.1.20/governance/embedded-human-judgment#how-human-in-the-loop-enters-the-protocol). *** ## State persistence and horizontal scaling AdCP is a multi-instance protocol. A single buyer's workflow with an agent may be routed across multiple backend replicas — `create_media_buy` may land on one replica, the subsequent `get_media_buy` on another — and both calls MUST see the same state. ### Normative requirements State keyed by a `(brand, account)` tuple MUST survive across agent process instances. This includes but is not limited to accounts, catalogs, creatives, audiences, event sources, governance configuration, active campaigns, proposals, insertion-order approval records, signal activations, sponsored-intelligence sessions, async task records, and idempotency-key cache entries. Implementations MUST NOT use in-process memory as the primary store for any `(brand, account)`-scoped state that a subsequent call can read back. In-process storage does not satisfy this requirement. For the canonical (non-exhaustive) catalog of state domains, see [Account state](/dist/docs/3.1.20/building/by-layer/L2/account-state). **Acceptable storage:** Postgres, Redis, DynamoDB, or any shared store that persists across process restarts and is reachable from every replica. **Not acceptable:** module-level variables, per-process Maps or dicts, single-node file storage, or sticky-session routing that masks the absence of shared state. Within a single `(brand, account)` context, implementations MUST support read-your-writes across replicas: after a successful non-async response, a subsequent request routed to any replica MUST observe the write. Async/pending task state (status transitions, `context_id`, push-notification subscriptions) is itself subject to this rule — once a task record is created, it MUST be readable from any replica. Eventual consistency is acceptable when the staleness window is bounded and disclosed — either capped at the implementation's documented async polling interval, or declared explicitly in `get_adcp_capabilities`. **Sandbox exemption.** Sandbox accounts are allowed to carry ephemeral state that does not persist across process restarts (see [Sandbox mode](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox)). Within a single sandbox session, read-your-writes across replicas still applies. Implementations may prove this invariant by architecture (managed serverless + shared datastore), by multi-instance compliance testing, or by their own verification; the protocol cares about the invariant, not the methodology. See [Validate your agent — Verifying cross-instance state](/dist/docs/3.1.20/building/verification/validate-your-agent#verifying-cross-instance-state). # Calling an AdCP agent Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/calling-an-agent Wire-level invariants every AdCP buyer must follow: idempotency_key replay, account oneOf variants, async status:'submitted' polling, and error recovery from adcp_error.issues[]. # Calling an AdCP agent This page is the canonical buyer-side wire contract: the rules that don't live cleanly in any single task schema, but apply to every mutating call you'll make. If you're building a buyer (DSP, planning tool, agentic client) and calling out to AdCP sales, creative, signals, governance, SI, or brand agents, read this once. The agent-facing version of this content lives at [`skills/call-adcp-agent/SKILL.md`](https://github.com/adcontextprotocol/adcp/blob/main/skills/call-adcp-agent/SKILL.md) — bundled into the [protocol tarball](/dist/docs/3.1.20/building/by-layer/L0/schemas#one-shot-protocol-bundle) so SDKs can ship it to coding agents. ## Discovery chain Walk these in order on first contact with any new agent: 1. **Agent card** (A2A) or **`tools/list`** (MCP): returns tool *names*. AdCP MCP servers no longer publish per-tool parameter schemas in `tools/list` — every tool shows `{type: 'object', properties: {}}`. Don't try to infer shape from there. 2. **`get_adcp_capabilities`**: returns supported protocols, AdCP major versions, and feature flags. Tells you *which* tools this agent supports, not how to call them. See [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities). 3. **`get_schema(tool_name)`** *(when the agent exposes it — pending standardization, see [#3057](https://github.com/adcontextprotocol/adcp/issues/3057))*: returns the JSON Schema for a specific tool's request/response. 4. **Bundled schemas** (offline, authoritative): every published AdCP version ships JSON Schemas for every tool, signed via Sigstore. The path differs by SDK — the spec repo source uses `dist/schemas//bundled/`, `@adcp/sdk` puts them at `schemas/cache//bundled/` after `npm run sync-schemas`, Python and Go SDKs use their own conventions. Don't hardcode a path; let the SDK's loader find them. Once located, each schema lives at `/-{request,response}.json`. ## Idempotency: replay vs. new operation Every mutating tool requires an `idempotency_key` (UUID). * **Same key on retry** → server replays the **same response**, byte-for-byte. Use this for transport-level retries (timeout, 5xx, dropped connection). * **Fresh key** → **new operation**, regardless of body. Generating a new UUID because the previous attempt failed is the most common way naïve callers create duplicate media buys. * **Same key, different canonical body** → `IDEMPOTENCY_CONFLICT`. Sellers MUST reject (rule 5 in [security.mdx#idempotency](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency)) — do not silently apply the second body, do not silently replay the first response. * **Same key while first request still running** → `IDEMPOTENCY_IN_FLIGHT` (rule 9 in [security.mdx#idempotency](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency)). The seller MAY return this code with top-level `error.retry_after` instead of blocking. Wait and retry with the **same key** — minting a fresh key on this code turns a safe retry into a double-execution race. For async flows, the replayed response carries the **same `task_id`** so polling continues against the same task instead of forking. `idempotency_key` is required on: `create_media_buy`, `update_media_buy`, `sync_creatives`, `sync_audiences`, `sync_accounts`, `sync_catalogs`, `sync_event_sources`, `sync_plans`, `sync_governance`, `activate_signal`, `acquire_rights`, `log_event`, `report_usage`, `provide_performance_feedback`, `report_plan_outcome`, `create_property_list`, `update_property_list`, `delete_property_list`, `create_collection_list`, `update_collection_list`, `delete_collection_list`, `create_content_standards`, `update_content_standards`, `calibrate_content`, `si_initiate_session`, `si_send_message`. Missing the key → `adcp_error.code: 'VALIDATION_ERROR'` with `/idempotency_key` in `issues`. ## `account` is `oneOf` — pick exactly one variant `account` is a discriminated union. On `create_media_buy` and `update_media_buy`, two variants: ```json theme={null} // variant 0: by seller-assigned id (from list_accounts or out-of-band onboarding; // buyer-declared sellers may also echo account_id from sync_accounts as an internal handle) "account": { "account_id": "seller_assigned_id" } // variant 1: by natural key (brand + operator, optional sandbox) // brand.domain — the buyer's brand domain (e.g., advertiser website) // operator — the buyer-side entity operating on behalf of the brand "account": { "brand": { "domain": "acme.com" }, "operator": "pinnacle-media.com" } ``` **Do NOT merge required fields across variants.** `additionalProperties: false` on each variant means `{account_id, brand}` fails BOTH. When a task schema requires `account`, send an explicit `AccountRef` even if the SDK auto-selected the only account available to the authenticated credential. Hidden credential-implied defaulting is not a protocol model. When a task marks `account` optional, omission has only the semantics documented by that task. Other tools (e.g. `sync_creatives`) may accept a superset — always check the specific tool's schema. ## Async responses: `status: 'submitted'` means queued A mutating tool can return one of three shapes: ```json theme={null} // Success (sync): the work is done { "media_buy_id": "mb_123", "packages": [...], "confirmed_at": "..." } // Submitted (async): the work is queued { "status": "submitted", "task_id": "tk_abc", "message": "Awaiting IO signature" } // Error: don't retry without fixing { "errors": [{ "code": "PRODUCT_NOT_FOUND", "message": "..." }] } ``` AdCP task state is an **application-layer** contract. MCP and A2A may wrap, stream, or transport an AdCP response, but their native task mechanisms do not replace the AdCP `task_id`, status values, webhook payloads, or polling/reconciliation surfaces. A transport task can complete after delivering an AdCP response whose payload still says `status: 'submitted'`. When you see `status: 'submitted'`, the work is **not** complete. In 3.x, poll via the legacy AdCP `tasks/get` surface using the returned `task_id`. Sellers MAY also advertise the non-colliding `get_task_status` alias; callers MAY use that alias when it appears in discovery. Both AdCP polling names use the same snake\_case payload shape, including the optional `account` scope for multi-account credentials. Do not confuse either AdCP polling shape with transport-native MCP/A2A `tasks/get`, which uses the transport's own task wire shape. Pass `include_result: true` when polling so the seller includes the completion payload once status transitions to `completed`: ```json theme={null} // tasks/get request (same payload as optional get_task_status alias) { "task_id": "task_456", "include_result": true, "account": { "brand": { "domain": "acmeoutdoor.example" }, "operator": "pinnacle-agency.example", "sandbox": true } } // tasks/get response — completed { "task_id": "task_456", "task_type": "create_media_buy", "protocol": "media-buy", "status": "completed", "completed_at": "2025-01-22T10:30:00Z", "result": { "media_buy_id": "mb_12345", "packages": [{ "package_id": "pkg_001" }] } } ``` The `result` field uses the same payload structure as the push-notification webhook `result` field for completed tasks — buyers who configure both polling and webhooks receive the same data shape either way. ## Error recovery — read `issues[]` Every validation failure produces an envelope shaped like: ```json theme={null} { "adcp_error": { "code": "VALIDATION_ERROR", "recovery": "correctable", "field": "/first/offending/pointer", "issues": [ { "pointer": "/account", "keyword": "oneOf", "message": "must match exactly one schema in oneOf", "variants": [ { "index": 0, "required": ["account_id"], "properties": ["account_id"] }, { "index": 1, "required": ["brand", "operator"], "properties": ["brand", "operator", "sandbox"] } ] }, { "pointer": "/brand/domain", "keyword": "required", "message": "must have required property 'domain'" } ] } } ``` * `issues[].pointer` — RFC 6901 JSON Pointer to the offending field * `issues[].keyword` — Ajv keyword (`required`, `type`, `oneOf`, `anyOf`, `additionalProperties`, `format`, `enum`) * `issues[].variants` — when `keyword` is `oneOf` or `anyOf`, each entry lists one variant's `required` + declared `properties` **For `oneOf` failures, pick ONE variant from `variants[]` and send only its `required` fields.** This is the fastest recovery path when you didn't know the field was a union. `recovery` values: * `correctable` — buyer-side fix; read `issues[]`, patch the pointers, resend * `transient` — retry with the **same** `idempotency_key` * `terminal` — requires human action (account suspended, payment required); do not retry ## Common shape pitfalls | Symptom | What it means | Fix | | -------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `keyword: 'oneOf'` with `variants[]` | Discriminated union — you sent fields from multiple variants, or none | Pick ONE variant from `variants[]`. Send only its `required` fields. | | 2-3 `additionalProperties` errors at the same pointer | You merged `oneOf` variants | Drop to one variant. Don't keep "extra" fields "for completeness". | | `keyword: 'required'`, `pointer: '/idempotency_key'` | Mutating tool, no UUID | Generate fresh UUID per logical operation. Reuse on retries. | | `keyword: 'type'` or `additionalProperties` at `/budget` | Sent `{amount, currency}` | `budget` is a number. Currency is implied by `pricing_option_id`. | | `additionalProperties` at `/format_id` (string passed) | Sent `"format_id": "video_..."` | `format_id` is `{agent_url, id}` — always an object. | | `keyword: 'enum'` at `/destinations/*/type` | Made-up destination type | Use `'platform'` (with `platform`) or `'agent'` (with `agent_url`). | | Response carries `status: 'submitted'` and `task_id` | Async — work is queued, NOT done | Poll with legacy `tasks/get`, or `get_task_status` when the seller advertises the alias. | ## Transport notes * **MCP**: `tools/call` with `{ name: 'tool_name', arguments: {...} }`. Read `structuredContent` for the typed response. * **A2A**: `message/send` with a `DataPart` of shape `{ skill: 'tool_name', input: {...} }`. The typed response is at `task.artifacts[0].parts[0].data`. Both transports share idempotency, error shape, schema enforcement, and handler semantics. If a call works on one, the equivalent call works on the other. A common trap: **A2A `Task.state: 'completed'` is not the same as AdCP completion.** A2A task state describes the transport call lifecycle; AdCP-level completion is in the artifact's payload (`structuredContent.status` or `data.status`). A `completed` A2A task can still carry a `submitted` AdCP response. ## Related * Per-task request/response shapes: see the protocol-specific reference (`/docs/media-buy/`, `/docs/creative/`, `/docs/signals/`, etc.). * [Protocol architecture](/dist/docs/3.1.20/protocol/architecture) — how the protocol domains fit together. * [Required tasks](/dist/docs/3.1.20/protocol/required-tasks) — which tasks an agent must implement to claim a specialism. * [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) — first call against any new agent. * [Schemas](/dist/docs/3.1.20/building/by-layer/L0/schemas) — how SDKs consume the protocol tarball (which now bundles `skills/`). * [Build a caller](/dist/docs/3.1.20/building/by-layer/L4/build-a-caller) — build-shaped guide for the caller side: install, call, handle responses, ingest reporting. # Capabilities explorer Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/capabilities-explorer Browsable view of the get_adcp_capabilities response schema — every top-level domain, every sub-namespace, with anchors and 'propose extension here' links so new flags land in the right home. # Capabilities explorer This page renders the actual top-level shape of the [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) response so you can find the right place for a new capability before you propose it. The most common reason an extension RFC gets bounced is wrong-shape: the proposal puts a flag at a level that doesn't match where similar flags already live. Walk the tree first. If you're here to draft an RFC, the workflow is: 1. Find the closest existing top-level domain for your flag below. 2. Drill into that domain's sub-namespaces (`features`, `execution`, etc.). 3. If your flag fits an existing sub-namespace, propose it there. Use the **propose extension here** link at the relevant node — it pre-fills the issue title with the path. 4. If nothing fits, scroll to **Before proposing a new top-level key** at the bottom and answer the gate questions in your RFC body. The authoritative schema lives at [`static/schemas/source/protocol/get-adcp-capabilities-response.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/source/protocol/get-adcp-capabilities-response.json). The tree below is curated to one level deep for the rich domains; for the full nested shape, read the schema. See also [Design principles — capabilities are commitments, declared under existing buckets](/dist/docs/3.1.20/protocol/design-principles#4-capabilities-are-commitments-declared-under-existing-buckets). *** ## Top-level domains The 14 domains below are the entire top-level surface of `get_adcp_capabilities`. Every capability flag eventually nests under one of these. New top-level keys are extremely rare and should not be the first move — see the gate questions at the end of this page. ### `adcp` — core protocol identity Version negotiation, idempotency contract, build identifier. * **`supported_versions`** — release-precision strings (e.g. `"3.0"`, `"3.1"`). Authoritative for buyer-side release pinning. * **`major_versions`** — DEPRECATED in favor of `supported_versions`; sellers MUST keep emitting through 3.x. * **`build_version`** — full semver build identifier; advisory metadata for buyer-side incident triage. * **`idempotency`** — discriminated union (`IdempotencySupported` / `IdempotencyUnsupported`) declaring replay semantics. **A declaration here is a commitment** — sellers that declare `supported: true` are probed by the conformance runner. [Propose an extension to `adcp`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+adcp+capability:+%3Cflag%3E\&body=Schema+location:+%60.adcp%60%0AExisting+keys:+supported_versions,+major_versions,+build_version,+idempotency%0A%0A%23%23+Proposal%0A%0A...%0A%0A%23%23+Why+this+belongs+under+%60adcp%60+and+not+a+new+top-level+key%0A%0A...\&labels=rfc,capabilities) *** ### `supported_protocols` — which AdCP protocols this agent implements Array of protocol names. Each value commits the agent to (a) implement those tools and (b) pass the baseline compliance storyboard at `/compliance/{version}/protocols/{protocol}/`. Valid values cover `media_buy`, `creative`, `signals`, `governance`, `sponsored_intelligence`, `brand`, `accounts`, `measurement` (in development). [Propose a new protocol to add to `supported_protocols`](https://github.com/adcontextprotocol/adcp/issues/new?title=Add+protocol+to+supported_protocols:+%3Cname%3E\&body=%23%23+Proposal%0A%0A...%0A%0A%23%23+Compliance+storyboard+plan%0A%0AHow+will+the+baseline+conformance+suite+exercise+this+protocol?+...\&labels=rfc,capabilities,new-protocol) *** ### `account` — account establishment and billing How accounts are negotiated; whether one is required before product discovery; what billing models are supported. * **`required_for_products`** — boolean. If true, `get_products` requires an established account. * **`authorization_endpoint`** — OAuth/auth URL for account negotiation. * **`require_operator_auth`** — declares whether operator-level authentication is required. * **`supported_billing`** — array of billing models (e.g. `prepaid`, `monthly_invoice`). * **`account_financials`** — what financial data the seller exposes (credit limits, current balance, etc.). * **`sandbox`** — sandbox-account capabilities. [Propose an extension to `account`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+account+capability:+%3Cflag%3E\&body=Schema+location:+%60.account%60\&labels=rfc,capabilities) *** ### `media_buy` — media buying capabilities The largest domain. Sub-namespaces are where most media-buying flags belong. * **`features`** — boolean feature flags (e.g. `inline_creative_management`, `property_list_filtering`, `catalog_management`, `committed_metrics_supported`). **Most new media-buy capability flags belong here.** * **`execution`** — technical execution capabilities. Contains `trusted_match` (TMP), `creative_specs` (VAST/MRAID/VPAID/SIMID versions), `targeting` (geo / audience / device / temporal), `axe_integrations` (deprecated). See note in [Design principles — Where the surface doesn't yet follow these](/dist/docs/3.1.20/protocol/design-principles#where-the-surface-doesnt-yet-follow-these-principles) about the `trusted_match` placement. * **`audience_targeting`** — declared audience-targeting capabilities. * **`content_standards`** — content-standards enforcement capabilities. * **`conversion_tracking`** — conversion-tracking capabilities. * **`offline_delivery_protocols`** — supported offline delivery protocols (broadcast trafficking, etc.). * **`portfolio`** — portfolio-management capabilities. * **`reporting_delivery_methods`** — how reporting is delivered. * **`supported_pricing_models`** — array (CPM, CPC, CPCV, CPP, fixed, etc.). [Propose a flag for `media_buy.features`](https://github.com/adcontextprotocol/adcp/issues/new?title=Add+media_buy+feature:+%3Cflag%3E\&body=Schema+location:+%60.media_buy.features%60%0A%0A%23%23+Proposal%0A%0A...%0A%0A%23%23+Why+a+feature+flag+and+not+a+new+task%0A%0A...%0A%0A%23%23+Conformance+probe%0A%0AHow+can+a+buyer+verify+this+capability+is+actually+honored?+...\&labels=rfc,capabilities) [Propose an extension to `media_buy.execution`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+media_buy.execution:+%3Cflag%3E\&body=Schema+location:+%60.media_buy.execution%60%0AExisting+sub-keys:+trusted_match,+creative_specs,+targeting\&labels=rfc,capabilities) *** ### `signals` — audience and contextual data activation Authorization scope and feature flags for the signals domain. * **`data_provider_domains`** — array of domains this signals agent is authorized to resell. Buyers fetch each provider's `adagents.json` to verify. * **`features`** — boolean feature flags. `catalog_signals` is deprecated; structured `signal_ref` support is part of the Signals protocol and should not require a feature flag. **Additional signals capability flags belong here**, not in a new top-level key. (E.g., a `signal_enforcement_on_guaranteed` flag for direct-sold targeting belongs at `signals.features`, not under `media_buy.execution.trusted_match`.) [Propose a flag for `signals.features`](https://github.com/adcontextprotocol/adcp/issues/new?title=Add+signals+feature:+%3Cflag%3E\&body=Schema+location:+%60.signals.features%60%0A%0A%23%23+Proposal%0A%0A...%0A%0A%23%23+Conformance+probe%0A%0A...\&labels=rfc,capabilities,signals) *** ### `governance` — governance protocol capabilities Property and creative governance capabilities. * **`property_features`** — what the governance agent does with property lists. * **`creative_features`** — what the governance agent does for creative review. * **`aggregation_window_days`** — how long the agent aggregates governance events. [Propose an extension to `governance`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+governance+capability:+%3Cflag%3E\&body=Schema+location:+%60.governance%60\&labels=rfc,capabilities,governance) *** ### `sponsored_intelligence` — conversational brand experiences For agents that handle SI sessions. * **`endpoint`** — SI endpoint URL. * **`brand_url`** — brand identity URL. * **`capabilities`** — SI-specific capabilities (commerce handoff, voice, UI components, etc.). [Propose an extension to `sponsored_intelligence`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+sponsored_intelligence+capability:+%3Cflag%3E\&body=Schema+location:+%60.sponsored_intelligence%60\&labels=rfc,capabilities) *** ### `brand` — brand protocol capabilities For brand agents. * **`description`** — agent description. * **`available_uses`** — what the brand data is licensed for. * **`generation_providers`** — supported generation providers. * **`right_types`** — right types the agent grants. * **`rights`** — concrete rights this agent issues. [Propose an extension to `brand`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+brand+capability:+%3Cflag%3E\&body=Schema+location:+%60.brand%60\&labels=rfc,capabilities) *** ### `creative` — creative protocol capabilities For creative agents. * **`has_creative_library`** — whether the agent maintains a creative library. * **`supports_compliance`** — creative compliance scanning. * **`supports_generation`** — generative-creative capabilities. * **`supports_transformation`** — creative transformation capabilities. [Propose an extension to `creative`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+creative+capability:+%3Cflag%3E\&body=Schema+location:+%60.creative%60\&labels=rfc,capabilities,creative) *** ### `request_signing` — RFC 9421 HTTP Signatures for incoming requests Optional in 3.0; capability-advertised so counterparties can opt into signing selectively. * **`supported`** — boolean. * **`required_for`** — array of operations where signing is required. * **`supported_for`** — array of operations where signing is supported. * **`warn_for`** — array of operations where unsigned requests produce warnings. * **`covers_content_digest`** — whether signatures cover the request body digest. [Propose an extension to `request_signing`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+request_signing+capability:+%3Cflag%3E\&body=Schema+location:+%60.request_signing%60\&labels=rfc,capabilities,security) *** ### `webhook_signing` — RFC 9421 signing for outbound webhooks Top-level peer of `request_signing`. * **`supported`** — boolean. * **`profile`** — signing profile name. * **`algorithms`** — supported signature algorithms. * **`legacy_hmac_fallback`** — whether HMAC fallback is supported for legacy receivers. [Propose an extension to `webhook_signing`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+webhook_signing+capability:+%3Cflag%3E\&body=Schema+location:+%60.webhook_signing%60\&labels=rfc,capabilities,security) *** ### `identity` — operator identity posture Key-scoping and compromise-response controls. Advisory in 3.x; required in 4.0. * **`per_principal_key_isolation`** — whether each principal has an isolated key. * **`key_origins`** — declared key-management origins. * **`compromise_notification`** — notification posture for key compromise events. [Propose an extension to `identity`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+identity+capability:+%3Cflag%3E\&body=Schema+location:+%60.identity%60\&labels=rfc,capabilities,security) *** ### `measurement` — measurement capabilities (in development) Quantitative metrics about ad delivery, exposure, or effectiveness. * **`metrics`** — array of metric definitions this agent emits. The protocol surface beyond capabilities discovery (reporting, attribution tasks) lands in subsequent minors. [Propose an extension to `measurement`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+measurement+capability:+%3Cflag%3E\&body=Schema+location:+%60.measurement%60\&labels=rfc,capabilities,measurement) *** ### `compliance_testing` — deterministic test scenarios Declares the agent supports `comply_test_controller` and which scenarios are honored. * **`scenarios`** — array of compliance scenario IDs the agent supports. [Propose an extension to `compliance_testing`](https://github.com/adcontextprotocol/adcp/issues/new?title=Extend+compliance_testing+capability:+%3Cflag%3E\&body=Schema+location:+%60.compliance_testing%60\&labels=rfc,capabilities,compliance) *** ### `specialisms` — kebab-case specialty IDs Optional. Specialty compliance claims (e.g., `creative-generative`, `sales-non-guaranteed`). Values are kebab-case enum IDs registered with the working group. [Propose a new specialism](https://github.com/adcontextprotocol/adcp/issues/new?title=Add+specialism:+%3Ckebab-case-id%3E\&body=%23%23+Specialism+ID%0A%0A%3Ckebab-case-id%3E%0A%0A%23%23+What+it+claims%0A%0A...%0A%0A%23%23+Conformance+criteria%0A%0AHow+do+we+verify+an+agent+actually+meets+this+claim?+...\&labels=rfc,capabilities,specialism) *** ## Things-this-agent-does that aren't a domain Three top-level lists carry capability metadata that doesn't fit a single protocol domain. The shape inconsistency between them is on the open list ([Design principles — Where the surface doesn't yet follow these](/dist/docs/3.1.20/protocol/design-principles#where-the-surface-doesnt-yet-follow-these-principles)). * **`extensions_supported`** — array of extension namespaces this agent populates (`ext.{namespace}`). * **`experimental_features`** — array of experimental surface IDs this agent implements (e.g. `trusted_match.core`). * **`compliance_testing`** — covered above. *** ## Before proposing a new top-level key If your flag genuinely doesn't fit any of the 14 domains above, open the RFC — but answer these gate questions in the body. Most reviewers expect them. RFCs that ignore them tend to bounce. 1. **Which existing top-level domain is closest?** Name it. Explain why your flag *isn't* an extension to that domain's `features`, `execution`, or other sub-namespaces. 2. **Why isn't this an `ext.{vendor}` extension?** Vendor-specific behavior belongs in vendor namespaces ([spec-guidelines on platform agnosticism](/dist/docs/3.1.20/spec-guidelines#platform-agnosticism)). Why is your flag normative across all implementers? 3. **What's the conformance probe?** A capability declaration is a commitment, not an advertisement. How does the conformance runner verify a seller that declares your flag actually honors it? 4. **What does this rule out?** What proposals does adding this top-level key make easier — and what does it make harder for someone scanning the top level a year from now? These are the same questions reviewers will ask. Answering them in the RFC saves a round-trip. [Propose a new top-level capability key (use only after answering the gate questions)](https://github.com/adcontextprotocol/adcp/issues/new?title=Propose+new+top-level+capability+key:+%3Cname%3E\&body=%23%23+Proposed+top-level+key%0A%0A%3Cname%3E%0A%0A%23%23+Gate+questions%0A%0A**1.+Which+existing+top-level+domain+is+closest+and+why+isn%27t+this+an+extension+there?**%0A%0A...%0A%0A**2.+Why+isn%27t+this+an+%60ext.%7Bvendor%7D%60+extension?**%0A%0A...%0A%0A**3.+What%27s+the+conformance+probe?**%0A%0A...%0A%0A**4.+What+does+this+rule+out?**%0A%0A...\&labels=rfc,capabilities,new-top-level-key) *** ## Related * [Design principles](/dist/docs/3.1.20/protocol/design-principles) — the reasoning behind the capability surface shape. * [`get_adcp_capabilities` task reference](/dist/docs/3.1.20/protocol/get_adcp_capabilities) — the response schema as documented per-field. * [Specification Guidelines](/dist/docs/3.1.20/spec-guidelines) — type naming, enum design, vendor-neutral rule. # How AdCP is designed Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/design-principles The load-bearing principles behind AdCP's design — what they rule out, when you'd be right to push back, and where the surface doesn't yet follow them. # How AdCP is designed You don't need to read this page to use AdCP. You do need to read it to extend it. This is the meta-protocol — the philosophical framework behind the calls we made. Each principle is a load-bearing decision that shows up the moment someone proposes "just add this one field." We've made these calls deliberately, and we revisit them deliberately. The goal here is to give contributors enough context to either propose something that fits the existing shape or to argue, with both feet on the ground, for changing the shape itself. Two principles bounce most RFCs that reach the working group: **the schema is the spec** and **compose before adding a task**. They sit first because they're load-bearing for the rest. Six supporting principles follow. A "where the surface doesn't yet follow these" section at the end names the contradictions we know about — the principles are credible only to the extent that we're honest about them. Each principle has the same structure: the rule, why we chose it, what it rules out, and the exception path — when pushing back on the principle is the right move. *** ## The two principles that bounce most RFCs ### 1. The schema is the spec Documentation describes; schemas decide. When documentation and schemas diverge, schemas win. A proposal that extends a field or task that does not exist in the schema is making two claims at once — that a thing exists, and that it should grow — and reviewers will bounce it on the first claim alone. **Why we chose it.** Generated SDKs come from schemas, conformance tests come from schemas, the registry comes from schemas. A doc page can lag for a release and the protocol still works; a schema change ships everywhere. The schema is also the only place we can enforce the cross-language guarantees AdCP claims (TypeScript, Python, Go all generate cleanly from one source). See [Specification Guidelines — Philosophy](/dist/docs/3.1.20/spec-guidelines#philosophy) for the canonical version of this rule. **What this rules out.** Proposing a `direct_sold_signals` flag inside a `trusted_match` capability object in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) when the schema's actual top-level keys are `adcp`, `supported_protocols`, `account`, `media_buy`, `signals`, `governance`, `sponsored_intelligence`, `brand`, `creative`, `request_signing`, `webhook_signing`, `identity`, `compliance_testing`, and `specialisms`. There is no `trusted_match` top-level key — and the right shape for the proposed flag is `signals.features.signal_enforcement_on_guaranteed`, not a new bucket. Reviewers bounce the issue on the wrong-shape premise alone, before evaluating the underlying idea — which may be perfectly good. **When you'd be right to push.** When the schema genuinely lacks a place to put your proposal. State that as the first claim, propose the new schema location with a path, and then propose the field. Two clear claims beat one fuzzy one. → Use the [Capabilities explorer](/dist/docs/3.1.20/protocol/capabilities-explorer) before proposing a new flag. It walks the actual schema tree. *** ### 2. Compose with existing primitives before adding a new task Every new top-level task is a permanent surface every implementer eventually has to reason about, even the ones who don't use it. Before proposing one, the question to answer is: can this be expressed via the existing tasks plus their existing modes? If yes, the proposal is a documentation gap (or a missing field) — not a new task. **Why we chose it.** Tasks are the protocol's coordination cost. Adding a task is the most expensive thing a contributor can ask for, because it lands on every sales agent, every buyer agent, every SDK, every test suite, every conformance row. Fields and modes compose; tasks don't. A protocol that grows by adding tasks every release ages into something only its authors can hold in their head. **What this rules out.** Proposing a `get_price_quote` task between [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) and [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) — a configure-price-quote step where the buyer submits targeting and the seller returns a firm rate. Most of what that proposal asks for already exists: account-scoped rate cards via `account`, firm prices via `pricing_options`, the `pricing_option_id` lock at commit time, and `buying_mode: "refine"` for the iteration loop. The "new task" framing assumes targeting and pricing are missing from discovery; they aren't. **When you'd be right to push.** When no composition reaches the desired semantics — when the proposal demands a state transition or a counterparty role no existing task can carry. The bar is high and that's intentional. Bring evidence: which existing task you tried to extend, why the extension didn't work, what the new task adds that an extension can't. *** ## Six supporting principles ### 3. The brief drives discovery; targeting is an input, not a step Targeting is what the buyer wants. It's not a downstream filter on a generic catalog — it's what the seller curates *against*. Buyers send a brief (or `wholesale + filters`) to [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products); the seller returns products already shaped for that intent, with pricing already scoped to the buyer's account via the `account` parameter. Iteration happens through `buying_mode: "refine"` with a typed change array — the round-trip is folded into discovery, not bolted on afterward. **Why we chose it.** A brief is the natural unit of buyer intent. The publisher knows their inventory, their audience, and their rate card better than any external taxonomy. Curating against the brief lets the seller bring all of that to the response in one round-trip, which is also the round-trip that produces the price. Splitting targeting and pricing across two tasks turns one expert decision into two underspecified ones. **What this rules out.** A separate quote step between products and buy creation. (See principle 2 — the same RFC fails both filters.) **When you'd be right to push.** When pricing depends on flight dates and total budget that the buyer hasn't committed yet — seasonality, volume tiers, sell-through-rate-driven yield. Or when the seller wants to issue a time-bound firm rate (`valid_until`) before commitment, distinct from indicative pricing options. Or when buyers need an auditable explanation of what drove the price (a `rate_basis` field). Those three are real gaps, and they're extensions to `pricing_options` and the discovery flow — not a new task. → See [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) for the brief-first model. *** ### 4. Capabilities are commitments, declared under existing buckets Two things, one principle. **Where capabilities live** in the schema: under existing top-level domains (`signals`, `media_buy`, `creative`, `governance`), not in new top-level keys. **What declaring a capability means**: an enforceable contract, not an advertisement. If a sales agent declares `idempotency.supported: true`, the conformance suite probes it and the contract is testable. Declarations cost something to make. **Why we chose it.** Top-level keys are the most visible part of the capabilities surface. Each one is a category every implementer scans on every read. Sub-namespacing keeps related capabilities discoverable together (`signals.features.X` belongs near `signals.data_provider_domains`) and keeps the top level scannable. Adding a top-level key for one flag is like adding a department to handle one form. And declarations need to be load-bearing to be useful: a flag the protocol can't enforce or test is a wish, not a contract. **What this rules out.** "Add a `trusted_match` top-level capability key" for a flag about how signals interact with guaranteed line items. The flag belongs in `signals.features` — a `signal_enforcement_on_guaranteed: "enforced" | "best_effort" | "not_supported"` enum, not a new bucket. Also: declaring a capability you don't actually support, hoping no one probes it. The conformance runner will. **Reviewer test.** Did the proposal cite the actual top-level keys in the schema today, and explain why none of them is the right home? If the proposal can't answer that question, it's not ready. → The [Capabilities explorer](/dist/docs/3.1.20/protocol/capabilities-explorer) renders the existing tree. *** ### 5. Trust is bilateral and `/.well-known`-rooted Trust in AdCP is bilateral and verifiable, not blessed by a registry. Operators declare organization identity, agents, property relationships, and signing-key discovery via [`brand.json`](/dist/docs/3.1.20/brand-protocol/brand-json) at `/.well-known/brand.json`; publishers declare which agents are authorized for which properties via [`adagents.json`](/dist/docs/3.1.20/governance/property/adagents). Either party can resolve and verify the other's declarations. The [Registry](/dist/docs/3.1.20/registry) helps with discovery and resolution; it does not gate participation. Every discovery hop is an HTTP GET against a deterministic path — the model ads.txt and sellers.json got right, deliberately reused. **Why we chose it.** Every centralized trust authority eventually becomes a tax. ads.txt and sellers.json got the model right: declarations are public and machine-readable; verification is bilateral; the network discovers truth without a single gatekeeper deciding who counts. AdCP follows that pattern deliberately — adding a "well-known brand registry" or a "verified buyer" tier would re-create the exact ad tech tax structure the protocol is meant to replace. **What this rules out.** Proposals that frame brand verification as a centralized validation problem — "who decides which brands are well-known?", "should AAO verify brand.json submissions before allowing them in the registry?" The premise is the error: `/.well-known/` is a [URI convention from RFC 8615](https://datatracker.ietf.org/doc/html/rfc8615), not a quality signal. A `brand.json` at a domain proves only that whoever controls that domain published it. Trust is built by composing that proof with adagents.json, account-level commercial relationships, and (when needed) signed requests — not by a third party blessing the brand list. **When you'd be right to push.** When you have evidence of a trust failure that bilateral declarations *demonstrably can't* address — not "what if a fraud declares themselves," but "here's a class of fraud the bilateral model misses, here's the cost, here's the smallest amount of centralization that closes it." Trust extensions are accepted; trust centralization needs receipts. The unresolved question on the substrate (CDN takeovers, DNS games, stale `/.well-known/` crawls) is real — see [Trust & Security](/dist/docs/3.1.20/trust) for what AdCP provides versus what it explicitly does not. *** ### 6. Privacy is layered, not uniform [Trusted Match Protocol](/dist/docs/3.1.20/trusted-match) has *structural* privacy — separated code paths, schema prohibitions on combining identity with context, decorrelation that's independently verifiable. Every other domain has *contractual* privacy — the parties exchanging data are bound by the account's terms or the user's consent. These are different mechanisms with different guarantees, and proposals that mix them produce confused features. **Why we chose it.** Structural privacy is expensive — it constrains the schema, requires separated infrastructure, and limits what compositions are possible. We pay that cost in TMP because TMP runs at impression time across mixed buyer/seller boundaries, where contractual confidentiality can't reach. Outside TMP, parties have already established a commercial relationship; a contract is the right unit of trust. Pretending the two are interchangeable means either over-engineering the contractual cases or under-protecting the structural ones. **What this rules out.** Proposing a "TMP-verified direct-sold targeting" capability that assumes TMP's privacy guarantees apply to direct-sold ad-server decisioning. They don't — TMP wasn't designed as the GAM/FreeWheel decisioning hook, and saying "no ad server supports TMP for direct-sold" is true but slightly misleading because that's not the layer TMP operates at. The honest framing is different: AdCP has no protocol-level mechanism to declare whether a seller can enforce signal-based targeting on guaranteed line items. That's a `signals.features` flag plus a [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) validation rule — a contractual-disclosure feature, not a structural-privacy one. **When you'd be right to push.** When you have a cross-domain privacy claim and you can name which mechanism applies. "This is a contractual feature, here's what's in the contract" or "this is a structural feature, here's the schema-level prohibition that makes it work." Vague privacy improvements tend to land on neither and end up in the wrong place. → See [Architecture — Privacy posture across domains](/dist/docs/3.1.20/protocol/architecture#privacy-posture-across-domains) for the per-domain mechanism table. *** ### 7. The protocol exposes seams; deployers wire decisions AdCP defines what fields exist and what guarantees the protocol makes about them. It does not define how a deployer's policy must be enforced, how a governance agent must decide, or what counts as a "good" buyer. This stance shows up in three places, and they share a single posture: **the protocol is realistic about how decisions actually happen — distributed across parties, asynchronous in time, human-checkable in process.** * *Capabilities are declared, not gated.* `check_governance` is a seam, not an enforcer. A seller that hasn't configured a governance agent will not call it; the protocol doesn't prevent a non-conformant seller from transacting. Schema-level enforcement exists but is rare and named (`fair_housing`, `fair_lending`, `fair_employment` in 3.0); the default is exposure, not coercion. * *Async is the default; sync is the optimization.* Every mutating task has `*-async-response-{submitted,working,input-required}.json` siblings. A protocol that pretends every operation is synchronous and atomic is one that breaks the moment a real workflow lands. * *Human review is architectural, not exceptional.* Any mutation can be taken async for human approval via the [task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle); campaign governance provides a declarative buyer-side review channel. HITL composes with everything else — audit logs, governance checks, async responses — rather than being a special case bolted onto certain tasks. A related invariant lives at the account boundary: **account ownership, not creation surface, determines visibility.** Buys created outside AdCP still belong to the account they fall under, and surface in account-scoped queries. This prevents the shadow-ledger pattern that breaks every "API on top of an ad server." **Why we chose it.** A protocol that ships policy is a platform with extra steps. The credibility of an open standard is exactly its restraint about what it tries to decide. Deployer policy is also where context-specific judgment belongs — what's a brand-safety violation in one vertical is fine in another, and AdCP can't carry that nuance without picking sides. **What this rules out.** "The protocol should reject buys that don't match capability X." Most of the time the right answer is "the protocol should make capability X visible, and the deployer rejects the buy." Also: framing autonomy and oversight as opposites — "for agentic to truly work, X must be automated end-to-end." For most regulated and high-stakes operations, that's not what we want, and it's not what the protocol assumes. **When you'd be right to push.** When the asymmetry is bad enough that a per-deployer policy doesn't solve the coordination problem — when buyers and sellers can't economically agree on enforcement without a protocol-level rule. Schema-level enforcement is rare and earned; argue for it when you have it, not as the first move. → See [Trust & Security — Governance](/dist/docs/3.1.20/trust#governance) for what AdCP provides versus what it explicitly does not, and [Governance — Embedded human judgment](/dist/docs/3.1.20/governance/embedded-human-judgment) for how HITL surfaces in the protocol. *** ### 8. Reference data: own the namespace and the resolution contract, not the corpus AdCP defines the **namespace** (which classification systems exist and the shape of their IDs) and the **resolution contract** (resolve a name against a declared source rather than infer an identifier from a model's training data, and surface ambiguity rather than pick one). It does not publish the underlying code↔name tables — the **corpus** — unless AdCP is itself the authority that mints the values. A code is identity and travels losslessly on the wire; a name is a *rendering* of that code, reconstituted at the edge by whoever displays it, from a lookup they already hold. **Why we chose it.** The durable asset is the contract, not the corpus. Publishing someone else's reference table makes the registry a downstream mirror that has to track an external publisher forever — owning its correctness, its refresh cadence, and its license exposure, with no ops function behind any of it. And a wrong-but-canonical table is worse than no table: agents hallucinate, and they trust an authority stamp precisely when they shouldn't, so a stale or fabricated entry misdirects real spend silently. The name doesn't need to travel or be centralized — it's a dimension attribute, replicated to the edge once and joined locally, not denormalized onto every request and not fetched from a central service per lookup. **What this rules out.** A canonical geo-metro registry — a versioned DMA `code → name` table the ecosystem fetches as source of truth (proposed and declined). The proposal's first commit shipped fabricated codes numbered sequentially by market rank — contradicting both the source authority and this repo's own examples — and only a human reviewer caught it. There is no automated authority check, so that error class recurs. The names are also a proprietary, trademarked corpus the project has no license to redistribute. The protocol already handles the read direction correctly: `geo-delivery-metrics` carries an optional `geo_name` beside the required `geo_code`, so a seller *can* render from its own catalog rather than force the buyer to reconstruct a name from a bare code. It equally rules out the opposite over-correction — stapling display names onto targeting values as authoritative. "Albany" names two different DMAs (NY and GA); targeting values are IDs in a declared namespace, and a name rides along only as a non-authoritative label, if at all. **When you'd be right to push.** When AdCP itself mints the values, owning the table is interop, not data-ops — `v1-canonical-mapping.json` is the legitimate example: AdCP defined both the v1 format vocabulary and the v2 canonical it projects to, the set is additive (entries are never removed, only deprecated), and there is no external rights-holder. And when a single external authority publishes openly (ISO 3166, Eurostat NUTS, ONS ITL, GeoNames under CC-BY), the project *may* ship a thin **generated** mirror with provenance and a regeneration script — a projection, explicitly non-authoritative, demoted the moment the source drifts or a second source disagrees. The bar: name the authority, name the license, and explain why a fetch-once-cache-locally lookup doesn't already solve it. **Reviewer test.** Is there a single external authority for these values, and is it us? If an external body owns the truth, AdCP declares the namespace and the resolution contract and defers the bytes. "Everyone re-implements this annoying mapping" is a library opportunity, not a protocol mandate. → The same anti-centralization instinct as principle 5, applied to reference data instead of trust: the [Registry](/dist/docs/3.1.20/registry) helps resolve and discover, it does not become the ecosystem's lookup table. *** ## Where the surface doesn't yet follow these principles The principles are credible to the extent we name where the surface still violates them. These are known and tracked. **`media_buy.execution.trusted_match` (principle 4).** The capabilities schema places TMP-related flags inside `media_buy.execution.trusted_match`, while the architecture page treats TMP as a peer transaction domain alongside Media Buy, Creative, and Signals. A reviewer using these principles to evaluate proposals lands in conflicting answers about where TMP-shaped flags belong. The schema sub-namespacing was right (per principle 4); the parent location is the open question. RFCs proposing changes to TMP capability declarations should expect this to be on the table. **`axe_integrations` and `axe_include_segment` / `axe_exclude_segment` (principles 1 and the [vendor-neutral rule in spec-guidelines](/dist/docs/3.1.20/spec-guidelines#platform-agnosticism)).** These survive in the v3 schema as normative fields. AXE is a Scope3-originated brand. By the spec-guidelines test, these should have moved to `ext.axe` or replaced cleanly by `trusted_match` before 3.0 GA. They reflect a deprecation in progress, not a stable shape — proposals that build on these fields should expect them to be moved. **Three top-level keys for "things this agent does that aren't a domain" (principle 4).** `compliance_testing`, `experimental_features`, and `extensions_supported` each carry related-but-shaped-differently capability metadata at the top level. A reviewer would ask why these aren't unified. **Three signing-related top-level keys (principle 4).** `request_signing`, `webhook_signing`, and `identity` (operator JWKS) are all signing infrastructure metadata. Three top-level keys for one concern is exactly the "scannable top level" cost the principle warns about. **The trust substrate (principle 5).** Trust in 3.x is trust-on-first-use, rooted in each counterparty's `/.well-known/` + DNS + CDN. Key transparency is deferred to 4.0. ads.txt and sellers.json have been gamed by exactly this attack class for years. The principle is right; the substrate isn't yet what the principle deserves. See [Trust & Security](/dist/docs/3.1.20/trust) for the explicit gap statement. These are the surface contradictions a sharp third-party reviewer flags on a first read. The honest answer is that some are deprecation in progress, some are unresolved, and at least one (the trust substrate) is the load-bearing 4.0 work. Naming them here keeps the principles credible. *** ## How to use this page If you're drafting an RFC, work through the principles before you write the title. Most proposals that get bounced get bounced on one of the first two — proposing a field that doesn't exist (principle 1) or proposing a new task for a behavior already expressible via existing tasks plus modes (principle 2). The [Capabilities explorer](/dist/docs/3.1.20/protocol/capabilities-explorer) renders the actual schema tree; check it before you propose a new top-level key. If you're reviewing an RFC, the principles double as a triage filter. A proposal that names which principle it's pushing back on, and why, is doing the work; a proposal that doesn't recognize a principle applies usually needs a coverage-leading reply before any drafting happens. The principles aren't immutable. Each one was chosen against a specific trade-off, and each is up for renegotiation when the trade-off shifts. But the renegotiation is the work of an RFC, not the side effect of one — argue for changing the rule explicitly, with the cost of the change named, before proposing the change that depends on it. # Format References Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/format-references Normative reference for format_id (structured identifier object) vs format (full definition object) in AdCP. AdCP uses two related but distinct concepts when working with creative formats. Their names are similar, which causes a recurring implementation error — this page defines both precisely and names the two failure modes. ## format\_id — structured reference object `format_id` is **always a JSON object**, never a plain string. It identifies a format by the agent that declared it and the format's local slug: ```json theme={null} { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } ``` Optional dimension and duration fields extend it for parameterized template formats: ```json theme={null} { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 300, "height": 250 } ``` `format_id` is a **pointer** — it names a format without carrying its definition. ## format — full definition object `format` is the complete specification of a creative format. It is returned by `list_creative_formats` and related tasks. A `format` object contains a `format_id` as one of its properties, plus the asset requirements, render specs, and all other metadata: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "name": "Display Banner 300×250", "assets": [ { "asset_id": "image", "asset_type": "image", "item_type": "individual", "required": true } ], "renders": [ { "role": "primary", "dimensions": { "width": 300, "height": 250 } } ] } ``` A `format` is a **definition** — it describes what the format requires and how it renders. ## Contrast at a glance | Concept | Field name | JSON type | Use | | -------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------ | | Format identifier | `format_id` | `object` — `{ agent_url, id }` | Pointer. Used in creative manifests, request filters, placement declarations. | | Format definition | `format` | `object` — full spec | Returned by `list_creative_formats`. Contains `format_id` as a nested field. | | Array of identifiers | `format_ids` | `array` of `format_id` objects | Filter parameter on `list_creatives`, `list_creative_formats`, and related requests. | | Array of definitions | `formats` | `array` of `format` objects | Response field on `list_creative_formats`. | ## Two named failure modes ### Anti-pattern A — string in a `format_id` slot Wrong — `format_id` must be an object, not a plain string: ```json theme={null} { "format_id": "display_300x250" } ``` Correct: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } } ``` AJV error: `format_id must be of type object`. Cause: the `.id` string inside the `format_id` object looks like a self-contained name and is sometimes extracted and used directly. ### Anti-pattern B — format\_id object in a `format` / `formats` slot Wrong — a `formats[]` element must be a full definition object, not a bare `format_id`: ```json theme={null} { "formats": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } ] } ``` Correct: ```json theme={null} { "formats": [ { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "name": "Display Banner 300×250", "assets": [ { "asset_id": "image", "asset_type": "image", "item_type": "individual", "required": true } ] } ] } ``` AJV error: `formats[0] must have required property 'name'`. Cause: `format_id` and `format` are both objects; the shorter object is sometimes put in the slot that expects the larger one. ## See also * [Creative Formats](/dist/docs/3.1.20/creative/formats) — full format object structure, asset types, and render specs * [Template Format IDs](/dist/docs/3.1.20/creative/template-format-ids) — parameterized format IDs for dimension-variable templates # get_adcp_capabilities Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/get_adcp_capabilities get_adcp_capabilities is the first call a buyer makes to discover an AdCP seller's supported protocols, auth model, version, and feature capabilities. Request and response schema reference. Discover a seller's protocol support and capabilities across all AdCP protocols. This is the first call a buyer should make to understand what a seller supports. **Why this shape.** Capabilities are organized into \~14 top-level domain keys (one per protocol plus identity and signing infrastructure), with feature flags nested under each domain's `features`/`execution`/etc. sub-namespace. We rejected a flat capability list — it forces every implementer to scan an unbounded surface, and it removes the discoverability that comes from related flags sitting next to each other. New capability flags belong under existing domains, not in new top-level keys; declarations are commitments, not advertisements (the conformance runner probes them). → [Capabilities explorer](/dist/docs/3.1.20/protocol/capabilities-explorer) walks the tree before you propose. → [Design principle: capabilities are commitments](/dist/docs/3.1.20/protocol/design-principles#4-capabilities-are-commitments-declared-under-existing-buckets). **Response Time**: \~2 seconds (configuration lookup) **Purpose**: * **AdCP discovery** - Does this agent support AdCP? Which versions? * **Protocol support** - Which protocols (media\_buy, signals, governance, sponsored\_intelligence, creative, brand)? * **Auth model** - Does this seller trust the agent directly, or must each operator authenticate independently? * **Detailed capabilities** - Features, execution integrations, geo targeting, portfolio **Per-caller authorization is NOT reported here.** `get_adcp_capabilities` returns the seller's surface — everything it *could* do for any authorized caller. To discover what *you* are allowed to do on a specific account (which tasks are callable for your identity, which request fields are modifiable, any named scope like `attestation_verifier`), read the `authorization` object on per-account entries in [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts) and [`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts) responses. See [Caller authorization](/dist/docs/3.1.20/accounts/overview#caller-authorization) for the full shape and semantics. **Request Schema**: [`/schemas/3.1.20/protocol/get-adcp-capabilities-request.json`](https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-request.json) **Response Schema**: [`/schemas/3.1.20/protocol/get-adcp-capabilities-response.json`](https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json) ## Tool-Based Discovery AdCP uses native MCP/A2A tool discovery. **The presence of `get_adcp_capabilities` in an agent's tool list indicates AdCP support.** ``` Discovery Flow: 1. Browse agent's tool list (MCP) or skills (A2A) 2. See 'get_adcp_capabilities' tool → Agent supports AdCP 3. Call get_adcp_capabilities → Get version, protocols, features, capabilities 4. Proceed based on returned capabilities ``` This approach: * Uses standard MCP/A2A mechanisms (no custom extensions) * Always returns current capabilities (not stale metadata) * Single source of truth for all capability information :::note The agent card extension (`adcp-extension.json`) has been removed in v3. Use tool-based discovery instead. ::: ## Version Negotiation Sellers declare which major versions they support via `adcp.major_versions` in the response. Buyers declare which version they're using via `adcp_major_version` on the request. ``` Version Negotiation Flow: 1. Buyer calls get_adcp_capabilities with adcp_major_version: 2 2. Seller checks 2 against its major_versions: [2, 3] 3. Version is supported → seller returns capabilities for v2 4. Buyer includes adcp_major_version: 2 on all subsequent requests ``` `adcp_major_version` is an optional field on every AdCP request schema. Buyers SHOULD include it on all requests when interacting with a multi-version seller. **Seller behavior:** * If `adcp_major_version` is provided and supported → respond using that version's schemas * If `adcp_major_version` is provided but unsupported → return `VERSION_UNSUPPORTED` (buyer should call without `adcp_major_version` to discover supported versions) * If `adcp_major_version` is omitted → assume the highest supported version **Why major versions, not minor?** Semver policy guarantees backward compatibility within a major version. A seller at 3.1 can serve a buyer at 3.0 without negotiation. The capability model handles feature-level differences — buyers check specific capabilities (targeting systems, features, extensions) rather than version numbers to determine compatibility. ## Request Parameters | Field | Type | Description | | -------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `adcp_major_version` | integer | Optional. The AdCP major version the buyer's payloads conform to. When provided, the seller validates against its `major_versions` and returns `VERSION_UNSUPPORTED` if not in range. When omitted, the seller assumes the highest major version it supports. | | `protocols` | string\[] | Optional. Filter to specific protocols (`media_buy`, `signals`, `governance`, `sponsored_intelligence`, `creative`, `brand`). If omitted, returns all supported protocols. | ## Response Structure ### adcp Core AdCP protocol information: | Field | Type | Description | | ---------------- | ---------- | --------------------------------------------------------------------- | | `major_versions` | integer\[] | **Required.** AdCP major versions supported (e.g., `[3]`) | | `idempotency` | object | **Required.** Idempotency semantics. See [idempotency](#idempotency). | #### idempotency Declares whether this seller honors `idempotency_key` replay protection. From 3.1 onward `idempotency_key` is required on every AdCP task request (read and mutating alike — staged enforcement for reads: SHOULD-reject in 3.1, MUST-reject in 3.2; see [security.mdx § Idempotency](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency)). Mirrors the `request_signing.supported` pattern — a single positive declaration, decoupled from the window detail. Clients MUST NOT assume a default; a seller without this block is non-compliant and should be treated as unsafe for retry-sensitive operations across all call modes. | Field | Type | Description | | -------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supported` | boolean | **Required.** Whether the seller deduplicates replays. When `false`, sending an `idempotency_key` is a no-op — the seller will NOT return `IDEMPOTENCY_CONFLICT` or `IDEMPOTENCY_EXPIRED`, and a naive retry WILL double-process. Buyers MUST use natural-key checks (e.g., `get_media_buys` plus request context such as `context.internal_campaign_id` or package context such as `context.buyer_ref`) before retrying spend-committing operations. | | `replay_ttl_seconds` | integer | Required when `supported: true`. How long the seller retains a canonical response for a key. Minimum `3600` (1h); recommended `86400` (24h); maximum `604800` (7d). | ```json theme={null} { "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } } } ``` Sellers that do not support replay dedup declare it explicitly: ```json theme={null} { "adcp": { "major_versions": [3], "idempotency": { "supported": false } } } ``` **Verifying the declaration.** `idempotency.supported: true` is a trust-bearing claim that enables buyers to safely retry spend-committing operations. A compromised or buggy seller could advertise `true` while silently ignoring keys, causing buyer double-spend on retry. Buyers and conformance runners SHOULD probe the declaration with a deliberate payload-mutation replay: send two requests with the same `idempotency_key` but different canonical payloads — a conformant seller MUST return `IDEMPOTENCY_CONFLICT` on the second. Sellers declaring `supported: true` MUST pass this probe as part of the baseline compliance storyboard before the declaration is considered verified. ### supported\_protocols AdCP protocols this agent supports. This is the single capability axis — each value both (a) declares which tools the agent implements *and* (b) commits the agent to pass the baseline compliance storyboard at `/compliance/{version}/protocols/{protocol}/`. The runner maps JSON snake\_case → URL kebab-case (`media_buy` → `/compliance/.../protocols/media-buy/`). ```json theme={null} { "supported_protocols": ["media_buy", "creative"] } ``` Valid values: `media_buy`, `creative`, `signals`, `governance`, `brand`, `sponsored_intelligence`. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for every protocol's scope. Support for the [compliance test controller](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller) is declared via the separate `compliance_testing` capability block (below), not as a protocol value. ### specialisms Optional specialization claims. Each entry corresponds to a narrow storyboard at `/compliance/{version}/specialisms/{id}/`. Every specialism rolls up to one protocol in `supported_protocols` — claiming `sales-guaranteed` requires `media_buy`. The runner rejects a specialism whose parent protocol is missing. ```json theme={null} { "specialisms": ["sales-guaranteed", "creative-template"] } ``` See the full [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) for every specialism and the [enum schema](https://adcontextprotocol.org/schemas/3.1.20/enums/specialism.json) for the authoritative list. ### Capability slot gaps SDK helpers such as `definePlatform` can project a platform implementation into narrower capability slots. Treat those slots as commitments: only declare a slot when the agent can execute the corresponding task path end to end. If a storyboard or local test vector targets a slot the agent does not declare, the expected conformance outcome is `not_applicable`, not failure. Until runner-side enforcement lands in `adcp-client#2244`, implementers running custom or prerelease suites should add explicit skip gates for vectors outside the declared slot scope. Do not work around a missing slot by declaring it and returning placeholder responses; that turns an honest coverage gap into a failed capability claim. ### account Account and authentication capabilities. All sellers should declare this section — buyers read it before calling `sync_accounts`, `list_accounts`, or any authenticated task. Even simple publishers need account management to handle billing relationships and sandbox testing. | Field | Type | Description | | ------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supported_billing` | string\[] | **Required.** Billing models this seller supports: `operator`, `agent`. The buyer must pass one of these values as `billing` in every `sync_accounts` entry. | | `require_operator_auth` | boolean | Default: `false`. Declares who must authenticate; it does not by itself declare whether OAuth is used, whether `list_accounts` is exposed, or which `sync_accounts` modes are supported. When `true`, each operator authenticates independently and account-scoped calls use seller-assigned `account_id` values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose `list_accounts` and buyers MUST resolve an explicit `account_id` before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose `list_accounts` returning that singleton; a seller MAY omit `list_accounts` only when it provides the same explicit `account_id` through another declared path or out-of-band onboarding. When `false`, the agent is trusted, buyer declares accounts via `sync_accounts`, and subsequent calls pass the natural key (`brand` + `operator`). For sandbox, account-id namespaces use pre-existing test accounts from `list_accounts` or out-of-band setup; buyer-declared accounts use `sync_accounts` with `sandbox: true`. | | `authorization_endpoint` | string | OAuth URL for operator authentication. Present when the seller supports OAuth for operator authentication. Relevant when `require_operator_auth: true`; if absent, operators obtain credentials out-of-band (seller portal, API key). | | `required_for_products` | boolean | Default: `false`. When `true`, the buyer must establish an account before calling `get_products`. When `false`, the buyer can browse products without an account — useful for price comparison and discovery before committing to a seller. | | `account_financials` | boolean | Default: `false`. When `true`, the seller supports [`get_account_financials`](/dist/docs/3.1.20/accounts/tasks/get_account_financials) for querying spend, credit, and invoice status. Only applicable to operator-billed accounts. | | `sandbox` | boolean | Default: `false`. Strongly recommended for production sales agents. When `true`, the seller supports sandbox accounts for testing. For sandbox, account-id namespaces discover pre-existing test accounts through `list_accounts` or out-of-band setup; buyer-declared accounts declare sandbox via `sync_accounts` with `sandbox: true` — no real platform calls or spend. See [Sandbox mode](/dist/docs/3.1.20/media-buy/advanced-topics/sandbox). | #### Auth models **Buyer-declared accounts** (`require_operator_auth: false`) — The seller trusts the agent's identity claims. The agent authenticates once with its own bearer token, then calls `sync_accounts` to declare which brands and operators it represents. The seller provisions accounts based on the agent's claims, optionally verifying operators against `brand.json`. All subsequent calls use the agent's single credential and pass natural keys (`brand` + `operator`). **Account-id namespaces** (`require_operator_auth: true`) — Each operator must authenticate with the seller directly. The agent obtains a credential per operator — via OAuth using `authorization_endpoint`, or out-of-band — opens a per-operator session, and passes seller-assigned `account_id` values on subsequent requests. OAuth is credential acquisition, not an account taxonomy axis. Two namespace patterns use the same wire reference: upstream-managed sellers expose `list_accounts`, making explicit account resolution mandatory before account-scoped calls; seller-defined namespaces without `list_accounts` provide account IDs out-of-band. SDKs SHOULD lazily call `list_accounts` when first needed, auto-select a singleton, cache it per credential/session, and still send explicit `AccountRef` values on required-account calls. For sandbox, the path follows the account namespace: account-id namespaces discover pre-existing test accounts via `list_accounts` or out-of-band setup; buyer-declared accounts declare sandbox via `sync_accounts` with `sandbox: true`. See [Accounts and Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#what-sellers-declare) for full workflows and [seller patterns](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#seller-patterns) for common combinations of auth model and billing support. ### media\_buy Media-buy protocol capabilities. Only present if `media_buy` is in `supported_protocols`. Sellers declaring `media_buy` should also include `account` (with `supported_billing`) and `media_buy.portfolio` — buyers need both to establish billing and understand inventory coverage. Compliance testing validates their presence. :::note 3.0 breaking changes The following fields have been removed from the capabilities response: * `media_buy.reporting` — Reporting is implied by `media_buy`. Use product-level `reporting_capabilities` instead. * `features.content_standards` — Replaced by `media_buy.content_standards` object. Presence of the object indicates support. * `features.audience_targeting` — Replaced by `media_buy.audience_targeting` object. Presence of the object indicates support. * `features.conversion_tracking` — Replaced by `media_buy.conversion_tracking` object. Presence of the object indicates support. * `execution.targeting.device_platform`, `device_type` — Implied by `media_buy` support. * `execution.targeting.audience_include`, `audience_exclude` — Implied by `audience_targeting` object presence. * `execution.trusted_match.supported` — Object presence indicates support. * `brand.identity` — Implied by `brand` in `supported_protocols`. `get_brand_identity` is always available. ::: #### reporting\_delivery\_methods Declares which push-based delivery methods are available across the seller's product portfolio. Polling via `get_media_buy_delivery` is a required task for all `media_buy` sellers regardless of this field. | Method | Description | Configuration | | --------- | --------------------------------------------------- | -------------------------------------------------- | | `webhook` | Seller pushes to buyer-provided URL | Buyer configures `reporting_webhook` per media buy | | `offline` | Seller pushes batch files to a cloud storage bucket | Seller provisions `reporting_bucket` per account | When absent, only polling is available. Cadence and metrics are declared per product in `reporting_capabilities`. When `offline` is declared, also include `offline_delivery_protocols` to declare which cloud storage protocols are supported (`s3`, `gcs`, `azure_blob`). Buyers express a protocol preference via `preferred_reporting_protocol` in `sync_accounts`; the seller provisions the account's `reporting_bucket` using a supported protocol. For offline delivery, the seller provisions a per-account bucket and grants the buyer read access out-of-band. The bucket location (including `file_retention_days`) appears on the account object returned by `sync_accounts` as `reporting_bucket`. See [Offline File Delivery](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting#offline-file-delivery-based-reporting) for details. #### creative\_approval\_mode Declares the seller's tenant-wide creative approval posture after creatives are assigned and automated validation passes. This is not a notification surface or a new approval workflow; it tells buyers and compliance runners whether human review can still block serving eligibility. Compliance runners use this declaration mainly to decide whether auto-approval-dependent storyboards such as `media_buy_seller/pending_creatives_to_start` apply. | Value | Description | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auto_approve` | Human review does not block serving eligibility after creatives are assigned and automated validation passes. Auto-approval-dependent storyboards can run. | | `require_human` | One or more products/accounts may require manual review before creatives become eligible to serve. Treat this as a tenant-wide worst-case ceiling until a product-level override exists. | Sellers with mixed approval policies SHOULD declare `require_human` unless every product/account that can be reached by the advertised agent supports automatic eligibility after automated validation. When the field is absent, approval behavior is legacy-unspecified; runners SHOULD NOT treat omission as an affirmative `auto_approve` claim. `ai_assisted` is intentionally not a value until the protocol defines what assistance changes in observable behavior. #### features Optional media-buy features. **If declared true, seller MUST honor requests using that feature.** | Feature | Description | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `inline_creative_management` | Accepts creatives inline in `create_media_buy` and `update_media_buy` package payloads | | `property_list_filtering` | Legacy 3.1 property-list support flag: honors `property_list` in `get_products` and `targeting_overlay.property_list` in media-buy create/update requests | | `catalog_management` | Supports `sync_catalogs` for catalog feed management | #### content\_standards Content standards implementation details. Presence of this object indicates the seller supports content\_standards configuration including sampling rates and category filtering. Gives buyers pre-buy visibility into local evaluation and artifact delivery capabilities. | Field | Type | Description | | --------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supports_local_evaluation` | boolean | Whether the seller runs a local evaluation model. When `false`, `local_verdict` will always be `unevaluated` and the `failures_only` filter on `get_media_buy_artifacts` is not useful. | | `supported_channels` | string\[] | Channels for which the seller can provide content artifacts. Helps buyers understand which parts of a mixed-channel buy will have content standards coverage. | | `supports_webhook_delivery` | boolean | Whether the seller supports push-based artifact delivery via `artifact_webhook` configured at buy creation time. | **Example:** ```json theme={null} { "content_standards": { "supports_local_evaluation": true, "supported_channels": ["display", "olv", "podcast"], "supports_webhook_delivery": true } } ``` If `supports_local_evaluation` is `false`, the `failures_only` filter on `get_media_buy_artifacts` will return an empty result set — all verdicts will be `unevaluated`. #### execution Technical execution capabilities: | Field | Type | Description | | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `trusted_match` | object | [TMP](/dist/docs/3.1.20/trusted-match) support. When present, this seller supports real-time contextual and/or identity matching. Check individual products for per-product TMP capabilities. | | `axe_integrations` | string\[] | Deprecated. Legacy AXE URLs this seller can execute through. Use `trusted_match` for new integrations. | | `creative_specs` | object | Creative specification support (VAST versions, MRAID, etc.) | | `targeting` | object | Targeting capabilities (geo granularity) | ##### axe\_integrations `axe_integrations` is an array of Agentic Ad Exchange (AXE) endpoint URLs that this seller can execute through. AXE is the real-time execution layer for AdCP campaigns — it connects buyer agents to programmatic inventory via standardized exchanges. When a seller declares AXE URLs in their capabilities, buyers can: * Route impression-level execution through the declared exchange * Use the exchange's targeting, optimization, and measurement capabilities * Execute alongside the seller's direct-sold inventory Buyers discover AXE support via `get_adcp_capabilities` and filter products to AXE-enabled sellers using `required_axe_integrations` on [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products). ##### creative\_specs | Field | Type | Description | | ---------------- | --------- | ------------------------------------------------------- | | `vast_versions` | string\[] | VAST versions supported (e.g., `["4.0", "4.1", "4.2"]`) | | `mraid_versions` | string\[] | MRAID versions supported | | `vpaid` | boolean | VPAID support | | `simid` | boolean | SIMID support | ##### targeting | Field | Type | Description | | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------- | | `geo_countries` | boolean | Country-level targeting using ISO 3166-1 alpha-2 codes | | `geo_regions` | boolean | Region/state-level targeting using ISO 3166-2 codes (e.g., `US-NY`, `GB-SCT`) | | `geo_metros` | object | Metro area targeting with system-specific support | | `geo_postal_areas` | object | Postal area targeting with country and precision support | | `age_restriction` | object | Age restriction capabilities with `supported` flag and `verification_methods` | | `language` | boolean | Language targeting (ISO 639-1 codes) | | `keyword_targets` | object | Keyword targeting with `supported_match_types` array (`broad`, `phrase`, `exact`). Presence indicates support. | | `negative_keywords` | object | Negative keyword targeting with `supported_match_types` array. Presence indicates support. | | `geo_proximity` | object | Proximity targeting from arbitrary coordinates (see below) | Device platform and device type targeting are implied by `media_buy` support. Audience include/exclude targeting is implied by the presence of the `audience_targeting` capabilities object. Sellers that support a geographic targeting level SHOULD support both inclusion and exclusion at that level. For example, `geo_metros.nielsen_dma: true` SHOULD mean the seller supports both `geo_metros` and `geo_metros_exclude` with Nielsen DMA codes. If a seller only supports one direction (e.g., inclusion but not exclusion), it MUST return a validation error for unsupported fields rather than silently ignoring them. See [Targeting Overlays](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) for exclusion semantics. **geo\_proximity** specifies which proximity targeting methods are supported: | Field | Type | Description | | ----------------- | --------- | --------------------------------------------------------------------------------------------- | | `radius` | boolean | Simple radius targeting (distance circle from a point) | | `travel_time` | boolean | Travel time isochrone targeting (requires a routing engine) | | `geometry` | boolean | Pre-computed GeoJSON geometry (buyer provides the polygon) | | `transport_modes` | string\[] | Transport modes supported for isochrones: `driving`, `walking`, `cycling`, `public_transport` | **geo\_metros** specifies which metro classification systems are supported: | System | Description | | ---------------- | -------------------------------------------------- | | `nielsen_dma` | Nielsen DMA codes (US market, e.g., `501` for NYC) | | `uk_itl1` | UK ITL Level 1 regions | | `uk_itl2` | UK ITL Level 2 regions | | `eurostat_nuts2` | Eurostat NUTS Level 2 regions (EU) | **geo\_postal\_areas** specifies which country-local postal code systems are supported. The preferred shape is keyed by ISO 3166-1 alpha-2 country, with each country listing supported systems: ```json theme={null} { "us_zip": true, "us_zip_plus_four": true, "US": ["zip", "zip_plus_four"], "GB": ["outward", "full"], "CA": ["fsa", "full"], "ZA": ["postal_code"] } ``` Use `postal_code` for the normal postal code string in countries without a more specific registered local system. During the 3.x migration, sellers SHOULD emit equivalent deprecated aliases such as `us_zip` alongside native country keys where an alias exists. Buyers and SDKs SHOULD normalize both shapes before making capability decisions. #### audience\_targeting Audience targeting capabilities. Presence of this object indicates the seller supports audience targeting, including `sync_audiences` and `audience_include`/`audience_exclude` in targeting overlays. Describes what identifier types the seller accepts for audience matching, size constraints, and expected matching latency. | Field | Type | Required | Description | | ------------------------------- | --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supported_identifier_types` | string\[] | **Required** | PII-derived identifier types accepted for audience matching. Buyers should only send identifiers the seller supports. Values: `hashed_email`, `hashed_phone`. | | `minimum_audience_size` | integer | **Required** | Minimum matched audience size required for targeting. Audiences below this threshold will have `status: too_small`. Varies by platform (100–1000 is typical). | | `supports_platform_customer_id` | boolean | | When `true`, the seller accepts the buyer's CRM/loyalty ID as a matchable identifier. Only applicable when the seller operates a closed ecosystem with a shared ID namespace (e.g., a retailer matching against their loyalty program). Buyers can include `platform_customer_id` values in `AudienceMember.identifiers`. Reporting on matched IDs typically requires a clean room or the seller's own reporting surface. | | `supported_uid_types` | string\[] | | Universal ID types accepted for audience matching (MAIDs, RampID, UID2, etc.). MAID support varies significantly by platform — check this field before sending `uids` with `type: maid`. | | `matching_latency_hours` | object | | Expected matching latency range in hours after upload. Use to calibrate polling cadence and set appropriate expectations before configuring `push_notification_config`. Shape: `{ min: integer, max: integer }`. | #### conversion\_tracking Seller-level conversion tracking capabilities. Declares what the seller supports for `kind: "event"` optimization goals. | Field | Type | Description | | ------------------------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `multi_source_event_dedup` | boolean | Whether the seller can deduplicate events across multiple event sources within a single goal. When `true`, the same `event_id` from multiple sources counts once. When `false` or absent, buyers should use a single event source per goal. | | `supported_event_types` | string\[] | Event types this seller can track. If omitted, all standard event types are supported. | | `supported_uid_types` | string\[] | Universal ID types accepted for user matching. | | `supported_hashed_identifiers` | string\[] | Hashed PII types accepted (`hashed_email`, `hashed_phone`). Buyers must hash before sending (SHA-256, normalized). | | `supported_action_sources` | string\[] | Action sources this seller accepts events from. | | `attribution_windows` | object\[] | Available attribution windows. Single-element arrays indicate fixed windows; multi-element arrays indicate configurable options the buyer can choose from via `attribution_window` on optimization goals. | #### portfolio Inventory portfolio information: | Field | Type | Description | | ---------------------- | --------- | ------------------------------------------------------ | | `publisher_domains` | string\[] | **Required.** Publisher domains this seller represents | | `primary_channels` | string\[] | Main advertising channels | | `primary_countries` | string\[] | Main countries (ISO codes) | | `description` | string | Markdown portfolio description | | `advertising_policies` | string | Content policies and restrictions | ### signals Signals protocol capabilities. Only present if `signals` is in `supported_protocols`. | Field | Type | Description | | -------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data_provider_domains` | string\[] | Data provider domains this signals agent is authorized to resell. Buyers should fetch each provider's `adagents.json` for signal definitions and to verify authorization. | | `discovery_modes` | string\[] | Discovery modes the agent supports on `get_signals`. `"brief"` (semantic discovery via `signal_spec` / `signal_refs`, with deprecated `signal_ids` accepted for older clients) is implicit and always supported. Declare `"wholesale"` to advertise that callers can omit `signal_spec` / `signal_refs` / `signal_ids` and enumerate the full priced signals feed. Agents not declaring `"wholesale"` MAY return `INVALID_REQUEST` for wholesale calls. Absent declaration is treated as `["brief"]`. | | `features.catalog_signals` | boolean | **Deprecated.** Legacy wire flag for structured `signal_ref` references to provider-published signal definitions in adagents.json `signals[]`. | `catalog_signals` is deprecated. Existing 3.x agents may continue to emit it for compatibility, but new agents SHOULD omit it and callers MUST NOT require it before using `signal_ref`. Treat `supported_protocols: ["signals"]`, `signals.data_provider_domains`, `signals.discovery_modes`, and the actual `get_signals` response as the capability surface. ### creative Creative protocol capabilities. Only present if `creative` is in `supported_protocols`. | Field | Type | Description | | ----------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supports_compliance` | boolean | When `true`, this creative agent can process briefs with compliance requirements (`required_disclosures`, `prohibited_claims`) and will validate that disclosures can be satisfied by the target format. Use the `disclosure_positions` filter on `list_creative_formats` to find compatible formats. | | `supports_transformers` | boolean | When `true`, this creative agent offers account-scoped transformers — the selectable units of build capability (voices, models, styles) discovered via [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers) and selected with `transformer_id` (plus the typed `config` bag) on [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative). When `false` or absent, the agent does not expose transformers; `list_transformers` is unavailable and `build_creative` ignores `transformer_id`/`config`. Pre-call discriminator for routing across creative agents. | | `supports_refinement` | boolean | When `true`, this creative agent retains produced `build_variant` leaves (for an agent-defined window) and can re-build from one via `refine_from_build_variant_id` on [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) — applying a natural-language instruction in `message` plus an optional `config` delta, returning new lineage-linked variants. A build-time capability independent of generation/transformation. When `false` or absent, `refine_from_build_variant_id` returns `UNSUPPORTED_FEATURE`; refine via the transform path (`creative_manifest` + `message`) instead. | | `refinable_retention_seconds` | integer | When `supports_refinement` is `true`, the **guaranteed-minimum** window (a floor, not a ceiling) during which a produced `build_variant_id` stays refinable via `refine_from_build_variant_id`. A ref within the window SHOULD resolve; the agent MAY retain longer. Omit to leave the window agent-defined (buyers treat refinability as best-effort and handle `REFERENCE_NOT_FOUND`). | | `multiplicity` | object | Pre-call fan-out discriminators so a buyer knows before sending `max_creatives`/`max_variants`: `supports_catalog_fanout` + `max_creatives_limit`, `supports_variants` + `max_variants_limit`, and `variant_dimensions[]` (which `variant_axis.dimension` values are supported). Over-limit requests are **clamped** to the ceilings (shortfall shown via `items_returned` \< `items_total`), not rejected. Absent means no fan-out — `build_creative` produces a single creative. Individual transformers may narrow this via `transformer.multiplicity`. | | `supports_spend_controls` | boolean | When `true`, `build_creative` honors a per-call `max_spend` ceiling (returns a partial paid build with `budget_status: "capped"` + a `BUDGET_CAP_REACHED` advisory rather than overspending) and supports `mode: "estimate"` dry-runs (a projected cost band, producing/billing nothing). When `false` or absent, both are rejected with `UNSUPPORTED_FEATURE`. Meaningful only with `bills_through_adcp: true`. | | `bills_through_adcp` | boolean | When `true`, this creative agent bills through the AdCP rate-card surface — `list_creatives` returns `pricing_options` (with `include_pricing=true` and an authenticated account), `build_creative` populates `pricing_option_id` and `vendor_cost`, and `report_usage` accepts records against the rate card. When `false` or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement); buyers should skip pricing fields and tolerate `report_usage` returning `accepted: 0` with `BILLING_OUT_OF_BAND` errors. Pre-call discriminator for routing across creative agents. | ### governance Governance protocol capabilities. Only present if `governance` is in `supported_protocols`. Governance agents declare capabilities across four domains: property evaluation, creative evaluation, content standards verification, and policy registry integration. #### property\_features Array of property features this governance agent can evaluate. See [Property Governance](/dist/docs/3.1.20/governance/property/index). | Field | Type | Description | | ----------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `feature_id` | string | **Required.** Unique identifier (e.g., `mfa_score`, `coppa_certified`). Use `registry:{policy_id}` prefix for features mapped to [Policy Registry](/dist/docs/3.1.20/governance/policy-registry) entries. | | `type` | string | **Required.** Data type: `binary`, `quantitative`, or `categorical` | | `range` | object | For quantitative: `{ min, max }` | | `categories` | string\[] | For categorical: valid values | | `description` | string | Human-readable description | | `methodology_url` | string | URL to methodology documentation | #### creative\_features Array of creative features this governance agent can evaluate. Same field schema as `property_features`. See [Creative Governance](/dist/docs/3.1.20/governance/creative/index). Creative governance agents evaluate creatives for security, content categorization, and regulatory compliance. Buyers filter creatives by feature requirements — for example, blocking creatives flagged for `auto_redirect` or requiring `registry:eu_ai_act_article_50` compliance. #### content\_standards Content standards verification capabilities. See [Content Standards](/dist/docs/3.1.20/governance/content-standards/index). | Field | Type | Description | | --------------------- | --------- | -------------------------------------------------------------------------------------- | | `supported` | boolean | Whether this agent can serve as a content standards verification agent | | `calibration_formats` | string\[] | Artifact asset types this agent can evaluate (e.g., `text`, `image`, `video`, `audio`) | #### policy\_registry Policy registry integration capabilities. See [Policy Registry](/dist/docs/3.1.20/governance/policy-registry). | Field | Type | Description | | ----------- | --------- | ---------------------------------------------------------------------------------------------------- | | `supported` | boolean | Whether this agent consumes policies from the AdCP Policy Registry | | `domains` | string\[] | Governance domains this agent covers (e.g., `campaign`, `property`, `creative`, `content_standards`) | **Example governance agent response:** ```json theme={null} { "$schema": "/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["governance"], "governance": { "property_features": [ { "feature_id": "mfa_score", "type": "quantitative", "range": { "min": 0, "max": 100 }, "description": "Made For Advertising detection (0=quality content, 100=likely MFA)", "methodology_url": "https://vendor.example.com/methodology/mfa" }, { "feature_id": "coppa_certified", "type": "binary", "description": "COPPA compliance certification" }, { "feature_id": "registry:uk_hfss", "type": "binary", "description": "UK HFSS advertising restrictions compliance" }, { "feature_id": "carbon_score", "type": "quantitative", "range": { "min": 0, "max": 100 }, "description": "Carbon footprint sustainability score", "methodology_url": "https://vendor.example.com/methodology/carbon-score" } ], "creative_features": [ { "feature_id": "registry:eu_ai_act_article_50", "type": "binary", "description": "EU AI Act Article 50 — AI-generated content disclosure" }, { "feature_id": "registry:ca_sb_942", "type": "binary", "description": "California SB 942 — AI transparency compliance" }, { "feature_id": "auto_redirect", "type": "binary", "description": "Detects auto-redirect behavior in creative code" }, { "feature_id": "credential_harvest", "type": "binary", "description": "Detects credential harvesting patterns" } ], "content_standards": { "supported": true, "calibration_formats": ["text", "image", "video"] }, "policy_registry": { "supported": true, "domains": ["campaign", "property", "creative", "content_standards"] } } } ``` ### measurement Experimental measurement protocol capabilities. Only present if `measurement` is in `supported_protocols`; agents implementing it must also list `measurement.core` in `experimental_features`. The `measurement` protocol is currently scoped to `get_adcp_capabilities` for catalog discovery (this block); additional measurement tasks (reporting, attribution, panel queries) and a baseline compliance storyboard land in subsequent minors. The capability block is shipping now so measurement vendors can publish their per-metric catalogs and AAO can crawl them, before the broader task surface is settled. **Scope.** An agent claiming `measurement` computes one or more quantitative metrics about ad delivery, exposure, or effect (impression verification, viewability, IVT, attention, brand lift, incrementality, outcomes, emissions — vendors define the surface in `metrics[]`). Returns metric definitions (this block), not pricing or coverage (negotiated per buy via `measurement_terms`) and not live values (returned per buy via `vendor_metric_values`). Same mechanical model as `compliance_testing` and `webhook_signing`: presence asserts the capability; the block carries the structured payload. Measurement agents publish a per-metric catalog so buyers know which metrics each vendor offers. This is the canonical source of truth — AAO crawls it to populate the federated [measurement-vendor index](/dist/docs/3.1.20/registry/index#measurement-vendor-discovery). #### metrics Array of metrics this measurement agent computes. | Field | Type | Description | | --------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `metric_id` | string | **Required.** Vendor-scoped identifier (`attention_units`, `gco2e_per_impression`, etc.) — matches the `metric_id` populated in `vendor_metric_values` on delivery. The full identity is the tuple `(vendor.domain, vendor.brand_id, metric_id)`. Each `metric_id` MUST be unique within a single agent's catalog. | | `standard_reference` | string (URI) | Optional URI pointing at the published standard this metric **implements** (IAB Attention Measurement Guidelines, MRC Viewable Impression Measurement, GARM emissions framework). Distinct from `accreditations` below — `standard_reference` is what the metric is built against; `accreditations` is third-party certification of conformance. | | `accreditations` | object\[] | Optional list of third-party accreditations this metric holds (MRC, ARF, JIC bodies, ABC, BARB, AGOF, etc.). Each entry: `accrediting_body` (required), optional `certification_id`, `valid_until`, `evidence_url`. Buyers asking "is this MRC-accredited?" SHOULD check this array — implementing a standard is not the same as being independently accredited. | | `unit` | string | Unit of the value when reported in `vendor_metric_values.value` (`score`, `seconds`, `persons`, `gCO2e`, `lift_percent`, `USD`, etc.). Sellers populating `vendor_metric_values.unit` MUST match this declaration. | | `description` | string | Human-readable description of what the metric measures and any methodology notes. AAO and buyer agents normalize across catalogs from this field plus the structured fields above; classification facets are not declared at the metric level. | | `methodology_url` | string (URI) | URL to vendor's full methodology documentation. Mirrors `governance.property_features[].methodology_url`. | | `methodology_version` | string | Optional version identifier (semver, ISO date, or vendor-defined string) for the methodology. When present, buyer agents can pin a contracted version so silent methodology changes are detectable; absence means the vendor does not version their methodology and buyers MUST treat any change as untracked. | | `ext` | object | Vendor extensions per the AdCP `ext` convention. | ```json Response example theme={null} { "measurement": { "metrics": [ { "metric_id": "attention_units", "standard_reference": "https://iabtechlab.com/standards/attention-measurement", "accreditations": [ { "accrediting_body": "MRC", "certification_id": "MRC-ATT-2026-001", "valid_until": "2027-12-31", "evidence_url": "https://mediaratingcouncil.org/accreditations/attentionvendor" } ], "unit": "score", "description": "Eye-tracking-based attention score (0-100). Computed from a panel of 25K opted-in households.", "methodology_url": "https://attentionvendor.example/docs/attention-units", "methodology_version": "v2.1" }, { "metric_id": "engagement_seconds", "unit": "seconds", "description": "Active dwell time in seconds, measured via in-content telemetry." } ] } } ``` **Discovery vs. settlement.** Buyers MAY hit a measurement agent's `get_adcp_capabilities` directly to see its current catalog (live, canonical, no staleness), or query AAO's federated index for cross-vendor aggregation. The index trades a TTL refresh window for cross-vendor speed; live calls trade speed for currency. Both are valid — typically planning queries hit the index, settlement/audit queries hit the agent. **This is a discovery surface, not a rate card.** The catalog tells buyers *what* a vendor measures and *what standards/accreditations* back it. Pricing per impression, minimum measurable inventory, attribution windows, geographic coverage, and data-freshness SLAs are negotiated per buy through the seller's `measurement_terms` on `create_media_buy` — not through this catalog. ### compliance\_testing Compliance testing capabilities. The presence of this block declares that the agent supports deterministic testing via [`comply_test_controller`](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller). Omit the block if the agent does not support compliance testing. **Production deployments MUST NOT include this block.** `comply_test_controller` is sandbox-only at the deployment level; advertising the capability on a production endpoint is non-conformant even if dispatch is gated. See [Compliance test controller § Sandbox gating](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller#sandbox-gating). | Field | Type | Description | | ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scenarios` | string\[] | Compliance testing scenarios this agent supports. Values SHOULD include every canonical controller scenario the agent implements, excluding `list_scenarios` because that value is a discovery operation rather than a test capability. Current canonical values include `force_creative_status`, `force_account_status`, `force_media_buy_status`, `force_create_media_buy_arm`, `force_task_completion`, `force_session_status`, `simulate_delivery`, `simulate_budget_spend`, `seed_product`, `seed_pricing_option`, `seed_creative`, `seed_plan`, `seed_media_buy`, `seed_creative_format`, `seed_measurement_catalog`, `query_upstream_traffic`, `query_provenance_audit_observations`, and `force_upstream_unavailable`. Values MAY also include implementation-specific scenarios. Runners MUST treat scenario names as open strings — new scenarios may be added in additive releases. | Storyboard runners check for the `compliance_testing` block before running deterministic testing steps. If the agent does not include the block, controller-dependent storyboard steps cannot be validated. Agents that implement `comply_test_controller` SHOULD include the `compliance_testing` capability block and list supported scenarios. Agents that only support a subset of scenarios (e.g., media buy status but not SI sessions) declare only those scenarios — the runner reports unsupported scenario coverage as skipped or partial rather than as a hidden pass. :::note Compliance testing is sandbox-only at the deployment level — production deployments MUST NOT advertise this block or expose `comply_test_controller` on any surface. `FORBIDDEN` is returned only when an in-sandbox caller passes `params` that reference a non-sandbox account; live-mode probes for the tool by name receive the transport's standard unknown-tool error. See [Sandbox gating](/dist/docs/3.1.20/building/by-layer/L3/comply-test-controller#sandbox-gating). ::: ### webhook\_signing Declares a seller's webhook-signing posture. Any seller whose capability surface advertises mutating-webhook emission — including but not limited to `media_buy.reporting_delivery_methods` containing `webhook`, `media_buy.content_standards.supports_webhook_delivery: true`, or `wholesale_feed_webhooks.supported: true` — MUST include this block with `supported: true`. A seller that emits no webhooks at all MAY omit the block entirely; the absence of both mutating-webhook emission in other capabilities and this block is an unambiguous "does not emit webhooks" posture. Buyers read the block at onboarding to determine which algorithms to expect per the [AdCP webhook-signing profile](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-callbacks). Buyers integrating with a seller that advertises mutating-webhook emission while advertising `supported: false` or omitting this block MUST fail onboarding with a user-actionable error; silent integration with a non-signing-but-webhook-emitting seller is unsafe for any mutating-webhook use case. | Field | Type | Description | | ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `supported` | boolean | **Required when the seller advertises mutating-webhook emission elsewhere in its capability surface.** `true` iff the seller signs outbound webhooks. `false` means the seller emits webhooks but does not sign them; buyers MUST fail onboarding. Sellers that emit no webhooks SHOULD omit the entire block rather than set `supported: false` — `false` is reserved for the unsafe posture of unsigned-webhook emission, not absence-of-webhooks. | | `profile` | string | **Required when `supported: true`.** The profile version string. Currently `"adcp/webhook-signing/v1"`. Future versions bump the string. | | `algorithms` | string\[] | **Required when `supported: true`.** Subset of `["ed25519", "ecdsa-p256-sha256"]` — the algorithms this seller will sign webhooks with. Matches the webhook-signing verifier allowlist (see [Verifier checklist for webhooks](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-callbacks), step 4). Buyers MUST be prepared to verify any algorithm listed AND MUST reject onboarding with a user-actionable error if an advertised algorithm is outside this enumerated set — a seller advertising an out-of-set algorithm (e.g., `hs256`) is either misconfigured or signalling a non-conforming profile. | | `legacy_hmac_fallback` | boolean | **Required when `supported: true`.** `true` iff the seller supports the legacy HMAC-SHA256 scheme when the buyer populates `push_notification_config.authentication.credentials` or `accounts[].notification_configs[].authentication.credentials`. `false` is the recommended posture in 3.x — the HMAC scheme is removed in AdCP 4.0. | **Example:** ```json theme={null} { "webhook_signing": { "supported": true, "profile": "adcp/webhook-signing/v1", "algorithms": ["ed25519", "ecdsa-p256-sha256"], "legacy_hmac_fallback": false } } ``` The webhook-signing block is parallel to `request_signing` (inbound) and the two blocks cover the two signing directions between buyer and seller. Buyers SHOULD validate both at onboarding; a seller that signs one direction but not the other has a lopsided security posture that operators need to notice explicitly. ### extensions\_supported Array of extension namespaces this agent supports. Buyers can expect meaningful data in `ext.{namespace}` fields on responses from this agent. | Field | Type | Description | | ---------------------- | --------- | ----------------------------------------------------- | | `extensions_supported` | string\[] | Extension namespaces (e.g., `["iab_tcf", "iab_gpp"]`) | Extension schemas are published in the [AdCP extension registry](/dist/docs/3.1.20/building/by-layer/L2/context-sessions#extensions). When an agent declares support for an extension, buyers know to look for and process `ext.{namespace}` data in responses. **Example:** ```json theme={null} { "extensions_supported": ["iab_tcf", "iab_gpp", "acmecorp"] } ``` This tells buyers: * Responses may include `ext.iab_tcf` with IAB TCF consent data * Responses may include `ext.iab_gpp` with IAB GPP (Global Privacy Platform) signals * Responses may include `ext.acmecorp` with vendor-specific data from Acme Corp ### experimental\_features Array of experimental AdCP surfaces this agent implements. A surface is experimental when its schema carries `x-status: experimental` — it is part of the core protocol but not yet frozen and may break between 3.x releases with 6 weeks' notice. Sellers that implement any experimental surface MUST list its feature id here. | Field | Type | Description | | ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `experimental_features` | string\[] | Experimental feature ids (e.g., `["brand.rights_lifecycle", "governance.campaign", "measurement.core", "trusted_match.core", "sponsored_intelligence.core"]`) | Buyers should inspect `experimental_features` before relying on an experimental surface. A seller that does not list a surface is asserting it does not implement it — there is no "silently experimental" mode. **Example:** ```json theme={null} { "experimental_features": ["brand.rights_lifecycle", "measurement.core", "trusted_match.core"] } ``` See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full stability contract, graduation criteria, and client guidance. ### wholesale\_feed\_versioning Conditional-fetch token capabilities for [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products#wholesale-feed-versioning) and [`get_signals`](/dist/docs/3.1.20/signals/tasks/get_signals#wholesale-feed-versioning). Independent of wholesale feed webhooks: an agent MAY support cheap version probes without pushing change payloads, and an agent MAY push change payloads while still requiring reconciliation reads for repair. | Field | Type | Description | | -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supported` | boolean | **Required.** Whether the agent returns `wholesale_feed_version` on responses and honors `if_wholesale_feed_version` on requests. When absent or `false`, buyers can still probe by reading the response (the field-presence detection path) but can't pre-flight-decide which agents to cache versions for. | | `pricing_version_separate` | boolean | Whether the agent tracks `pricing_version` independently of `wholesale_feed_version`. When `true`, callers can send `if_pricing_version` alongside `if_wholesale_feed_version` for finer-grained "did prices change?" probes. When `false` or absent, the agent collapses both into `wholesale_feed_version` — sending `if_pricing_version` is wasted bytes. | | `cache_scope_account` | boolean | Whether the agent ever returns `cache_scope: "account"` (i.e., publishes per-account overlays distinct from the public rate card). When `true`, buyers MUST be prepared to maintain account-overlay caches alongside the public layer. When `false` or absent, all responses are `cache_scope: "public"` — the agent's rate card is universal across accounts. Note: declaring `true` advertises that the agent runs custom-pricing deals, which is itself a small market-posture signal; agents preferring confidentiality MAY omit the field and detect-on-call instead. | **Example:** ```json theme={null} { "wholesale_feed_versioning": { "supported": true, "pricing_version_separate": true, "cache_scope_account": true } } ``` ### wholesale\_feed\_webhooks Per-agent wholesale product-feed and wholesale signals-feed webhook capabilities. Declared by sales agents (products) and signals agents (signals). When `supported` is `true`, consumers can register `sync_accounts.accounts[].notification_configs[]` entries for `product.*`, `signal.*`, and `wholesale_feed.bulk_change` events and receive the actual change payload in each webhook. See `specs/wholesale-feed-webhooks.md` for the full spec. **Terminology.** Here "wholesale feed" means the agent's buyable wholesale product feed and wholesale signals feed exposed by `get_products` and `get_signals`. It is distinct from `sync_catalogs`, which pushes buyer-provided campaign input feeds into a seller account for campaign execution. Complementary to (and independent of) [`wholesale_feed_versioning`](#wholesale_feed_versioning): webhooks push changed products/signals or bulk-change summaries; version tokens give a cheap repair and reconciliation probe. Adopters MAY ship either, both, or neither. Agents that declare `supported: true` MUST apply the same account/caller authorization and scope predicate used by the corresponding wholesale read before emitting each webhook. A caller that could not see a product, signal, price, or account overlay through `get_products buying_mode: "wholesale"` or `get_signals discovery_mode: "wholesale"` MUST NOT receive a webhook revealing that change. Agents unable to guarantee per-principal filtering MUST NOT declare support. **Capability consistency.** Agents listing any `product.*` value in `event_types[]` MUST declare and support wholesale `get_products` (`media_buy.buying_modes` includes `"wholesale"`). Agents listing any `signal.*` value MUST declare and support wholesale `get_signals` (`signals.discovery_modes` includes `"wholesale"`). Agents listing `wholesale_feed.bulk_change` MUST have at least one of those wholesale repair paths, and each bulk-change payload's `affected_entity_type` MUST name only a feed family the agent can repair through a declared wholesale read. **Consumer precedence.** When more than one mechanism is declared, consumers SHOULD prefer them in this order: (1) `wholesale_feed_webhooks` when maintaining a long-lived mirror (lowest latency, lowest seller cost); (2) `wholesale_feed_versioning` conditional fetch for occasional polling and webhook repair; (3) wholesale enumeration via `buying_modes` / `discovery_modes` for cold start, `wholesale_feed.bulk_change`, or any missed/distrusted push. | Field | Type | Description | | ------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `supported` | boolean | **Required.** Whether this agent can push wholesale feed change payloads through account-level `sync_accounts.accounts[].notification_configs[]`. When `false` or this stanza is absent, consumers fall back to wholesale polling, optionally with `if_wholesale_feed_version` probes. | | `event_types` | string\[] | Event types this agent can emit. Sales agents emit `product.*` events; signals agents emit `signal.*` events; agents that are both can emit both event families. `product.*` requires wholesale `get_products`; `signal.*` requires wholesale `get_signals`; `wholesale_feed.bulk_change` requires at least one declared wholesale repair path and names one affected feed the consumer repairs by re-reading via `get_products` or `get_signals`. | **Example (sales + signals agent):** ```json theme={null} { "wholesale_feed_webhooks": { "supported": true, "event_types": [ "product.created", "product.updated", "product.priced", "product.removed", "signal.created", "signal.updated", "signal.priced", "signal.removed", "wholesale_feed.bulk_change" ] } } ``` ## The Capability Contract **If a capability is declared, the seller MUST honor it.** * `media_buy.execution.targeting.geo_postal_areas.US` contains `zip` → Buyer can send `{ country: "US", system: "zip", values: [...] }`, seller MUST honor it * `media_buy.execution.targeting.geo_postal_areas.us_zip: true` → Buyer can send the deprecated `{ system: "us_zip", values: [...] }` form, and SDKs can backfill the native `{ country: "US", system: "zip", values: [...] }` form * `media_buy.execution.targeting.geo_postal_areas.ZA` contains `postal_code` → Buyer can send normal South African postal codes, seller MUST honor them * `media_buy.execution.targeting.geo_metros.nielsen_dma: true` → Buyer can send DMA codes, seller MUST honor them * `media_buy.content_standards` object present → Seller MUST apply content standards when provided * `media_buy.audience_targeting` object present → Seller MUST support `sync_audiences` and audience targeting overlays * `media_buy.conversion_tracking` object present → Seller MUST support `sync_event_sources` and `log_event` * AXE URL in `media_buy.execution.axe_integrations` → Seller can execute through that exchange (legacy — new integrations use [TMP](/dist/docs/3.1.20/trusted-match)) No silent ignoring. If a seller can't support a capability, they should declare `false` or omit it. ## Common Scenarios ### Basic Capability Discovery ```javascript theme={null} import { AdcpClient } from '@adcp/sdk'; const client = new AdcpClient({ baseUrl: 'https://seller.example.com/mcp' }); // Get seller capabilities const result = await client.getAdcpCapabilities({}); if (result.errors) { throw new Error(`Request failed: ${result.errors[0].message}`); } // Check protocol support console.log(`AdCP versions: ${result.adcp.major_versions.join(', ')}`); console.log(`Supported protocols: ${result.supported_protocols.join(', ')}`); // Check media-buy capabilities if (result.supported_protocols.includes('media_buy')) { const mediaBuy = result.media_buy; // Check content standards support (object presence = signal) if (mediaBuy.content_standards) { console.log('Content standards supported'); } // Check AXE integrations (legacy — new integrations use TMP via trusted_match on products) if (mediaBuy.execution?.axe_integrations?.includes('https://axe.example.com')) { console.log('AXE integration available'); } // Check geo targeting (normalize native country keys and deprecated aliases) const postalSupport = mediaBuy.execution?.targeting?.geo_postal_areas; if (postalSupport?.US?.includes('zip') || postalSupport?.us_zip === true) { console.log('US ZIP code targeting supported'); } // Portfolio overview console.log(`Publishers: ${mediaBuy.portfolio.publisher_domains.length}`); console.log(`Channels: ${mediaBuy.portfolio.primary_channels?.join(', ')}`); } ``` ### Check multi-protocol support ```javascript theme={null} const caps = await client.getAdcpCapabilities({}); const sellsMedia = caps.supported_protocols.includes('media_buy'); const managesCreatives = caps.supported_protocols.includes('creative'); if (sellsMedia && managesCreatives) { // Single agent handles both protocols — no need to discover a separate service const formats = await client.listCreativeFormats({}); const delivery = await client.getCreativeDelivery({ media_buy_ids: ['mb_12345'] }); } ``` ### Filter sellers by capability ```javascript theme={null} // Find sellers that support specific requirements async function findCompatibleSellers(sellers, requirements) { const compatible = []; for (const sellerUrl of sellers) { const client = new AdcpClient({ baseUrl: sellerUrl }); const caps = await client.getAdcpCapabilities({}); if (caps.errors) continue; // Must support media_buy protocol if (!caps.supported_protocols.includes('media_buy')) continue; const mediaBuy = caps.media_buy; // Check AXE integration requirement (legacy — new integrations use TMP) if (requirements.axeIntegration) { if (!mediaBuy.execution?.axe_integrations?.includes(requirements.axeIntegration)) { continue; } } // Check geo targeting requirement if (requirements.postalCodeTargeting) { const postalSupport = mediaBuy.execution?.targeting?.geo_postal_areas; if (!(postalSupport?.US?.includes('zip') || postalSupport?.us_zip === true)) { continue; } } // Check content standards requirement (object presence = signal) if (requirements.contentStandards) { if (!mediaBuy.content_standards) { continue; } } compatible.push({ url: sellerUrl, capabilities: caps }); } return compatible; } // Usage const sellers = await findCompatibleSellers( ['https://seller1.com/mcp', 'https://seller2.com/mcp'], { axeIntegration: 'https://axe.example.com', postalCodeTargeting: true, contentStandards: true } ); ``` ### Use Capabilities to Build Targeting Capabilities tell you what you CAN specify in create\_media\_buy targeting. Use `required_geo_targeting` to filter products to sellers that support specific geo targeting levels and systems: ```javascript theme={null} // First, check capabilities const caps = await client.getAdcpCapabilities({}); if (!caps.supported_protocols.includes('media_buy')) { throw new Error('Seller does not support media_buy protocol'); } const mediaBuy = caps.media_buy; const postalSupport = mediaBuy.execution?.targeting?.geo_postal_areas; // Filter products to sellers with specific geo targeting capabilities const products = await client.getProducts({ brief: "Premium video inventory in US for ZIP-targeted campaign", filters: { channels: ['olv', 'ctv'], countries: ['US'], // Require seller supports ZIP targeting (capability filter - no actual ZIPs needed) // level = granularity, system = classification taxonomy required_geo_targeting: [ { level: 'postal_area', country: 'US', system: 'zip' } ], // Only include if seller supports this AXE (legacy — new integrations use TMP) required_axe_integrations: ['https://axe.example.com'] } }); // Then, create media buy with fine-grained targeting // (if seller supports postal areas, we can target specific ZIP codes) const buy = await client.createMediaBuy({ brand: { domain: 'mybrand.com' }, packages: [{ product_id: products.products[0].product_id, pricing_option_id: products.products[0].pricing_options[0].id, budget: 10000, // Targeting overlay refines delivery within product coverage targeting_overlay: { geo_countries: ['US'], // Only specify ZIP targeting if seller supports it ...((postalSupport?.US?.includes('zip') || postalSupport?.us_zip === true) && { geo_postal_areas: [{ country: 'US', system: 'zip', values: ['10001', '10002', '10003', '10004', '10005'] }] }) } }], start_time: { type: 'asap' }, end_time: '2025-03-01T00:00:00Z' }); ``` **Two models for product geography:** | Inventory Type | Filter By | Example | | ----------------------------- | ------------------------------------ | ------------------------------------------------ | | Digital (display, OLV, CTV) | Capability: `required_geo_targeting` | Products have broad coverage, target at buy time | | Local (radio, DOOH, local TV) | Coverage: `metros`, `regions` | Products ARE geographically bound | * **Digital inventory**: Use `countries` + `required_geo_targeting` (capability), apply fine-grained targeting in `create_media_buy` * **Local inventory**: Use `metros`/`regions` (coverage) to find products with coverage in your target markets ### Local Inventory Example (Radio, DOOH) For locally-bound inventory, products ARE geographically specific. A radio station in NYC DMA only covers NYC. ```javascript theme={null} // Find radio products in specific DMAs const radioProducts = await client.getProducts({ brief: "Radio inventory in NYC and LA markets", filters: { channels: ['radio'], // Coverage filter: products must cover these metros metros: [ { system: 'nielsen_dma', code: '501' }, // NYC { system: 'nielsen_dma', code: '803' } // LA ] } }); // For local inventory, targeting_overlay is optional - // the product's coverage IS the geography const buy = await client.createMediaBuy({ brand: { domain: 'mybrand.com' }, packages: [{ product_id: radioProducts.products[0].product_id, pricing_option_id: radioProducts.products[0].pricing_options[0].id, budget: 5000 // No targeting_overlay needed - product covers NYC DMA }], start_time: { type: 'asap' }, end_time: '2025-03-01T00:00:00Z' }); ``` ## Response Example ```json theme={null} { "$schema": "/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["media_buy"], "account": { "require_operator_auth": false, "supported_billing": ["operator", "agent"] }, "media_buy": { "creative_approval_mode": "auto_approve", "features": { "inline_creative_management": true, "property_list_filtering": true }, "execution": { "axe_integrations": ["https://axe.example.com"], "creative_specs": { "vast_versions": ["4.0", "4.1", "4.2"], "mraid_versions": ["3.0"], "vpaid": false, "simid": true }, "targeting": { "geo_countries": true, "geo_regions": true, "geo_metros": { "nielsen_dma": true }, "geo_postal_areas": { "us_zip": true, "us_zip_plus_four": true, "US": ["zip", "zip_plus_four"], "GB": ["outward", "full"], "CA": ["fsa", "full"], "ZA": ["postal_code"] }, "language": true, "keyword_targets": { "supported_match_types": ["broad", "phrase", "exact"] }, "negative_keywords": { "supported_match_types": ["broad", "exact"] } } }, "content_standards": { "supports_local_evaluation": true, "supported_channels": ["display", "olv"], "supports_webhook_delivery": false }, "audience_targeting": { "supported_identifier_types": ["hashed_email", "hashed_phone"], "supports_platform_customer_id": false, "supported_uid_types": ["uid2", "rampid"], "minimum_audience_size": 500, "matching_latency_hours": { "min": 1, "max": 24 } }, "conversion_tracking": { "multi_source_event_dedup": false, "supported_event_types": ["purchase", "lead", "add_to_cart", "view_content"], "supported_action_sources": ["website", "app"], "attribution_windows": [ { "post_click": [{ "interval": 7, "unit": "days" }, { "interval": 28, "unit": "days" }], "post_view": [{ "interval": 1, "unit": "days" }, { "interval": 7, "unit": "days" }] } ] }, "portfolio": { "publisher_domains": ["example.com", "news.example.com"], "primary_channels": ["display", "olv"], "primary_countries": ["US", "CA"] } }, "extensions_supported": ["acmecorp"], "last_updated": "2025-01-23T10:00:00Z" } ``` This tells buyers: * **AdCP versions**: Version 1 * **Protocols**: Media buy only * **Auth model**: Agent-trusted (`require_operator_auth: false`) — authenticate once, declare brands via `sync_accounts` * **Billing**: Operator or agent billing; default is operator * **Country targeting**: Available (ISO 3166-1 alpha-2: `US`, `GB`, etc.) * **Region targeting**: Available (ISO 3166-2: `US-NY`, `GB-SCT`, etc.) * **Metro targeting**: Nielsen DMA only (US market) * **Postal targeting**: US ZIP, UK outward codes, Canadian FSA * **Audience targeting**: Accepts hashed email, hashed phone, UID2, and RampID; minimum matched audience size of 500; matching latency 1–24 hours * **Conversion tracking**: Accepts purchase, lead, add\_to\_cart, view\_content events from website/app; no multi-source dedup * **Extensions**: Vendor-specific data in `ext.acmecorp` ### Multi-protocol agent An agent can implement multiple protocols from a single endpoint. This is common for sellers that manage both media buying and creative generation — the buyer calls all tasks on the same URL. ```json theme={null} { "$schema": "/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["media_buy", "creative"], "account": { "require_operator_auth": false, "supported_billing": ["operator"] }, "media_buy": { "creative_approval_mode": "require_human", "features": { "inline_creative_management": true }, "portfolio": { "publisher_domains": ["news.example.com"], "primary_channels": ["display", "olv"] } }, "creative": { "has_creative_library": true, "supports_generation": true, "supports_transformation": false, "supports_compliance": false, "bills_through_adcp": true } } ``` This agent supports: * **Media Buy Protocol**: Product discovery, media buying, delivery reporting * **Creative Protocol**: Creative library management, AI-powered creative generation, variant-level delivery analytics via `get_creative_delivery` * **Shared account**: A single account established via `sync_accounts` applies to both protocols When `supported_protocols` includes `"creative"`, the buyer can call Creative Protocol tasks (`list_creative_formats`, `sync_creatives`, `get_creative_delivery`, etc.) on this agent. See [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities). ### Geo Standards Reference | Level | System | Examples | | ----------- | ---------------------- | ---------------------------------------- | | Country | ISO 3166-1 alpha-2 | `US`, `GB`, `DE`, `CA` | | Region | ISO 3166-2 | `US-NY`, `GB-SCT`, `DE-BY`, `CA-ON` | | Metro (US) | `nielsen_dma` | `501` (NYC), `803` (LA), `602` (Chicago) | | Metro (UK) | `uk_itl2` | `UKI` (London), `UKD` (North West) | | Metro (EU) | `eurostat_nuts2` | `DE30` (Berlin), `FR10` (Île-de-France) | | Postal (US) | `US` / `zip` | `10001`, `90210` | | Postal (US) | `US` / `zip_plus_four` | `10001-1234` | | Postal (UK) | `GB` / `outward` | `SW1`, `EC1`, `M1` | | Postal (UK) | `GB` / `full` | `SW1A 1AA` | | Postal (CA) | `CA` / `fsa` | `K1A`, `M5V` | ## Migration from list\_authorized\_properties (v2) The `list_authorized_properties` task was removed in v3. If migrating from v2: | Old Field | New Location | | ----------------------- | ------------------------------------------ | | `publisher_domains` | `media_buy.portfolio.publisher_domains` | | `primary_channels` | `media_buy.portfolio.primary_channels` | | `primary_countries` | `media_buy.portfolio.primary_countries` | | `portfolio_description` | `media_buy.portfolio.description` | | `advertising_policies` | `media_buy.portfolio.advertising_policies` | | `last_updated` | `last_updated` (top level) | New fields: * `adcp.major_versions` - Version compatibility * `supported_protocols` - Which domain protocols are supported * `media_buy.features` - Optional feature support * `media_buy.execution.axe_integrations` - Ad exchange support * `media_buy.execution.creative_specs` - VAST/MRAID versions * `media_buy.execution.targeting` - Geo targeting granularity ## Error Handling | Error Code | Description | Resolution | | --------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `AUTH_MISSING` | No credentials presented | Provide credentials via auth header | | `AUTH_INVALID` | Credentials rejected (expired / revoked) | Human credential rotation required | | `VERSION_UNSUPPORTED` | Declared `adcp_major_version` not in seller's `major_versions` | Call without `adcp_major_version` to discover supported versions, then retry | | `INTERNAL_ERROR` | Server error | Retry with backoff | ## Best Practices **1. Cache Capabilities** Capabilities rarely change. Cache results and use `last_updated` for staleness detection. **2. Check Protocol Support First** Before accessing protocol-specific fields, verify the protocol is in `supported_protocols`. **3. Check Before Requesting** Don't send postal areas for a system the seller doesn't support. Don't request features the seller doesn't support. **4. Fail Fast on Incompatibility** If a seller doesn't support required capabilities, skip them early rather than discovering failures later. **5. Read the Auth Model Before Proceeding** Check `account.require_operator_auth` immediately after discovery. Agent-trusted and operator-scoped flows diverge significantly: the former uses a single credential for all brands and operators, the latter requires per-operator credentials and sessions. **6. Use Protocol Version for Routing** Route requests to appropriate API versions based on `adcp.major_versions`. ## Next Steps After discovering capabilities: 1. **Set up accounts**: Follow the auth model from `account.require_operator_auth` — see [Accounts and Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#what-sellers-declare) 2. **Filter products**: Use [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) with capability-aware filters 3. **Validate properties**: Fetch publisher `adagents.json` files for property definitions 4. **Create buys**: Use [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) with supported features ## Learn More * [Accounts and Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) - Auth models, account setup, billing * [adagents.json Specification](/dist/docs/3.1.20/governance/property/adagents) - Publisher authorization files * [Product Filters](/dist/docs/3.1.20/media-buy/task-reference/get_products#filters) - Capability-aware filtering * [Content Standards](/dist/docs/3.1.20/governance/content-standards) - Brand safety configuration # Required tasks by protocol Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/required-tasks Consolidated reference of required and optional tasks for each AdCP protocol, organized by agent role. # Required tasks by protocol Each AdCP protocol defines tasks that agents implement depending on their role. This page consolidates the requirements from each protocol specification into a single reference. **Legend**: **Required** — the specification mandates implementation. **Conditional** — required when the agent has a specific capability. **Optional** — the specification recommends but does not mandate. Some tasks span protocols. For example, `list_creative_formats` and `sync_creatives` are defined in the Creative Protocol but also implemented by Media Buy sales agents. These appear under both protocols with a note indicating the source. ## Shared Every AdCP agent, regardless of protocol, implements: | Task | Requirement | Reference | | ----------------------- | ----------- | ------------------------------------------------------------------------ | | `get_adcp_capabilities` | Required | [Capability discovery](/dist/docs/3.1.20/protocol/get_adcp_capabilities) | Caller-scope RBAC introspection is not a standalone task. Sellers that support scope introspection return a per-account `authorization` object on `sync_accounts` and `list_accounts` responses. See [Accounts Protocol — Caller authorization](/dist/docs/3.1.20/accounts/overview#caller-authorization). AdCP task lifecycle state is application-layer state, not MCP-native or A2A-native task state. 3.x sellers MAY advertise non-colliding AdCP aliases for polling and reconciliation: `get_task_status` for legacy `tasks/get`, and `list_tasks` for legacy `tasks/list`. These aliases are optional compatibility surfaces in 3.x; callers MUST continue to support the legacy names through the 3.x line. Alias and legacy request payloads use the same snake\_case shape, including the optional `account` scope used to keep task visibility within the caller's authenticated account + principal pair. ## Media Buy Protocol ### Sales agent (seller) | Task | Requirement | Notes | | ------------------------------ | ----------- | ------------------------------------------------------------------------------------------------- | | `get_products` | Required | Inventory discovery | | `list_creative_formats` | Required | Format specifications (Creative Protocol task) | | `create_media_buy` | Required | Campaign creation and order confirmation | | `update_media_buy` | Required | Budget, targeting, pause, cancel | | `get_media_buys` | Required | Operational state retrieval | | `get_media_buy_delivery` | Required | Delivery metrics at the package level; `reporting_capabilities` MUST be included on every product | | `provide_performance_feedback` | Required | Accept buyer optimization signals | | `sync_creatives` | Conditional | Required when the sales agent hosts a creative library (Creative Protocol task) | | `list_creatives` | Optional | Creative catalog browsing (Creative Protocol task) | | `sync_catalogs` | Optional | Product/inventory catalog sync | | `sync_event_sources` | Optional | Conversion tracking setup | | `log_event` | Conditional | Required when event sources are configured | | `sync_audiences` | Optional | First-party CRM audience upload | Sales agents MUST also support at least one transport (MCP or A2A) and declare `media_buy` in `supported_protocols`. **Reference**: [Media Buy Specification](/dist/docs/3.1.20/media-buy/specification) · [Seller integration guide](/dist/docs/3.1.20/building/operating/seller-integration) ### Orchestrator (buyer) Orchestrators are not MCP/A2A servers — they call sales agent tasks. Conformant orchestrators MUST: 1. Authenticate with sales agents 2. Include required fields per request schemas 3. Handle asynchronous task-level responses (`submitted`, `working`, `input-required`) and webhook delivery of completion artifacts 4. Use `media_buy_id` for all subsequent operations 5. Respect `creative_deadline` for creative uploads **Reference**: [Media Buy Specification — Orchestrator conformance](/dist/docs/3.1.20/media-buy/specification#orchestrator-conformance) ## Creative Protocol ### Creative agent | Task | Requirement | Notes | | ----------------------- | ----------- | ------------------------------------------------------------------------- | | `list_creative_formats` | Required | Format discovery with technical specs | | `list_transformers` | Conditional | Required when the agent advertises `creative.supports_transformers: true` | | `build_creative` | Optional | Generation, transformation, or library retrieval | | `preview_creative` | Optional | Preview rendering | | `list_creatives` | Conditional | Required when `has_creative_library: true` | | `sync_creatives` | Conditional | Required when the agent accepts creative uploads | | `get_creative_delivery` | Optional | Variant-level delivery metrics | Creative agents MUST return authoritative format definitions only for formats they own. **Reference**: [Creative Specification](/dist/docs/3.1.20/creative/specification) ## Signals Protocol ### Signal agent | Task | Requirement | Notes | | ----------------- | ----------- | ------------------------------------------ | | `get_signals` | Required | Signal discovery | | `activate_signal` | Required | Signal activation on decisioning platforms | Signal agents MUST enforce access control for private signals and activation keys. **Reference**: [Signals Specification](/dist/docs/3.1.20/signals/specification) ## Brand Protocol ### Brand agent | Task | Requirement | Notes | | -------------------- | ----------- | ----------------------------------------------------------- | | `get_brand_identity` | Required | Core identity is public; authorized callers get deeper data | | `get_rights` | Conditional | Required when the agent manages brand rights | | `acquire_rights` | Conditional | Required when the agent manages brand rights | | `update_rights` | Conditional | Required when the agent manages brand rights | | `creative_approval` | Conditional | Required when the agent reviews AI-generated content | Brand agents MUST declare `brand` in `supported_protocols`. **Reference**: [Brand Protocol](/dist/docs/3.1.20/brand-protocol) · [Building a brand agent](/dist/docs/3.1.20/brand-protocol/building-a-brand-agent) ## Accounts Protocol ### Any agent accepting accounts | Task | Requirement | Notes | | ------------------------ | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sync_accounts` | Conditional | Buyer-declared accounts (`require_operator_auth: false`) — buyer declares brand/operator pairs; account-id namespaces may use this only for settings updates unless a future explicit capability declares provisioning | | `list_accounts` | Conditional / recommended | Account-id namespaces (`require_operator_auth: true`) MUST expose it when a credential can access multiple accounts and SHOULD return a singleton otherwise. Buyer-declared-account sellers (`require_operator_auth: false`) SHOULD expose it as the cold-start recovery read for stateless buyers. | | `sync_governance` | Conditional | Required when the buyer uses campaign governance | | `report_usage` | Conditional | Required when the agent charges for services | | `get_account_financials` | Optional | Financial summary per account | Agents MUST implement at least one of `sync_accounts` or `list_accounts` depending on their account model. An agent MAY implement both. Buyer-declared-account sellers SHOULD implement both: `sync_accounts` establishes the relationship, while `list_accounts` lets a stateless buyer recover the seller's acknowledged `(brand, operator, sandbox)` portfolio without already possessing the natural keys it lost. **Reference**: [Accounts Protocol](/dist/docs/3.1.20/accounts/overview) ## Governance: Campaign ### Governance agent | Task | Requirement | Notes | | --------------------- | ----------- | --------------------------------------------------------- | | `sync_plans` | Required | Plan creation and amendment | | `check_governance` | Required | Validation at purchase, modification, and delivery phases | | `report_plan_outcome` | Required | Outcome reporting and budget commitment | | `get_plan_audit_logs` | Required | Audit trail retrieval | **Reference**: [Campaign Governance Specification](/dist/docs/3.1.20/governance/campaign/specification) ## Governance: Property ### Governance agent | Task | Requirement | Notes | | ---------------------------- | ----------- | ----------------------------------- | | `create_property_list` | Required | List creation with filters | | `get_property_list` | Required | Resolved property retrieval | | `update_property_list` | Required | List modification | | `delete_property_list` | Required | List removal | | `list_property_lists` | Required | List enumeration | | `validate_property_delivery` | Optional | Post-campaign compliance validation | **Reference**: [Property Governance Specification](/dist/docs/3.1.20/governance/property/specification) ## Governance: Collection ### Governance agent | Task | Requirement | Notes | | ------------------------ | ----------- | ------------------------------------------------- | | `create_collection_list` | Required | List creation with collection sources and filters | | `get_collection_list` | Required | Resolved collection retrieval | | `update_collection_list` | Required | List modification | | `list_collection_lists` | Required | List enumeration | | `delete_collection_list` | Required | List removal | **Reference**: [Collection Governance](/dist/docs/3.1.20/governance/collection) ## Governance: Content Standards ### Content standards agent | Task | Requirement | Notes | | --------------------------- | ----------- | ------------------------------------ | | `create_content_standards` | Required | Standards creation | | `get_content_standards` | Required | Standards retrieval | | `list_content_standards` | Required | Standards enumeration | | `update_content_standards` | Required | Standards modification | | `calibrate_content` | Optional | Seller calibration against standards | | `validate_content_delivery` | Optional | Post-delivery content compliance | | `get_media_buy_artifacts` | Optional | Artifact retrieval for validation | **Reference**: [Content Standards](/dist/docs/3.1.20/governance/content-standards) ## Governance: Creative ### Creative governance agent | Task | Requirement | Notes | | ----------------------- | ----------- | ----------------------------------------- | | `get_creative_features` | Required | Security scanning, content categorization | **Reference**: [Creative Governance](/dist/docs/3.1.20/governance/creative) ## Sponsored Intelligence Protocol ### Brand agent | Task | Requirement | Notes | | ---------------------- | ----------- | ----------------------------- | | `si_get_offering` | Optional | Pre-session offering lookup | | `si_initiate_session` | Required | Session creation with consent | | `si_send_message` | Required | Conversational messaging | | `si_terminate_session` | Required | Session cleanup | Brand agents MUST declare `sponsored_intelligence` in `supported_protocols`. **Reference**: [Sponsored Intelligence Specification](/dist/docs/3.1.20/sponsored-intelligence/specification) ## Trusted Match Protocol TMP uses a different communication model (direct HTTP, not MCP/A2A tasks). See the [TMP Specification](/dist/docs/3.1.20/trusted-match/specification) for message types and conformance requirements. # Snapshot and log Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/protocol/snapshot-and-log The contract that ties every read API to its push channel — what a snapshot is, what a log is, how they share an id space, and why pulling the snapshot is the only replay primitive AdCP commits to. # Snapshot and log Every state surface AdCP exposes has two faces: a **snapshot** read from a `get_*` task and a **log** of push events fired against a registered webhook URL. The snapshot says *what is true now*. The log says *what fired, when, with what id*. This page is the contract that keeps them coherent. You don't need to read this page to call an AdCP task. You do need to read it to build a webhook receiver, to propose a new notification type, or to argue that a missing-event scenario is a spec gap rather than a buyer-side bug. ## The two faces ### Snapshot The current truth, exposed on a read API: * `get_media_buys` returns each buy's `status`, `health`, open `impairments[]`, and `webhook_activity[]`. * `list_creatives` returns each creative's `status`. * `sync_audiences` (without changes) returns each audience's current `status`. * `get_event_source_health` returns each source's current `assessment-status`. A snapshot is always re-readable. It carries no history — only what's true at the moment of the read. ### Log A stream of push events fired to the buyer's registered webhook URL: * Delivery report fires (`notification_type: scheduled | final | delayed | adjusted`). * Dependency impairment fires (`notification_type: impairment`). * Future event types are added the same way: a new `notification-type` value, a defined payload, the same delivery contract. Each event carries a stable `notification_id` and corresponds to a change visible on the snapshot. ## The five rules These rules apply across every snapshot/log pair in the protocol. If you're building a new notification type, your design must satisfy all five. ### 1. Two distinct ids: per-fire and per-state **Dedupe transport retries by `idempotency_key`. Correlate fires to state by `notification_id`.** These are different ids on the same fire — receivers MUST track both. * **`idempotency_key`** — transport-layer, **per delivery attempt**. Issued by the seller for each fire. Receivers dedupe on this to suppress retries of the same logical fire. Defined in the [webhooks transport contract](/dist/docs/3.1.20/building/by-layer/L3/webhooks). * **`notification_id`** — event-layer, **per state event**. Stable across re-emissions of the same logical event. For state-shaped events this equals the resource's stable id (e.g., `impairment_id` is the `notification_id` for impairment events). Typed at the envelope level on [`mcp-webhook-payload.json`](https://adcontextprotocol.org/schemas/3.1.20/core/mcp-webhook-payload.json); per-type population is documented on [`notification-type.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/notification-type.json) enumDescriptions. Absent on point-in-time data events (e.g., delivery report fires) that have no persistent state id. The split is intentional. A receiver seeing the same `idempotency_key` twice is observing a transport retry — uninteresting, dedupe and move on. A receiver seeing the same `notification_id` twice under **different** `idempotency_key`s is observing a re-emission — signal. The seller is repeating itself, usually because the buyer's receiver was unreachable for long enough that the seller wants to make sure the state was delivered. That's a missed-events warning the receiver should not collapse. For state-shaped events (impairment, lifecycle), the per-state id is the resource id. For point-in-time data events (delivery report fires), there is no persistent state id — the per-fire `idempotency_key` is all there is. That asymmetry is honest about the limits of Rule 4 below. ### 2. Every push event corresponds to a snapshot delta There is no webhook-only state. If a webhook fires with `notification_type: impairment`, the affected media buy's `impairments[]` will show the impairment on the next read. If a delivery report fires, the next `get_media_buy_delivery` reflects the same reporting window. Push channels do not carry information unavailable from the read API. This rule rules out push events that exist solely as ephemeral signals — "you might want to know X" without a corresponding readable state. If you want to surface a suggestion that doesn't change state, build a pull tool, not a webhook. ### 3. Push is at-least-once; the snapshot is authoritative When push and snapshot disagree, the snapshot wins. A duplicate webhook fire (same `notification_id`) is the expected behavior under at-least-once delivery — buyer agents dedupe and continue. A stale webhook fire (the push reports a state that the snapshot no longer reflects, because the resource moved on) is also expected — buyer agents re-read the snapshot rather than acting on the push payload. This is why receivers MUST verify against the snapshot before taking irreversible action on a push. ### 4. Either path is complete A buyer using webhooks reliably gets all the data. A buyer using only GET (no webhooks) gets the same data. The two paths are at parity in content and granularity; the buyer chooses based on latency, ergonomics, and receiver infrastructure. This rule has two halves: * **For state events** (impairment, lifecycle, status changes): GET returns current state. A buyer who missed a webhook calls `get_*` and reads the snapshot — recovery is lossless. ✅ Holds today. * **For data-bearing events** (delivery report fires, individual log events): GET MUST honor windowed pulls at every granularity the seller declares in `reporting_capabilities.windowed_pull_granularities`, with the same windowing the webhook delivers at that granularity. A seller that declares `["hourly", "daily"]` MUST honor hourly and daily windowed pulls on `get_media_buy_delivery` (via `time_granularity` + `include_window_breakdown: true`); the slice payload is shape-aligned with the webhook fire it could have replaced. Sellers MAY emit higher-frequency webhooks than they expose for pull — common in stream-tap architectures where the webhook is a Kafka tap and historical reads go through a warehouse with coarser granularity. In that case the buyer knows up front via the capability that pull-recovery is unavailable at the higher frequency and treats the webhook as primary for it. The two-paths-equal contract holds within each seller's **declared** parity set. Sellers MUST be honest about the set: declare every granularity at which the GET surface can in fact reproduce the webhook payload, no more, no less. A seller that declares a granularity but rejects pulls at that granularity is in breach of Rule 4; a seller that omits a granularity is opting out of two-paths-parity at that frequency and is fine. Pulls outside the declared set return `UNSUPPORTED_GRANULARITY` with `error.details.supported_granularities` echoing the capability. Without two-paths-equal, AdCP becomes pub/sub for some channels and REST for others — buyers building against the contract have to know which model applies where. With it, both paths are equivalent: a buyer chooses webhooks for latency or polling for simplicity, and gets the same data either way. ### 5. Push events and log entries share an id space A webhook delivery surfaced via `webhook_activity[]` references the same `notification_id` that the buyer received in the push body. A buyer can correlate "I received fire X" with "the seller's log shows fire X" without bookkeeping across two namespaces. Likewise, an `impairment_id` referenced in `impairments[]` matches the `notification_id` of the push that announced it. ## Webhook activity log pattern The transport half of Rule 5. Any AdCP resource that exposes a snapshot read API and has webhook fires associated with it MAY also surface a `webhook_activity[]` array on that read API — recent per-fire transport records, scoped to the calling principal, useful for buyer-side debugging when a fire didn't land or a retry trail looks suspect. This section is the contract any resource adopting that surface MUST follow. ### Canonical record shape The record shape is fixed at [`/schemas/core/webhook-activity-record.json`](https://adcontextprotocol.org/schemas/3.1.20/core/webhook-activity-record.json). Read schemas adopting this surface MUST `$ref` the canonical record rather than inline it — the shape is intentionally uniform across resources so a buyer's debug tooling can consume `webhook_activity[]` from any read API without resource-specific parsing. Each record carries `idempotency_key` (equals the payload's `idempotency_key` per Rule 5 — no parallel `delivery_id`), `subscriber_id` (reserved for #3009 multi-subscriber), `fired_at`, `completed_at`, `notification_type`, `sequence_number`, `attempt` (1-indexed; one record per attempt), `status` (`success` / `failed` / `timeout` / `connection_error` / `pending`), `url` (query string and fragment stripped, secret-shaped path segments redacted), `http_status_code`, `response_time_ms`, `payload_size_bytes`, and `error_message` (server-side classification only — never request/response bodies or headers). ### Request-field convention Read schemas that surface `webhook_activity[]` MUST use the same two request-field names so callers can opt in uniformly across resources: * **`include_webhook_activity`** — boolean, default `false`. When true, the seller MAY return a `webhook_activity[]` array on each item (subject to the three-state presence semantics below). * **`webhook_activity_limit`** — integer, range 1–200, default 50. Per-item cap on returned records, most-recent first. ### Scoping (normative) `webhook_activity[]` MUST be scoped to the **calling principal**. When multiple principals share visibility into the same resource via account-level access, each principal sees only fires targeting its own registered endpoint. This is the same scoping rule that applies to push delivery itself. ### Retention (normative) Sellers that surface `webhook_activity[]` **MUST** retain records for at least 30 days from each record's `completed_at`. This applies uniformly to every terminal status — `success`, `failed`, `timeout`, and `connection_error` all populate `completed_at` (for `timeout` and `connection_error` it is the moment the seller declared the attempt terminal) and the 30-day clock runs from there. For records still in `pending` status (the attempt is in flight or queued for retry, `completed_at` is null), the clock runs from `fired_at` until the attempt terminates and then transitions to 30 days from `completed_at` — so a retry trail does not age out mid-flight just because the initial fire happened 29 days ago. The 30-day floor is a hard contract — sellers unable to honor it MUST omit the field entirely (see three-state presence below) rather than return a shorter window. This gives buyers a single retention guarantee they can build debug tooling against, and gives sellers with thin storage a clean opt-out via the three-state semantics rather than forcing the spec to negotiate per-seller retention floors. ### Three-state presence semantics | State | Meaning | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Field **omitted** | Seller does not surface webhook activity for this resource. Causes are resource-specific (see "Adoption checklist" below) but typically include: the seller does not persist fire history; the resource has no registered webhook endpoint for the calling principal; the seller's declared capability surface excludes the webhook channel for the relevant notification types. Buyers MUST NOT infer "no fires occurred" from omission. | | Empty array `[]` | Seller persists fire history but has fired nothing recent for this principal. | | Non-empty array | Actual fire records, most-recent first. | Sellers MUST NOT collapse these into a single state. Opting in via `include_webhook_activity: true` does not override the seller's intrinsic capability — a seller that cannot meet the retention floor returns omission regardless of the request. Buyers diagnosing an unexpected omission have two readily observable signals to discriminate the cause without needing operator help: (1) their own `push_notification_config` registration state for the resource (rules out "no registered endpoint") and (2) the seller's capability declaration (rules out "capability surface excludes the channel"). When both check out, "seller does not persist fire history" is the remaining cause and no further protocol-side fix is available — escalate. ### Record cardinality One record per attempt. A successful first-attempt fire appears as a single record with `attempt: 1`. A 3-attempt retry trail (e.g., two failures then a success) appears as three records sharing `idempotency_key` — the trail is reconstructed by the buyer grouping records on that key. ### Privacy * `url` MUST have query string and fragment stripped, and high-entropy / token-shaped path segments SHOULD be further redacted. * `error_message` is a server-side classification string only — never request headers, response bodies, or buyer-endpoint stack traces. * Request and response bodies are out of scope for the basic surface. A future `include_webhook_payloads` extension may add them under stricter access controls, and would use the [universal truncation sentinel](https://adcontextprotocol.org/schemas/3.1.20/core/truncation-sentinel.json) at `/schemas/core/truncation-sentinel.json` when bodies exceed a configured cap. ### Adoption checklist Resources adopting `webhook_activity[]` MUST satisfy all of the following. The list is intentionally explicit so the "MUST" hooks are unambiguous; everything not on this list is at adopter discretion (e.g., per-resource cardinality tuning within the 1–200 range). 1. **Notification channel (prerequisite).** Adoption requires a registered notification channel for the relevant fire types. Media buys satisfy this today via per-buy `push_notification_config` (and the related `reporting_webhook`); resources that outlive any single buy — creatives, audiences, properties, account-level governance — wait on the **per-account subscription model defined in #4582 track 3** (forthcoming in 3.2.0). The two are different primitives that fulfill the same prerequisite: a buy-scoped config blob attached to the buy versus an account-scoped subscription resource. Without a channel there are no fires for `webhook_activity[]` to log; this item gates every other rule below. Adopters MUST cite the specific channel in their call-site documentation. 2. **Record shape.** Item schema MUST `$ref` `/schemas/core/webhook-activity-record.json`. Resource-specific cross-references (e.g., a parent-resource id when records are nested inside an account-level read) go on the canonical record's `ext` envelope, not as top-level record fields. 3. **Request fields.** The opt-in field names MUST be `include_webhook_activity` (boolean, default `false`) and `webhook_activity_limit` (integer, 1–200, default 50). The 200 ceiling is the canonical cap; adopters MAY narrow the maximum on a per-resource basis but MUST NOT exceed 200 or rename the fields. 4. **Scoping.** MUST be calling-principal only, per § Scoping above. 5. **Retention floor.** MUST honor the 30-day floor per § Retention above. The pivot (`completed_at`, with carve-out for `pending`) is the same across resources. 6. **Three-state presence cardinality.** Omitted / `[]` / non-empty are the three states; adopters MUST NOT collapse them. 7. **Capability gate.** Adopters MUST document which resource-specific capability declaration gates the field (for media buys this is `capabilities.media_buy.propagation_surfaces` including `webhook`). The specific *causes* of the "field omitted" state ARE resource-specific and adopters MUST enumerate them in their call-site documentation; the cardinality and the rule that omission is not "no fires occurred" are universal. 8. **Notification type registry.** Adopters whose webhook fires carry notification types not in [`/schemas/enums/notification-type.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/notification-type.json) MUST add those types to that shared enum rather than minting a parallel enum on the canonical record. The enum is the cross-resource registry. ### Consumers and the dependency chain #### Today (3.1) * `get_media_buys.media_buys[].webhook_activity[]` — the first and currently only consumer of this pattern. The notification channel is the existing per-buy `push_notification_config`, so item 1 of the checklist is satisfied without any new primitive. Capability gate: `capabilities.media_buy.propagation_surfaces` MUST include `webhook` for the field to be surfaced on a buy. See [get\_media\_buys § Webhook activity](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys#webhook-activity) for the call-site documentation and the [persistent webhook contract](/dist/docs/3.1.20/building/by-layer/L3/webhooks#persistent-channel-contract) for the transport-side rules this surface debugs against. #### Account-level adopters (3.1) Resources that outlive a single media buy register their push channel on the account, not on any one buy. The account-level surface is `notification_configs[]` — an array of per-subscriber registrations carried on [`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts#account-level-webhook-subscriptions) and echoed on `list_accounts`. Each entry filters by `event_types[]` so a subscriber only receives the types its endpoint handles, and multiple entries with distinct `subscriber_id`s fan a single event out to multiple endpoints (multi-subscriber composition). * **[#2261](https://github.com/adcontextprotocol/adcp/issues/2261) creative-lifecycle webhooks** — `list_creatives.creatives[].webhook_activity[]` is the second consumer of this pattern. The notification channel is the account's `notification_configs[]` set, registered via `sync_accounts` in either provisioning or settings-update mode. Supported event types and per-type coalescence windows are declared via `get_adcp_capabilities`. The two creative-lifecycle event types — `creative.status_changed` and `creative.purged` — share the same record shape and retention rule as media-buy webhook activity; the parent creative is unambiguous so `ext.creative_id` MAY be omitted on the inner records. See [list\_creatives § Webhook activity](/dist/docs/3.1.20/creative/task-reference/list_creatives#webhook-activity) for the call-site documentation. * **Other resources that outlive a buy** — audiences, properties, account-level compliance under [#1711](https://github.com/adcontextprotocol/adcp/issues/1711) — follow the same chain: subscribe via `sync_accounts.accounts[].notification_configs[]`, adopt the `webhook_activity[]` read on the resource's `list_` task. These are open RFCs. **Rule 4 carve-out for hard purges.** `creative.purged` with `purge_kind: hard` (legal-erasure-only — GDPR Article 17, CCPA deletion, court order) is the one sanctioned exception to Rule 4: the webhook fire has no corresponding snapshot delta because the seller MUST NOT retain a tombstone. Buyers who miss a hard-purge fire have no read-side recovery; that's the legal regime's design constraint, not a protocol gap. Soft purges retain a tombstone on `list_creatives` (with `include_purged: true`) and remain Rule-4 compliant. Adopters follow this checklist verbatim regardless of whether the notification channel is per-buy or per-account. ## What this rules out * **A push channel for suggestions that don't change state.** If "the seller wants you to know X" doesn't correspond to a readable field, it's not a snapshot/log event. Build a pull tool instead. (See the advisory epic.) * **A replay tool that re-fires past webhooks.** Snapshot reads are the replay. A replay tool is an operator-side debug feature; it's not part of the buyer-facing protocol contract. * **Per-event subscription filtering on per-buy push.** A buyer who registers `push_notification_config` on a media buy receives every event type fired against that buy. Filtering at the receiver is fine; filtering at the per-buy protocol surface is out of scope. Account-level subscriptions (`notification_configs[]`) are the exception — they filter by `event_types` at registration time, because the account-level surface is heterogeneous (creative events, future audience/property events) and an endpoint that handles only creative events would otherwise be force-fed signals it cannot interpret. * **A "did you receive my webhook?" confirmation step.** Receivers acknowledge via HTTP 2xx; senders retry on non-2xx per the [persistent webhook contract](/dist/docs/3.1.20/building/by-layer/L3/webhooks#persistent-channel-contract). Sellers do not poll buyers for receipt. ## Where the surface doesn't yet follow this * **Delivery reports** (`scheduled` / `final` / `delayed` / `adjusted`) predate this contract. Rule 4 closes for them in 3.1 via two surfaces: * **Per-window data parity** — `get_media_buy_delivery` accepts `time_granularity` + `include_window_breakdown: true`, returning `media_buy_deliveries[].windows[]` slices shape-aligned with `reporting_webhook` payloads at the same granularity. Capability-scoped via `reporting_capabilities.windowed_pull_granularities`; pulls outside the declared set return `UNSUPPORTED_GRANULARITY`. Landed in #4590. * **Per-fire transport log** — even with per-window parity, buyers debugging webhook delivery want to see which fires hit their endpoint and when. The `webhook_activity[]` surface on `get_media_buys` ([#4278](https://github.com/adcontextprotocol/adcp/issues/4278)) closes this for transport-layer observability. It is the first consumer of the [webhook activity log pattern](#webhook-activity-log-pattern) above; future resources adopting the pattern follow the same record shape, retention floor, and three-state presence semantics. * **Audience and property lifecycle webhooks** — creative-lifecycle webhooks now adopt this pattern via [#2261](https://github.com/adcontextprotocol/adcp/issues/2261) (account-level `notification_configs[]` + `list_creatives.webhook_activity[]`). Audience suspensions outside a buy's scope and property depublications remain open — until those land, the snapshot half (a fresh `sync_audiences` or property crawl) is the only reliable signal for changes to those resources when not currently referenced by an active buy. ## When you'd be right to push back This section is non-normative. It describes when raising an exception is reasonable, not when one is sanctioned. When a use case genuinely needs an event with no snapshot half — a high-frequency signal where polling cost dominates and recovery isn't critical (e.g., a metrics stream). AdCP doesn't have one of these today. If you're proposing one, name it explicitly and argue why pull-via-snapshot doesn't fit; reviewers will weigh that against the contract this page commits to. ## Related * [Push notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks) — the transport contract that this page sits on top of. * [Media buy lifecycle](/dist/docs/3.1.20/media-buy/media-buys/lifecycle) — applies snapshot/log to `status` + `health` + `impairments[]`. # Quickstart Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/quickstart Pick your path — buyers call an AdCP agent in 5 minutes, publishers and sellers stand up their own agent. Pick your path. Buyers call the public test agent in 5 minutes. Publishers and sellers stand up an agent buyers can call. Buyer side. The rest of this page walks through calling the public test agent — no signup, copy-pasteable curl. Publisher or seller side. Stand up an agent buyers can call. ## Setup Use the public test token to get started immediately — no signup required: ```bash theme={null} export ADCP_AUTH_TOKEN="1v8tAhASaUYYp4odoQ1PnMpdqNaMiTrCRqYo9OJp6IQ" export AGENT_URL="https://test-agent.adcontextprotocol.org/sales/mcp" ``` The test agent is path-routed: `/sales/mcp` serves media-buy tools (this quickstart's path), and sibling URLs serve the other specialisms — `/signals/mcp`, `/governance/mcp`, `/creative/mcp`, `/creative-builder/mcp`, `/brand/mcp`. Hit [`/.well-known/adagents.json`](https://test-agent.adcontextprotocol.org/.well-known/adagents.json) for the full tenant + tool list. For your own API key (org-scoped, usage tracking), create one at the [AAO dashboard](https://agenticadvertising.org/dashboard/api-keys). ## 1. Discover products AdCP over MCP uses JSON-RPC 2.0. The transport is [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) — responses arrive as server-sent events. ```bash theme={null} curl -X POST $AGENT_URL \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $ADCP_AUTH_TOKEN" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_products", "arguments": { "brief": "Video ads for pet food brand", "brand": { "domain": "premiumpetfoods.com" } } } }' ``` **Response** (SSE envelope omitted for clarity): ```json theme={null} { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"products\":[{\"product_id\":\"pinnacle_news_video_premium\",\"name\":\"Pinnacle News Group video guaranteed\",\"channels\":[\"olv\",\"ctv\"],\"pricing_options\":[{\"pricing_option_id\":\"pinnacle_news_video_premium_pricing_0\",\"pricing_model\":\"cpm\",\"currency\":\"USD\",\"fixed_price\":15}],\"delivery_type\":\"guaranteed\"}, ...],\"sandbox\":true}" } ] } } ``` **Extract the result** — the AdCP payload is JSON-encoded inside `content[0].text`: ```javascript theme={null} const response = /* parsed JSON-RPC response */; const payload = JSON.parse(response.result.content[0].text); console.log(payload.products[0].product_id); // "pinnacle_news_video_premium" console.log(payload.products[0].channels); // ["olv", "ctv"] console.log(payload.products[0].pricing_options[0].pricing_option_id); // "pinnacle_news_video_premium_pricing_0" console.log(payload.products[0].pricing_options[0].fixed_price); // 15 ``` ## 2. Handle errors Send an invalid tool name to see what errors look like: ```bash theme={null} curl -X POST $AGENT_URL \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $ADCP_AUTH_TOKEN" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "nonexistent_tool", "arguments": {} } }' ``` **Response:** ```json theme={null} { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"code\":\"INVALID_REQUEST\",\"message\":\"Unknown tool: nonexistent_tool\"}" } ], "isError": true } } ``` **Handle it** — check `isError`, then parse the error payload: ```javascript theme={null} const response = /* parsed JSON-RPC response */; if (response.result.isError) { const err = JSON.parse(response.result.content[0].text); console.log(err.code); // "INVALID_REQUEST" console.log(err.message); // "Unknown tool: nonexistent_tool" } ``` Common error codes: `INVALID_REQUEST` (bad input), `RATE_LIMITED` (retry with backoff), `UNAUTHORIZED` (check credentials). ## 3. Create a media buy (idempotently) Use the product IDs from step 1 to create a campaign. Every mutating request MUST carry an `idempotency_key` — a client-generated UUID v4 that makes retries safe. Send the same key with the same payload and the seller returns the original result instead of creating a duplicate buy: ```bash theme={null} export IDEMPOTENCY_KEY="$(uuidgen | tr '[:upper:]' '[:lower:]')" curl -X POST $AGENT_URL \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $ADCP_AUTH_TOKEN" \ -d "{ \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"tools/call\", \"params\": { \"name\": \"create_media_buy\", \"arguments\": { \"idempotency_key\": \"$IDEMPOTENCY_KEY\", \"account\": { \"account_id\": \"test_account\" }, \"brand\": { \"domain\": \"premiumpetfoods.com\" }, \"start_time\": \"asap\", \"end_time\": \"2026-04-30T00:00:00Z\", \"packages\": [{ \"product_id\": \"pinnacle_news_video_premium\", \"budget\": 5000, \"pricing_option_id\": \"pinnacle_news_video_premium_pricing_0\" }] } } }" ``` Replay the same request (same key, same payload) and the seller returns the original response with `replayed: true`. Send the same key with a different payload and you get `IDEMPOTENCY_CONFLICT`. Check a seller's window via `get_adcp_capabilities`: ```json theme={null} { "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } } ``` See the [Security guide](/dist/docs/3.1.20/building/by-layer/L1/security) for the full retry model, including `IDEMPOTENCY_CONFLICT`, `IDEMPOTENCY_EXPIRED`, and UUID v4 guidance for AdCP Verified agents. **Response** (IDs will differ on each call): ```json theme={null} { "jsonrpc": "2.0", "id": 1, "result": { "content": [ { "type": "text", "text": "{\"media_buy_id\":\"mb_f4139524\",\"status\":\"active\",\"revision\":1,\"packages\":[{\"package_id\":\"pkg_3df649f0\",\"product_id\":\"pinnacle_news_video_premium\",\"budget\":5000,\"pricing_option_id\":\"pinnacle_news_video_premium_pricing_0\"}],\"valid_actions\":[\"pause\",\"cancel\",\"update_budget\",\"update_dates\",\"update_packages\",\"add_packages\",\"sync_creatives\"],\"sandbox\":true}" } ] } } ``` **Extract the result:** ```javascript theme={null} const response = /* parsed JSON-RPC response */; const buy = JSON.parse(response.result.content[0].text); console.log(buy.media_buy_id); // "mb_f4139524" console.log(buy.status); // "active" console.log(buy.packages[0].budget); // 5000 console.log(buy.valid_actions); // ["pause", "cancel", "update_budget", ...] ``` ## 4. Push notifications (signed webhooks) Production agents send webhooks for long-running operations. AdCP 3.0 signs webhooks with the **same RFC 9421 HTTP Message Signatures profile used for agent-to-agent requests** — one verifier, one JWKS, one trust surface. No shared HMAC secrets. Point the agent at your webhook endpoint and advertise your JWKS. The agent signs each POST with a key it trusts; you fetch the agent's JWKS and verify the signature before acting on the payload: ```json theme={null} { "name": "create_media_buy", "arguments": { "idempotency_key": "5c4c6f29-...", "account": { "account_id": "your_account" }, "brand": { "domain": "premiumpetfoods.com" }, "push_notification_config": { "url": "https://you.example.com/webhooks/adcp", "authentication": { "schemes": ["HTTP_MESSAGE_SIGNATURES"] } } } } ``` When the operation completes, the agent POSTs a signed request to your URL. The payload carries its own `idempotency_key` so your receiver can dedupe retries: ```json theme={null} { "task_id": "task_456", "idempotency_key": "webhook_evt_8f2a...", "task_type": "create_media_buy", "status": "completed", "timestamp": "2026-04-22T10:30:00Z", "result": { "media_buy_id": "mb_12345", "packages": [{ "package_id": "pkg_001" }] } } ``` Verify the signature before trusting the payload — resolve the `keyid` via the seller operator's `brand.json` `agents[].jwks_uri`, apply any publisher `adagents.json` `signing_keys[]` pin when present, run the AdCP webhook verifier checklist, and reject unknown keys, expired dates, or mismatched digests with a typed `webhook_signature_*` reason code: ```typescript theme={null} app.post('/webhooks/adcp/*', async (req, res) => { try { await verifyAdcpWebhookSignature(req, { sellerAgentUrl: req.sellerContext.agentUrl, requiredTag: 'adcp/webhook-signing/v1', allowedAlgs: ['ed25519', 'ecdsa-p256-sha256'], }); } catch (err) { return res.status(401) .setHeader('WWW-Authenticate', `Signature error="${err.code}"`) .end(); } const { idempotency_key } = req.body; if (await seen(idempotency_key)) return res.status(200).end(); await process(req.body); res.status(200).end(); }); ``` See the [Security guide](/dist/docs/3.1.20/building/by-layer/L1/security) and [Webhooks guide](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for the full verification profile — required headers, covered components, nonce and date windows, and the negative-vector suite the compliance runner exercises. ## Using the client library The examples above use raw HTTP for clarity. In practice, use the AdCP client library which handles SSE parsing, retries, and authentication: ```bash theme={null} npm install @adcp/sdk # JavaScript/TypeScript pip install adcp # Python ``` ```javascript theme={null} import { ADCPMultiAgentClient } from '@adcp/sdk'; const client = new ADCPMultiAgentClient([{ id: 'test', name: 'Test Agent', agent_uri: 'https://test-agent.adcontextprotocol.org/sales/mcp', protocol: 'mcp', auth_token: process.env.ADCP_AUTH_TOKEN, }]); const result = await client.agent('test').getProducts({ brief: 'Video ads for pet food brand', brand: { domain: 'premiumpetfoods.com' }, }); console.log(result.data.products); ``` ## What's next * **[Build an agent](/dist/docs/3.1.20/building/by-layer/L4/build-an-agent)** — use skill files to generate a storyboard-compliant agent with a coding agent * **[Validate your agent](/dist/docs/3.1.20/building/verification/validate-your-agent)** — test your agent with storyboards and compliance checks * **[Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog)** — the domains and specialisms an agent can claim, and the storyboards that verify each claim * **[MCP integration guide](/dist/docs/3.1.20/building/by-layer/L0/mcp-guide)** — transport, sessions, auth details * **[A2A integration guide](/dist/docs/3.1.20/building/by-layer/L0/a2a-guide)** — streaming, artifacts, push notifications * **[Media buy lifecycle](/dist/docs/3.1.20/media-buy/media-buys/lifecycle)** — state machine, sequenced flow, guaranteed deal IO path, creative sync timing * **[Task reference](/dist/docs/3.1.20/media-buy/task-reference)** — all available tasks with testable examples * **[Error handling](/dist/docs/3.1.20/building/operating/transport-errors)** — error codes, recovery strategies * **[Authentication](/dist/docs/3.1.20/building/by-layer/L2/authentication)** — production credential setup # Migrating from 3.0 to 3.1 Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/3-0-to-3-1 A focused upgrade guide for moving AdCP 3.0 integrations to the 3.1 stable release. # Migrating from 3.0 to 3.1 **3.1 is released.** New 3.1 integrations should pin `"3.1"` after confirming the agent advertises it in `supported_versions`. Existing 3.0 integrations can remain pinned to `"3.0"` while they migrate. 3.1 is a minor release over 3.0. The protocol changes are additive: existing 3.0-conformant agents can remain on `"3.0"` without changing request or response shapes. Use this guide when you are preparing an SDK, buyer, seller, creative agent, signal agent, brand agent, or compliance workflow to claim or consume 3.1. For the feature narrative, start with [What's New in AdCP 3.1](/dist/docs/3.1.20/reference/whats-new-in-3-1). For the full release record, see [Release Notes](/dist/docs/3.1.20/reference/release-notes#version-3-1-0). ## Upgrade checklist | Step | Who | Action | | ---- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | 1 | Everyone | Decide whether this integration is staying on `"3.0"` for now or moving to `"3.1"`. | | 2 | SDKs and hand-rolled clients | Add an `adcp_version` release pin on every request. Send `"3.1"` only to agents that advertise it. | | 3 | Sellers and agents | Advertise every accepted release in `get_adcp_capabilities.adcp.supported_versions` and echo the served release at the envelope root. | | 4 | Buyers | Decode `VERSION_UNSUPPORTED` and retry only against a version that the seller advertised. | | 5 | Hand-rolled buyers | Add `idempotency_key` to read tasks as well as mutating tasks before claiming 3.1 behavior. | | 6 | MCP and A2A adapters | Accept envelope fields and transport wrappers without rejecting unknown root members. | | 7 | Error handlers | Treat `error.code` as an open string and dispatch on `error.recovery` when present. | | 8 | Compliance operators | Run the 3.1 storyboard bundle and keep the 3.0 compatibility line green until the 3.1 badge is issued. | ## Version pinning 3.0 integrations negotiated by major version only. 3.1 adds release-precision negotiation: * Use `"3.0"` for stable 3.0 traffic. * Use `"3.1"` for stable 3.1 traffic after the agent advertises it. * Do not send patch values such as `"3.0.19"` or full semver prereleases such as `"3.1.0-rc.15"` on the wire. Sellers should reject unsupported pins with `VERSION_UNSUPPORTED` and include the supported release list in `error.data.supported_versions`. Buyers should not silently downshift across minor releases. Surface the mismatch or retry against a version the seller explicitly advertised. ## Runtime changes to audit ### Idempotency on every task 3.0 required `idempotency_key` on mutating requests. 3.1 extends the reliability model to every task so retries, replays, and downstream reconciliation behave uniformly. SDK users should pick up this behavior from the SDK release. Hand-rolled buyers should generate a UUID v4 idempotency key for read tasks as well as writes. When a response is marked `replayed: true`, treat it as the historical result of the original operation. If you need fresh state before acting, re-read the relevant resource after handling the replayed response. ### Envelope tolerance 3.1 relies on transport adapters tolerating the AdCP envelope root. MCP and A2A clients should unwrap the transport-specific wrapper first, then read AdCP fields such as `status`, `result`, `errors`, `adcp_version`, `replayed`, and `context`. Unknown envelope members must not cause rejection. ### Error decoding The standard error catalog expanded in 3.1, but `error.code` remains an open string. Clients should: * Accept unknown error codes. * Prefer `error.recovery` when present. * Apply a bounded transient fallback for unknown legacy errors when `error.recovery` is absent. * Handle the auth split between `AUTH_MISSING` and `AUTH_INVALID`. * Treat `CREDENTIAL_IN_ARGS` as terminal and move credentials to the proper transport channel before retrying. ### Proposal and action discovery cleanup 3.1 makes `proposal_status` the proposal source of truth and uses structured action discovery on products and buys. Sellers should not emit the pre-GA `requires_proposal` action mode. Buyers with cached prerelease action metadata should invalidate it and re-read the product, proposal, or buy surface. ### Brand verification signing Brand agents that implement `verify_brand_claim` or `verify_brand_claims` must return signed response evidence. Publish a per-brand response-signing key and bind signatures to the task, resolved brand tenant, responding agent URL, caller/request hash, and validity window. ### Signal targeting Signal identity moves toward `SignalRef`, with product-scoped `included_signals`, `signal_targeting_options`, and buy-time `signal_targeting_groups`. Owned-signal discovery and marketplace activation are separate capabilities. Buyers should call `activate_signal` only when the agent declares marketplace or activation support. ### Creative formats and transformers Creative agents should expose canonical `format_kind` values and discover build units through `list_transformers`. Format-attached input/output/pricing declarations are deprecated in favor of transformer-scoped configuration and pricing. Hosted audio/video slots should use `duration_ms_exact` for fixed durations and `duration_ms_range` for bounded or one-sided ranges. ### Reporting and billing 3.1 adds delivery and usage finality markers, reach-window semantics, `viewability.viewed_seconds`, and the `BILLING_OUT_OF_BAND` error for creative billing outside AdCP. Buyers should avoid treating delivery numbers as final until the relevant finality fields say they are final. ## Role-based migration | Role | Minimum prep before claiming 3.1 | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | SDK buyer | Upgrade to an SDK that emits `adcp_version`, pins `"3.1"` when the seller supports it, adds idempotency keys consistently, and accepts open error codes. | | Hand-rolled buyer | Add release pins, read-task idempotency keys, envelope tolerance, forward-compatible error decoding, and replay handling. | | Seller | Advertise `supported_versions`, echo the served release, tolerate envelope fields, reject unsupported pins with `VERSION_UNSUPPORTED`, and audit idempotency/replay/error behavior. | | Creative agent | Move discovery to `list_transformers`, publish canonical formats, and keep legacy format references only as compatibility aliases. | | Signal agent | Separate owned-signal discovery from marketplace activation; expose product-scoped targeting options only where buyers can select them. | | Brand agent | Publish response-signing keys and return signed evidence for brand verification tasks. | | Compliance operator | Run 3.1 storyboards, retain 3.0 compatibility evidence, and issue version-scoped badges only for the release actually tested. | ## Prerelease artifacts The 3.1 prerelease artifacts remain available for adopters who pinned them during validation, but new integrations should use the stable `"3.1"` wire value. If you built against a prerelease, invalidate cached prerelease-only fields such as `requires_proposal`, re-read current product/proposal/action metadata, and rerun the 3.1 storyboard bundle before claiming the stable release. ## Related * [What's New in AdCP 3.1](/dist/docs/3.1.20/reference/whats-new-in-3-1) * [Release Notes](/dist/docs/3.1.20/reference/release-notes#version-3-1-0) * [Versions & Compatibility](/dist/docs/3.1.20/reference/versions) * [Versioning & Governance](/dist/docs/3.1.20/reference/versioning) * [Version Adaptation](/dist/docs/3.1.20/building/cross-cutting/version-adaptation) # Migrating attribution windows Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/attribution Migrate AdCP attribution windows from v2 to v3. Replaces integer day counts with structured Duration objects and adds required attribution model field. # Migrating attribution windows AdCP 3.0 renames attribution window fields and replaces integer day counts with structured `Duration` objects. An attribution `model` field is now required. ## What changed | v2 field | v3 field | Change type | | ----------------------------- | ----------------------- | ---------------------- | | `click_window_days` (integer) | `post_click` (Duration) | Renamed + type changed | | `view_window_days` (integer) | `post_view` (Duration) | Renamed + type changed | | `model` (required) | `model` (required) | Unchanged | *** ## Attribution window object ### Before (v2) ```json theme={null} { "attribution_window": { "click_window_days": 7, "view_window_days": 1, "model": "last_touch" } } ``` ### After (v3) ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/attribution-window.json", "post_click": { "interval": 7, "unit": "days" }, "post_view": { "interval": 1, "unit": "days" }, "model": "last_touch" } ``` Key differences: * **Field renames** — `click_window_days` → `post_click`, `view_window_days` → `post_view`. The new names describe the user action that starts the lookback window. * **Structured Duration** — Time windows are `{ interval, unit }` objects instead of integer day counts. This enables sub-day granularity (`minutes`, `hours`) and campaign-scoped windows. * **Attribution model** — Required in both v2 and v3. No change needed. *** ## Duration object The `Duration` type is shared across frequency caps, attribution windows, and other time-based settings. ```json theme={null} { "interval": 30, "unit": "minutes" } ``` | Unit | Meaning | | ---------- | ------------------------------------------- | | `minutes` | Clock minutes | | `hours` | Clock hours | | `days` | Calendar days | | `campaign` | Full campaign flight (interval must be `1`) | *** ## Migration steps Replace `click_window_days` with `post_click` and `view_window_days` with `post_view` in all attribution window objects. Replace integer day counts (e.g., `7`) with structured Duration objects (e.g., `{ "interval": 7, "unit": "days" }`). Frequency caps have a similar migration. v2 used `suppress_minutes` (integer). v3 uses `suppress` (Duration object) and adds `max_impressions` with a `window` (Duration). Convert `suppress_minutes: 30` to `suppress: { "interval": 30, "unit": "minutes" }`. Ensure attribution window objects validate against the `attribution-window.json` schema. Configure event sources, send conversion events, and set attribution windows. *** **Related:** [Channels](/dist/docs/3.1.20/reference/migration/channels) | [Pricing](/dist/docs/3.1.20/reference/migration/pricing) | [Geo targeting](/dist/docs/3.1.20/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.1.20/reference/migration/creatives) | [Catalogs](/dist/docs/3.1.20/reference/migration/catalogs) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating audiences Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/audiences Migrate AdCP audiences from beta.3 to rc.1. Promotes external_id to a required top-level field on AudienceMember with stable buyer-assigned identifiers. # Migrating audiences AdCP 3.0 rc.1 promotes `external_id` from a value in the `uid-type` enum to a required top-level field on `AudienceMember`. Every audience member must now have a buyer-assigned stable identifier plus at least one matchable identifier. ## What changed | beta.3 | rc.1 | Notes | | ------------------------------ | ---------------------------------------------- | --------------------------------------------------------- | | `external_id` in uid-type enum | `external_id` required top-level field | Always present on every member | | Optional buyer identifier | Required buyer identifier | Used for deduplication and removal | | Single identifier model | Dual requirement: `external_id` + matchable ID | At least one of `hashed_email`, `hashed_phone`, or `uids` | ## AudienceMember schema ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/audience-member.json", "external_id": "crm_user_12345", "hashed_email": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "uids": [ { "type": "uid2", "value": "uid2_token_abc123" } ] } ``` Two requirements enforced by the schema: 1. `external_id` is **required** — buyer-assigned stable identifier (CRM record ID, loyalty ID) 2. At least one matchable identifier — `hashed_email`, `hashed_phone`, or `uids` array ## Before and after **beta.3 — external\_id as a uid-type entry:** ```json test=false theme={null} { "uids": [ { "type": "external_id", "value": "crm_user_12345" }, { "type": "uid2", "value": "uid2_token_abc123" } ] } ``` **rc.1 — external\_id as required top-level field:** ```json test=false theme={null} { "external_id": "crm_user_12345", "uids": [ { "type": "uid2", "value": "uid2_token_abc123" } ] } ``` ## uid-type enum The `uid-type` enum no longer includes `external_id`. Current values: | Value | Description | | -------- | ------------------------------------------ | | `rampid` | LiveRamp RampID | | `id5` | ID5 universal ID | | `uid2` | Unified ID 2.0 | | `euid` | European Unified ID | | `pairid` | Publisher Addressable Identity (IAB PAIR) | | `maid` | Mobile Advertising ID (IDFA/GAID) | | `other` | Other universal ID type (specify in `ext`) | ## Why the change Separating `external_id` from the uid-type enum makes the buyer's stable identifier explicit. It enables: * **Deduplication** — Remove duplicate members across syncs by `external_id` * **Targeted removal** — Remove specific members without re-uploading the full list * **Cross-referencing** — Correlate audience membership with buyer CRM systems CDPs that don't natively assign IDs can derive one (e.g., hash of the member's identifiers). ## Sync audiences `sync_audiences` uses delta operations (`add`/`remove`) per audience. Members are identified by `external_id` for removal: ```json test=false theme={null} { "account": { "account_id": "acct_pinnacle" }, "audiences": [ { "audience_id": "high_value_customers", "name": "High-Value Customers", "add": [ { "external_id": "crm_user_12345", "hashed_email": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" } ], "remove": [ { "external_id": "crm_user_99999", "hashed_email": "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5" } ] } ] } ``` ## Migration steps Move any `{ "type": "external_id", "value": "..." }` entry from the `uids` array to the top-level `external_id` field. If members don't have a buyer-assigned ID, derive one (e.g., hash of identifiers). The schema requires `external_id` on every member. Every member must also have at least one of `hashed_email`, `hashed_phone`, or `uids`. This is enforced by the schema's `anyOf` constraint. When removing members via `sync_audiences`, use `external_id` as the stable key. The member still needs a matchable identifier in the `remove` array. Run member objects against `audience-member.json` schema. It enforces both `external_id` (required) and the matchable identifier constraint. Full reference for signal discovery, activation, and pricing models. *** **Related:** [Brand identity](/dist/docs/3.1.20/reference/migration/brand-identity) | [Optimization goals](/dist/docs/3.1.20/reference/migration/optimization-goals) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating brand identity Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/brand-identity Migrate AdCP brand identity from beta.3 to rc.1. Replaces inline brand_manifest objects with BrandRef references resolved via brand.json or the community registry. # Migrating brand identity AdCP 3.0 rc.1 replaces inline `brand_manifest` objects with lightweight brand references (`BrandRef`). Brand data is resolved at execution time from `/.well-known/brand.json` or the community brand registry. ## What changed | beta.3 | rc.1 | Notes | | ---------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------- | | `brand_manifest` (inline object) | `brand` (`BrandRef`) | Reference resolved at execution time | | Brand data passed in every request | Brand data fetched once from `brand.json` | Caching recommended (24h TTL) | | No standard identity format | `/.well-known/brand.json` specification | Four variants: House Portfolio, Brand Agent, House Redirect, Authoritative Location | ## BrandRef schema A brand reference identifies a brand by domain and optional `brand_id`: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/brand-ref.json", "domain": "nova-brands.com", "brand_id": "spark" } ``` * `domain` (required) — Domain where `/.well-known/brand.json` is hosted, or the brand's operating domain * `brand_id` (optional) — Brand identifier within a house portfolio. Omit for single-brand domains. For single-brand domains: ```json theme={null} { "domain": "acme-corp.com" } ``` ## Where brand appears | Task | Required? | Purpose | | ------------------ | --------- | ------------------------------------------------------- | | `create_media_buy` | Yes | Campaign identity — immutable once set | | `get_products` | No | Discovery context. Required when `catalog` is provided | | `build_creative` | No | Resolves to colors, logos, tone for creative generation | `brand` does not appear on `update_media_buy` (immutable from creation), `sync_creatives` (scoped to account), or `sync_catalogs` (scoped to account). ## Before and after **beta.3 — inline brand\_manifest on create\_media\_buy:** ```json test=false theme={null} { "brand_manifest": { "name": "Spark", "domain": "spark.nova-brands.com", "logo_url": "https://spark.nova-brands.com/logo.png", "industries": ["technology.hardware"] }, "account": { "account_id": "acct_nova" }, "start_time": "2025-04-01T00:00:00Z", "end_time": "2025-04-30T23:59:59Z" } ``` **rc.1 — brand reference:** ```json test=false theme={null} { "brand": { "domain": "nova-brands.com", "brand_id": "spark" }, "account": { "account_id": "acct_nova" }, "start_time": "2025-04-01T00:00:00Z", "end_time": "2025-04-30T23:59:59Z" } ``` The seller resolves `nova-brands.com` + `spark` to the full brand identity (logos, colors, tone, properties) via the Brand Protocol. ## Resolution flow Given a `BrandRef`, the resolution is: 1. Fetch `https://{domain}/.well-known/brand.json` 2. If it's a **House Redirect** (has `house` field), follow to the house domain (max 3 redirects) 3. If it's a **House Portfolio**, look up the brand by `brand_id` in the `brands` array 4. If it's a **Brand Agent**, call the MCP agent for dynamic brand data 5. If it's an **Authoritative Location**, follow the `location` URL Sellers cache resolved brand data (recommended 24-hour TTL for validated files, 1 hour for failed lookups). ## Migration steps In all task requests (`create_media_buy`, `get_products`, `build_creative`), replace the `brand_manifest` object with a `brand` BrandRef: `{ "domain": "...", "brand_id": "..." }`. Publish `/.well-known/brand.json` on the brand's domain. Choose a variant: House Portfolio for multi-brand companies, Brand Agent for dynamic data, or single-brand for simple cases. If the brand domain doesn't host `brand.json`, register it in the community brand registry so sellers can resolve it. Seller agents must resolve `BrandRef` to full brand data at execution time. Implement caching and handle all four `brand.json` variants. Remove any code that constructs or parses `brand_manifest` objects. Brand data is no longer passed inline. Run requests against `brand-ref.json` schema. The `domain` field requires a valid lowercase domain pattern. Full reference for brand.json variants, the brand registry, and BrandRef resolution. *** **Related:** [Channels](/dist/docs/3.1.20/reference/migration/channels) | [Catalogs](/dist/docs/3.1.20/reference/migration/catalogs) | [Brand Protocol](/dist/docs/3.1.20/brand-protocol) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating catalogs Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/catalogs Migrate AdCP catalogs from v2 to v3. Replaces promoted_offerings with first-class catalog objects, sync_catalogs task, and format-level catalog requirements. # Migrating catalogs AdCP 3.0 removes the `promoted_offerings` creative asset type and the `promoted_offering` string field from media buys and creative manifests. Catalogs are now first-class protocol objects with their own sync task, format-level requirements, and conversion event alignment. ## What changed | v2 field | v3 field | Change type | | ------------------------------------------------- | ---------------------------------------------- | ----------- | | `promoted_offerings` (creative asset type) | `catalog` assets in creative manifest `assets` | Replaced | | `promoted_offering` (string on media-buy) | Removed | Removed | | `promoted_offering` (string on creative-manifest) | `catalog` assets in `assets` | Replaced | | No catalog sync | `sync_catalogs` task | New | | No format catalog requirements | `catalog` asset type on Format `assets` | New | | No catalog-event linking | `conversion_events` on Catalog | New | *** ## Creative manifests ### What changed In v2, catalog data was embedded inside `creative_manifest.assets` as a `promoted_offerings` object that bundled brand identity, inline offerings, and SI agent URL together. In v3, brand identity is a first-class parameter on tasks (`build_creative`, `sync_creatives`), and catalogs are included as `catalog` asset types in the manifest's `assets`. ### Before (v2) ```json theme={null} { "creative_id": "product-carousel", "format_id": { "agent_url": "https://creatives.example.com", "id": "product_carousel" }, "assets": { "promoted_offerings": { "brand": { "domain": "acmecorp.example.com" }, "offerings": [ { "offering_id": "winter-sale", "name": "Winter Sale Collection", "description": "50% off all winter items" } ] } } } ``` ### After (v3) ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creatives.example.com", "id": "product_carousel" }, "assets": { "product_catalog": { "asset_type": "catalog", "type": "product", "catalog_id": "winter-products" } } } ``` Key differences: * **Brand identity** comes from the `brand` parameter on the task, not embedded in creative assets * **Catalogs** are `catalog` asset types in the `assets` map, keyed by a role name (e.g., `product_catalog`) * **Catalog references synced data** by `catalog_id` instead of inlining items (though inline items are still supported for simple cases) *** ## Media buys ### What changed The `promoted_offering` string field is removed from media buy objects. What's being promoted is now expressed through the `brief` on `get_products`/`create_media_buy` and through catalog references on creatives. ### Before (v2) ```json theme={null} { "media_buy_id": "mb_123", "promoted_offering": "Winter Sale Collection", "status": "draft", "total_budget": { "amount": 10000, "currency": "USD" }, "packages": [] } ``` ### After (v3) ```json theme={null} { "media_buy_id": "mb_123", "status": "draft", "total_budget": { "amount": 10000, "currency": "USD" }, "packages": [] } ``` If you were using `promoted_offering` for reporting or display purposes, that context now comes from: * The `brief` field on `get_products` and `create_media_buy` * The `brand` parameter on tasks * Catalog assets in creative manifest `assets` *** ## Syncing catalogs (new in v3) The `sync_catalogs` task lets buyers push catalog data to sellers before submitting creatives. This replaces the pattern of embedding product data inside creative assets. Key features: * **Bulk sync** with per-catalog results * **Async approval** workflow (working, input-required, submitted) * **Item-level review** with approve/reject per catalog item * **Discovery mode** — omit the catalogs field to see existing synced catalogs * **Validation modes** — strict, lenient, or dry\_run ### Workflow ``` list_creative_formats → check catalog assets → sync_catalogs → sync_creatives with catalog assets ``` 1. Discover what catalogs a format needs via `catalog` asset types in the format's `assets` 2. Sync those catalogs using `sync_catalogs` 3. Wait for approval if the seller requires review 4. Submit creatives that reference the synced `catalog_id` Complete reference including all 13 catalog types, sync workflow, and item-level review. *** ## Format catalog requirements (new in v3) Formats declare catalog needs as `catalog` asset types in their `assets` array: ```json theme={null} { "assets": [ { "item_type": "individual", "asset_id": "product_catalog", "asset_type": "catalog", "required": true, "requirements": { "catalog_type": "product", "min_items": 3, "required_fields": ["name", "price", "image_url"] } } ] } ``` Format declarations use `asset_type: "catalog"` with a `requirements` object containing: | Field | Type | Description | | ----------------- | --------- | ----------------------------------------------------------------------- | | `catalog_type` | string | Required. The catalog type (e.g., `product`, `store`, `job`) | | `min_items` | integer | Minimum items the catalog must contain | | `max_items` | integer | Maximum items the format can render | | `required_fields` | string\[] | Fields that must be present on every item | | `feed_formats` | string\[] | Accepted feed formats (e.g., `google_merchant_center`, `linkedin_jobs`) | This replaces the v2 pattern where formats implicitly required `promoted_offerings` in their assets — the requirement is now explicit and discoverable. ### Creative agent migration Creative agents that read format definitions to determine catalog requirements need to change how they discover and fulfill catalog slots. **Before (v2)** — checking a dedicated `catalog_requirements` field: ```javascript theme={null} // v2: catalog requirements were a separate top-level array for (const req of format.catalog_requirements) { const catalogType = req.catalog_type; const minItems = req.min_items; } ``` **After (v3)** — iterating the `assets` array and filtering by `asset_type`: ```javascript theme={null} // v3: catalogs are assets like any other const catalogAssets = format.assets.filter(a => a.asset_type === "catalog"); for (const slot of catalogAssets) { const catalogType = slot.requirements.catalog_type; const minItems = slot.requirements.min_items; const slotId = slot.asset_id; // use as key in manifest.assets } ``` When building a manifest, catalog assets are keyed by their `asset_id` from the format definition: ```json theme={null} { "assets": { "product_catalog": { "type": "product", "catalog_id": "winter-products" } } } ``` The `asset_id` (e.g., `product_catalog`) from the format's `assets` array becomes the key in the manifest's `assets` object. *** ## Conversion event alignment (new in v3) Catalogs declare which event types represent conversions for their items: ```json theme={null} { "catalog_id": "job-feed", "type": "job", "content_id_type": "job_id", "conversion_events": ["submit_application", "complete_registration"] } ``` This links catalogs to the conversion tracking system. When a `log_event` is sent with `content_ids` matching catalog items, the platform knows which events to attribute. The `content_id_type` field declares what identifier type `content_ids` values represent. For vertical catalogs, this matches the item's canonical ID field (`job_id`, `hotel_id`, etc.). For product catalogs, it distinguishes between `sku` and `gtin` for cross-retailer matching. Omit when using a custom identifier scheme. | Catalog type | Typical conversion events | | ------------- | --------------------------------------------- | | `product` | `purchase`, `add_to_cart` | | `hotel` | `purchase` (booking) | | `flight` | `purchase` (booking) | | `job` | `submit_application` | | `vehicle` | `lead`, `schedule` | | `real_estate` | `lead`, `schedule` | | `education` | `submit_application`, `complete_registration` | | `destination` | `purchase` (booking) | Full documentation on catalog-event alignment. *** ## product\_selectors replaced by catalog on get\_products The `product_selectors` field on `get_products` has been replaced by `catalog`. The `PromotedProducts` schema has been removed. ### Before ```json theme={null} { "brand": { "domain": "acmecorp.com" }, "product_selectors": { "manifest_gtins": ["00013000006040"], "manifest_tags": ["organic"] } } ``` ### After ```json theme={null} { "brand": { "domain": "acmecorp.com" }, "catalog": { "type": "product", "gtins": ["00013000006040"], "tags": ["organic"] } } ``` ### Field mapping | Old (`product_selectors`) | New (`catalog`) | | ------------------------- | --------------- | | `manifest_gtins` | `gtins` | | `manifest_skus` | `ids` | | `manifest_tags` | `tags` | | `manifest_category` | `category` | | `manifest_query` | `query` | ### Response changes * `product_selectors_applied` is now `catalog_applied` * `catalog_match.matched_skus` is now `catalog_match.matched_ids` *** ## New fields on Product * **`catalog_types`** — Array of catalog types this product supports (e.g., `["product"]`, `["job", "offering"]`) * **`catalog_match.matched_ids`** — Generic item ID matches (replaces `matched_skus`) * **`catalog_match.matched_count`** — Count of matched items *** ## Catalog on packages Packages now accept a `catalog` field for catalog-driven campaigns. One budget envelope promotes an entire catalog, with the platform optimizing delivery across items. *** ## Store catchment targeting `targeting_overlay` now supports `store_catchments` — referencing synced store catalogs for proximity targeting. *** ## Per-catalog-item delivery `get_media_buy_delivery` now includes `by_catalog_item` breakdowns within packages for catalog-driven campaigns. *** ## Migration steps Remove `promoted_offerings` objects from `creative_manifest.assets`. Brand identity is now provided via the `brand` task parameter. For creatives that render catalog items (product carousels, store locators, etc.), add catalog assets to the manifest's `assets` map. Each catalog asset is a catalog object with `type` and `catalog_id` (e.g., `{ "type": "product", "catalog_id": "winter-products" }`). Remove the `promoted_offering` string from `create_media_buy` requests. Use the `brief` field to describe what's being promoted. Call `list_creative_formats` and check for `catalog` asset types in each format's `assets` to understand what catalog types and fields are needed. Use the `sync_catalogs` task to push catalog data to sellers. Handle async approval if the seller requires review. Include `conversion_events` on catalogs to link catalog items to your conversion tracking setup. Add `content_id_type` to declare what identifier type (e.g., `sku`, `gtin`, `job_id`) the event's `content_ids` should be matched against. Ensure creative manifests, media buys, and catalog objects validate against v3 schemas. *** **Related:** [Channels](/dist/docs/3.1.20/reference/migration/channels) | [Pricing](/dist/docs/3.1.20/reference/migration/pricing) | [Geo targeting](/dist/docs/3.1.20/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.1.20/reference/migration/creatives) | [Attribution](/dist/docs/3.1.20/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating channels Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/channels Migrate AdCP channels from v2 to v3. Maps v2's 9 format-oriented channels to v3's 20 planning-oriented media channels with field-by-field examples. # Migrating channels AdCP 3.0 replaces v2's 9 channels with 20 planning-oriented channels that reflect how buyers allocate budgets. Five channels carry over unchanged, the rest are split, removed, or renamed, and several channels — including `sponsored_intelligence` for AI surfaces — are net-new in v3 with no v2 equivalent. ## Why the channel model changed v2 mixed format-oriented channels (`video`, `audio`, `native`) with planning-oriented ones (`social`, `ctv`, `dooh`). Buyers don't plan budgets around rendering technology — they plan around media types. A video budget gets split between OLV, CTV, and cinema. An audio budget spans radio, streaming, and podcasts. v3 channels consistently match how agencies structure media plans. ## Channel mapping | v2 channel | v3 channel(s) | Notes | | ---------- | ---------------------------- | ------------------------------------------------------------ | | `display` | `display` | Unchanged | | `video` | `olv`, `linear_tv`, `cinema` | Split by distribution (`ctv` was already separate in v2) | | `audio` | `radio`, `streaming_audio` | Split by distribution (`podcast` was already separate in v2) | | `native` | Removed | Use format-level properties instead | | `social` | `social` | Unchanged | | `ctv` | `ctv` | Unchanged | | `podcast` | `podcast` | Unchanged | | `dooh` | `dooh` | Unchanged | | `retail` | `retail_media` | Renamed for clarity | ## Complete v3 channel enum All 20 values from the `channels.json` schema: ``` display, olv, social, search, ctv, linear_tv, radio, streaming_audio, podcast, dooh, ooh, print, cinema, email, gaming, retail_media, influencer, affiliate, product_placement, sponsored_intelligence ``` | Channel | Description | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `display` | Digital display advertising (banners, native, rich media) across web and app | | `olv` | Online video advertising outside CTV (pre-roll, outstream, in-app video) | | `social` | Social media platforms | | `search` | Search engine advertising and search networks | | `ctv` | Connected TV and streaming on television screens | | `linear_tv` | Traditional broadcast and cable television | | `radio` | Traditional AM/FM radio broadcast | | `streaming_audio` | Digital audio streaming services | | `podcast` | Podcast advertising (host-read or dynamically inserted) | | `dooh` | Digital out-of-home screens in public spaces | | `ooh` | Classic out-of-home (physical billboards, transit, etc.) | | `print` | Newspapers, magazines, and other print publications | | `cinema` | Movie theater advertising | | `email` | Email advertising and sponsored newsletter content | | `gaming` | In-game advertising across platforms (intrinsic in-game, rewarded video, playable ads) | | `retail_media` | Retail media networks and commerce marketplaces | | `influencer` | Creator and influencer marketing partnerships | | `affiliate` | Affiliate networks, comparison sites, and performance-based partnerships | | `product_placement` | Product placement, branded content, and sponsorship integrations | | `sponsored_intelligence` | Sponsored Intelligence — advertising within AI assistants, AI search, and generative AI experiences via the reversed data flow (net-new in v3; no v2 equivalent) | The `gaming` channel covers all in-game advertising. Rewarded video in gaming apps could also be classified as `olv` — use `gaming` when the inventory comes from a gaming budget, `olv` when it comes from a video budget. ## Migrating `video` products The v2 `video` channel must be split across 3 v3 channels based on distribution (`ctv` was already a separate v2 channel): * **`olv`** — Online video (web, mobile app, social): pre-roll, outstream, in-app video * **`linear_tv`** — Traditional broadcast and cable television * **`cinema`** — Movie theater pre-show advertising A single publisher may support multiple channels. A streaming service might declare both `olv` and `ctv` if their inventory serves both mobile and TV. ## Migrating `audio` products The v2 `audio` channel splits into 2 v3 channels (`podcast` was already a separate v2 channel): * **`radio`** — Traditional AM/FM radio broadcast * **`streaming_audio`** — Digital audio streaming services (music platforms) ## Migrating `native` products v3 removes `native` as a channel. Native is a rendering style, not a budget category. If you have products tagged `native` in v2, assign the appropriate v3 channel based on how the budget is planned: * Native display ads -> `display` * Native social ads -> `social` * Native content recommendations -> `display` or `affiliate` depending on model ## Migrating `retail` products Simple rename from `retail` to `retail_media`. ## Multi-channel products v3 products declare an array of channels, so a single product can span multiple channels. Here's a capabilities response showing a seller that supports multiple channels: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["media_buy"], "account": { "supported_billing": ["operator", "agent"] }, "media_buy": { "portfolio": { "primary_channels": ["display", "olv", "ctv"], "publisher_domains": ["pinnaclemedia.example.com"], "primary_countries": ["US"] } } } ``` When products are returned from `get_products`, each product's `channels` array declares which channels that product is sold as: ```json theme={null} { "channels": ["olv", "ctv"] } ``` ## Declaring channel support in capabilities Buyers should call `get_adcp_capabilities` to discover which channels a seller supports before filtering `get_products` requests. The `portfolio.primary_channels` field lists the seller's main channels. ## Migration steps Find all places your code reads or writes channel values. Use the mapping table above to convert each value. Note that `display`, `social`, `ctv`, `podcast`, and `dooh` are unchanged. Where v2 `video` or `audio` maps to multiple v3 channels, classify each product by its distribution context. If your buyer agent filters by channel, update to the new values. Implement `get_adcp_capabilities` with correct channel declarations. v3 schema validation will reject old channel values like `video`, `audio`, `native`, and `retail`. Full definitions and design rationale for all 20 v3 channels. *** **Related:** [Pricing](/dist/docs/3.1.20/reference/migration/pricing) | [Geo targeting](/dist/docs/3.1.20/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.1.20/reference/migration/creatives) | [Catalogs](/dist/docs/3.1.20/reference/migration/catalogs) | [Attribution](/dist/docs/3.1.20/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating to creative transformers (3.1) Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/creative-transformers Move build-capability discovery and creative pricing off Format fields onto transformers (list_transformers). Additive in 3.1; deprecated Format fields removed at 4.0. # Migrating to creative transformers (3.1) AdCP 3.1 introduces **transformers** — the creative analog of a media-buy product: an agent-offered, account-scoped, selectable unit of build capability (a voice, model, style, or director) with a typed configuration surface and per-account pricing, discovered via [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers) and selected with `transformer_id` on [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative). Because a transformer carries **its own input/output formats and its own pricing**, three things that 3.0 hung on the *format* are now redundant and are deprecated: * `Format.input_format_ids` / `Format.output_format_ids` * `Format.pricing_options` * the `input_format_ids` / `output_format_ids` discovery **filters** on `list_creative_formats` **Nothing breaks if you do nothing in 3.1.** All four are `deprecated: true` but still validate and still function through the 3.1–3.x line. Act before 4.0. The hazards below are about *silent degradation* over the window, not hard breaks. ## What changed | Surface | 3.0 | 3.1 | Removed | | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------- | | Build-capability discovery | filter `list_creative_formats` on `input_format_ids` / `output_format_ids`; read `Format.input_format_ids` / `output_format_ids` | discover via `list_transformers` (each transformer declares its own I/O signature) | 4.0 | | Creative/transform pricing | `Format.pricing_options` (per format) | `transformer.pricing_options` (per transformer; `include_pricing` + `account` on `list_transformers`) | 4.0 | | `list_creative_formats` `input_format_ids` / `output_format_ids` filters | supported | `deprecated: true` — use `list_transformers` filters + `brief` | 4.0 | ## Migrate ### Format I/O signature → transformer A transformer declares the formats it accepts and produces directly, so build capability is a property of the *selectable unit*, not a relationship hung on a format. **Before (3.0)** — a transform format declares what it consumes/produces: ```json test=false theme={null} { "format_id": { "agent_url": "https://creative.example", "id": "resize_to_meta" }, "input_format_ids": [{ "agent_url": "https://creative.example", "id": "display_master" }], "output_format_ids": [{ "agent_url": "https://creative.example", "id": "meta_reels_9x16" }] } ``` **After (3.1)** — declare a transformer (discovered via `list_transformers`): ```json test=false theme={null} { "transformer_id": "resize_to_meta", "name": "Resize to Meta Reels", "input_format_ids": [{ "agent_url": "https://creative.example", "id": "display_master" }], "output_format_ids": [{ "agent_url": "https://creative.example", "id": "meta_reels_9x16" }], "params": [], "pricing_options": [ { "pricing_option_id": "per_format", "model": "per_unit", "unit": "format", "unit_price": 0.10, "currency": "USD" } ] } ``` ### Format pricing → transformer pricing `Format.pricing_options` moves to `transformer.pricing_options` (same `vendor-pricing-option` shape; `per_unit` is typical). The applied option is echoed per-leaf on the `build_creative` response and reconciled via `report_usage`, unchanged. For multi-output transformers, a pricing option can carry `applies_to_output_format_ids` to scope that rate to specific outputs. An unscoped pricing option is the default for any output. If a build targets an output that matches no scoped option and the transformer has no unscoped default, the agent rejects the build with `UNPRICEABLE_OUTPUT` rather than guessing a price. ### `list_creative_formats` discovery filters → `list_transformers` Stop filtering `list_creative_formats` on `input_format_ids` / `output_format_ids` to find "what can build X". Use `list_transformers` — filter on its `input_format_ids` / `output_format_ids`, narrow with `brief`, and expand account-scoped option values with `expand_params`. ## What breaks silently if you do nothing These do not throw — which is exactly why they are the reason this guide exists. 1. **Discovery-read degradation (buyers).** If your buyer/storyboard learns build capability *only* by reading `Format.input_format_ids` / `output_format_ids` or by filtering `list_creative_formats` on them, you keep working against a 3.1 seller but see **progressively emptier results** as sellers stop emitting the deprecated fields and move capability onto transformers. No error — just fewer and fewer options. **The field outlives its data.** Move discovery to `list_transformers`. 2. **Per-output pricing gaps (multi-publisher template sellers).** A template that priced outputs differently can keep that shape with `applies_to_output_format_ids`, but every output needs either a scoped pricing option or an unscoped default. Otherwise builds for unpriced outputs are rejected with `UNPRICEABLE_OUTPUT`. 3. **Fan-out / best-of-N spend under-counting (billing pipelines).** With `max_variants` / `max_creatives`, every produced variant is billed, but only a **trafficked** leaf lazily earns a `creative_id`. Discarded best-of-N leaves are billed via the **inline per-leaf `vendor_cost`** on the `build_creative` response *only* — they never earn a `creative_id` and so never appear in `report_usage`. A pipeline that reconciles spend solely from `report_usage` (the 3.0 invariant "every billed unit reconciles via `creative_id`") will **under-count** by every discarded variant. Ingest the inline per-leaf receipts as the authoritative record for untrafficked leaves. Because that receipt is the sole record, the schema enforces it: whenever a build reports cost (the aggregate `vendor_cost` is present), every produced leaf MUST carry its own `vendor_cost` + `currency`, so a paid build cannot return leaves with no machine-readable cost. Note the boundary: the schema enforces the *internal completeness and consistency* of a reported cost — it cannot tell a genuinely free build (or a legitimately-`0` CPM-deferred leaf) from a paid build that omits the aggregate to under-report. "Aggregate absent" is a trust assertion to reconcile (seller invoice vs. `report_usage` + inline receipts, distinguished by the `pricing_option_id` basis), not proof of zero cost — don't mistake schema-validity for billing-honesty. ## SDK behavior and timeline | Version | Deprecated fields | | ------- | ------------------------------------------------------------------------------- | | 3.1 | `deprecated: true`; still emitted and honored. SDKs MUST continue to read them. | | 3.1–3.x | Honored throughout. New code SHOULD NOT emit them. | | 4.0+ | SDKs MAY reject. Removed. | ## See also * [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers) — discover transformers, their params, option values, and pricing * [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) — select a transformer, configure, multiply, refine * [What's new in 3.1](/dist/docs/3.1.20/reference/whats-new-in-3-1) # Migrating creatives Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/creatives Migrate AdCP creatives from v2 to v3. Replaces creative_ids with weighted assignments and assets_required with a unified assets array for format discovery. # Migrating creatives AdCP 3.0 makes three breaking changes to creative handling: the `FormatCategory` enum and format `type` field are removed, weighted creative assignments replace simple ID arrays, and a unified `assets` array replaces `assets_required` for format discovery. ## Format category removal ### What changed | v2 field | v3 field | Change type | | ----------------------------------------------------------------- | -------- | ----------- | | `type` on format objects (FormatCategory enum) | Removed | Deleted | | `format_types` filter on `list_creative_formats` / `get_products` | Removed | Deleted | ### Why The `FormatCategory` enum (`video`, `display`, `audio`, `native`, `social`, `custom`) was a coarse classifier that didn't map well to multi-asset formats. A "video" format might also require display companion banners and text overlays. The enum forced implementors to pick one category for inherently multi-modal formats, which led to inconsistent filtering and discovery gaps. ### Migration Replace `format_types` filters with `asset_types` (what the format needs) or `format_ids` (exact match): **v2:** ```json theme={null} { "format_types": ["video"] } ``` **v3 — filter by asset type:** ```json theme={null} { "asset_types": ["video"] } ``` **v3 — filter by exact format ID:** ```json theme={null} { "format_ids": [ { "agent_url": "https://creatives.example.com", "id": "video_preroll_30s" } ] } ``` `asset_types` returns any format that includes at least one asset of that type — so a video format with companion banners appears in both `["video"]` and `["image"]` results. *** ## Creative assignments ### What changed | v2 field | v3 field | Change type | | ----------------------------- | ------------------------------------- | ----------- | | `creative_ids` (string array) | `creative_assignments` (object array) | Replaced | ### Simple migration (equal weights) **v2:** ```json theme={null} { "creative_ids": ["creative_1", "creative_2"] } ``` **v3 — omit `weight` for equal distribution:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/creative-assignment.json", "creative_id": "creative_1" } ``` In context of a `create_media_buy` package: ```json theme={null} { "creative_assignments": [ { "creative_id": "creative_1" }, { "creative_id": "creative_2" } ] } ``` When `weight` is omitted on all assignments, impressions are distributed equally. ### Weighted assignments Control what percentage of impressions each creative receives: ```json theme={null} { "creative_assignments": [ { "creative_id": "hero_video", "weight": 60 }, { "creative_id": "promo_video", "weight": 40 } ] } ``` Weights are relative — they don't need to sum to 100, but doing so makes intent clear. ### Placement targeting Assign specific creatives to specific placements within a product: ```json theme={null} { "creative_assignments": [ { "creative_id": "hero_video", "placement_ids": ["homepage_hero"], "weight": 100 }, { "creative_id": "sidebar_banner", "placement_ids": ["article_sidebar"], "weight": 100 }, { "creative_id": "fallback_banner", "weight": 50 } ] } ``` When `placement_ids` is omitted, the creative runs on all placements in the package. `placement_ids` references `placement_id` values from the product's placements array. `sync_creatives` does not support `placement_ids`. Use `create_media_buy` or `update_media_buy` for placement-level targeting. ### Creative assignment schema Each assignment object: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/creative-assignment.json", "creative_id": "hero_video", "weight": 60, "placement_ids": ["homepage_hero"] } ``` | Field | Type | Required | Description | | --------------- | -------------- | -------- | --------------------------------------------------------------- | | `creative_id` | string | Yes | References a creative from `list_creatives` or `sync_creatives` | | `weight` | number (0-100) | No | Delivery weight. Omit for equal distribution. | | `placement_ids` | string\[] | No | Target specific placements. Omit to run everywhere. | *** ## Asset discovery ### What changed | v2 field | v3 field | Change type | | -------------------------------- | ----------------------- | ----------- | | `assets_required` (string array) | `assets` (object array) | Replaced | | `preview_image` | `format_card` | Replaced | ### Format assets **v2** — only listed required asset IDs: ```json theme={null} { "format_id": { "agent_url": "https://creatives.example.com", "id": "display_300x250" }, "name": "Standard Banner 300x250", "assets_required": ["main_image", "headline"] } ``` **v3** — lists ALL assets with `required` boolean: ```json theme={null} { "format_id": { "agent_url": "https://creatives.example.com", "id": "display_300x250" }, "name": "Standard Banner 300x250", "assets": [ { "item_type": "individual", "asset_id": "main_image", "asset_type": "image", "required": true, "requirements": { "min_width": 300, "min_height": 250, "accepted_mime_types": ["image/png", "image/jpeg"] } }, { "item_type": "individual", "asset_id": "headline", "asset_type": "text", "required": true, "requirements": { "max_length": 50 } }, { "item_type": "individual", "asset_id": "impression_tracker", "asset_type": "url", "required": false, "requirements": { "role": "impression_tracker" } } ] } ``` ### What the assets array provides * **Full discovery** — see ALL assets a format supports, not just required ones * **Type information** — each asset declares its `asset_type` (image, video, text, url, etc.) * **Requirements** — inline constraints (dimensions, length, MIME types) * **Optional assets** — trackers, companion banners, and other optional elements are now visible * **Repeatable groups** — carousel and multi-item formats use `item_type: "repeatable_group"` ### Asset item types Each entry in the `assets` array has an `item_type` discriminator: **Individual assets** (`item_type: "individual"`): ```json theme={null} { "item_type": "individual", "asset_id": "main_video", "asset_type": "video", "required": true, "requirements": { "min_duration_seconds": 15, "max_duration_seconds": 30, "accepted_mime_types": ["video/mp4"] } } ``` **Repeatable groups** (`item_type: "repeatable_group"`) for carousels and multi-item formats: ```json theme={null} { "item_type": "repeatable_group", "asset_group_id": "card", "required": true, "min_count": 2, "max_count": 10, "selection_mode": "sequential", "assets": [ { "asset_id": "card_image", "asset_type": "image", "required": true, "requirements": { "min_width": 600, "min_height": 600, "aspect_ratio": "1:1" } }, { "asset_id": "card_title", "asset_type": "text", "required": true, "requirements": { "max_length": 40 } } ] } ``` ### Format cards (replacing preview\_image) v2's `preview_image` URL is replaced by `format_card`, which uses the creative rendering system: ```json theme={null} { "format_card": { "format_id": { "agent_url": "https://creatives.example.com", "id": "format_card_standard" }, "manifest": { "format_name": "Standard Banner 300x250", "preview_url": "https://cdn.example.com/previews/banner_300x250.png" } } } ``` *** ## Migration steps ### Format category Remove `format_types` from `list_creative_formats` and `get_products` requests. Use `asset_types` to filter formats by what assets they accept (e.g., `["video"]`, `["image"]`). Use `format_ids` for exact format matching when you know the specific formats you need. Remove any code that reads the `type` field from format objects — it no longer exists in v3. ### Creative assignments Replace `creative_ids` arrays with `creative_assignments` object arrays. Set `creative_id` on each assignment object. Add weights if you need non-equal distribution, otherwise omit `weight`. Add `placement_ids` if you need placement-level targeting. These use `creative_assignments` too, but without `placement_ids`. ### Asset discovery Replace `assets_required` parsing with `assets` array iteration. Check `required` boolean on each asset instead of assuming all listed assets are required. Use `asset_type` to understand what kind of file each asset expects. Check for `"individual"` vs `"repeatable_group"`. Replace `preview_image` reads with `format_card` rendering. Creative manifests must use exact `asset_id` values as keys. Full creative documentation: formats, asset types, manifests, and creative agents. *** **Related:** [Channels](/dist/docs/3.1.20/reference/migration/channels) | [Pricing](/dist/docs/3.1.20/reference/migration/pricing) | [Geo targeting](/dist/docs/3.1.20/reference/migration/geo-targeting) | [Catalogs](/dist/docs/3.1.20/reference/migration/catalogs) | [Attribution](/dist/docs/3.1.20/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating geo targeting Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/geo-targeting Migrate AdCP geo targeting from v2 to v3. Replaces implicit US-centric targeting with named systems (Nielsen DMA, postal codes) for global market support. # Migrating geo targeting AdCP 3.0 replaces implicit US-centric geo targeting with named systems that support global markets. Metro targeting values are grouped by system, and postal targeting values are grouped by country plus the country-local postal system. ## What changed | v2 field | v3 field | Change type | | --------------------------------- | -------------------------------------------------- | ------------------------ | | `geo_metros` (string array) | `geo_metros` (system/values objects) | Restructured | | `geo_postal_codes` (string array) | `geo_postal_areas` (country/system/values objects) | Renamed and restructured | Fields that didn't change: `geo_countries`, `geo_countries_exclude`, `geo_regions`, `geo_regions_exclude` all remain simple string arrays using ISO codes. ## Metro targeting **v2** — flat array of codes, assumed to be Nielsen DMAs: ```json theme={null} { "targeting": { "geo_metros": ["501", "602"] } } ``` **v3** — codes grouped by system: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/targeting.json", "geo_metros": [ { "system": "nielsen_dma", "values": ["501", "602"] } ] } ``` Each entry specifies a system and the codes within that system. Multiple systems can coexist: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/targeting.json", "geo_metros": [ { "system": "nielsen_dma", "values": ["501", "602"] }, { "system": "uk_itl2", "values": ["UKC1", "UKD3"] } ] } ``` ### Metro systems | System | Coverage | Example codes | | ---------------- | -------------------------- | ------------------------------------------------- | | `nielsen_dma` | US designated market areas | `501` (New York), `602` (Chicago) | | `uk_itl1` | UK regions | `UKC` (North East), `UKD` (North West) | | `uk_itl2` | UK sub-regions | `UKC1` (Tees Valley), `UKD3` (Greater Manchester) | | `eurostat_nuts2` | EU statistical regions | `DE11` (Stuttgart), `FR10` (Ile-de-France) | | `custom` | Publisher-defined areas | Publisher-specific codes | Registered metro systems are `nielsen_dma`, `uk_itl1`, `uk_itl2`, `eurostat_nuts2`, and `custom`. ## Postal targeting **v2** — flat array of codes, assumed to be US ZIP codes: ```json theme={null} { "targeting": { "geo_postal_codes": ["10001", "90210"] } } ``` **v3** — renamed to `geo_postal_areas`, codes grouped by country-local system: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/targeting.json", "geo_postal_areas": [ { "country": "US", "system": "zip", "values": ["10001", "90210"] } ] } ``` ### Postal systems | Country | System | Precision | Example codes | | ------- | --------------- | ---------------------- | ---------------- | | `US` | `zip` | 5-digit ZIP | `10001`, `90210` | | `US` | `zip_plus_four` | ZIP+4 | `10001-1234` | | `GB` | `outward` | Outward postcode | `SW1`, `EC2` | | `GB` | `full` | Full postcode | `SW1A 1AA` | | `CA` | `fsa` | Forward sortation area | `M5V`, `V6B` | | `CA` | `full` | Full postal code | `M5V 2T6` | | `DE` | `plz` | Postleitzahl | `10115`, `80331` | | `FR` | `code_postal` | Code postal | `75001`, `13001` | | `AU` | `postcode` | Postcode | `2000`, `3000` | | `ZA` | `postal_code` | Normal postal code | `2196` | Use `postal_code` for the normal postal code string in countries without a more specific registered local system. Deprecated country-fused aliases such as `us_zip` remain accepted for compatibility and SDK backfill. Registered country/system pairings are defined in `postal-country-system.json`; the published `postal-system.json` enum remains additive and also contains deprecated country-fused aliases for 3.x compatibility. ## Exclusion targeting v3 adds `_exclude` variants for metro and postal targeting: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/targeting.json", "geo_countries": ["US"], "geo_metros_exclude": [ { "system": "nielsen_dma", "values": ["501"] } ] } ``` This targets the entire US except the New York DMA. ## Discovering seller capabilities Before sending geo targeting, buyers should verify the seller supports the requested systems. Use `get_adcp_capabilities`: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["media_buy"], "account": { "supported_billing": ["operator", "agent"] }, "media_buy": { "execution": { "targeting": { "geo_countries": true, "geo_regions": true, "geo_metros": { "nielsen_dma": true }, "geo_postal_areas": { "us_zip": true, "us_zip_plus_four": true, "US": ["zip", "zip_plus_four"], "ZA": ["postal_code"] } } } } } ``` The `geo_metros` object uses boolean properties to indicate which metro systems are supported. The `geo_postal_areas` object is keyed by country and lists the supported country-local postal systems; during the 3.x migration, sellers SHOULD also emit equivalent deprecated aliases such as `us_zip` where one exists. Buyers and SDKs should normalize both shapes. If you request a system the seller doesn't declare, expect a validation error. ## Full targeting example A v3 targeting overlay combining geo restrictions: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/targeting.json", "geo_countries": ["US", "CA"], "geo_regions": ["US-NY", "US-CA"], "geo_metros": [ { "system": "nielsen_dma", "values": ["501", "803"] } ], "geo_postal_areas": [ { "country": "US", "system": "zip", "values": ["10001", "10002", "90210"] } ] } ``` Inclusion fields are combined with AND logic — delivery must match all specified constraints. Exclusion fields (`_exclude` variants) act as AND NOT — delivery must match inclusions and must not match any exclusion. ## Migration steps Find all uses of `geo_metros` and `geo_postal_codes`. `geo_postal_codes` becomes `geo_postal_areas`. Wrap metro arrays into `{ "system": "...", "values": [...] }` objects and postal arrays into `{ "country": "...", "system": "...", "values": [...] }` objects. For US-only code, use `nielsen_dma` and `country: "US", system: "zip"`. For international, add the appropriate country-local systems. Call `get_adcp_capabilities` to check what systems each seller supports. If needed, use the new `_exclude` variants for negative targeting. Full targeting reference: audiences, contextual, geographic, and device targeting. *** **Related:** [Channels](/dist/docs/3.1.20/reference/migration/channels) | [Pricing](/dist/docs/3.1.20/reference/migration/pricing) | [Creatives](/dist/docs/3.1.20/reference/migration/creatives) | [Catalogs](/dist/docs/3.1.20/reference/migration/catalogs) | [Attribution](/dist/docs/3.1.20/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating from v2 to v3 Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/index A complete guide to migrating AdCP integrations from v2.x to v3.0, with breaking changes, effort estimates, and deep-dive pages for each area. # Migrating from v2 to v3 This page covers every breaking change when upgrading from AdCP 2.x to 3.0, with effort estimates and links to detailed migration pages. For new features see [What's new in v3](/dist/docs/3.1.20/reference/whats-new-in-v3); for release-candidate deltas see [prerelease upgrade notes](/dist/docs/3.1.20/reference/migration/prerelease-upgrades); for SDK versions that support 3.0 see [Schemas and SDKs](/dist/docs/3.1.20/building/schemas-and-sdks#adcp-3-0-support). **Upgrading from 3.0 to 3.1?** Use the [3.0 to 3.1 migration guide](/dist/docs/3.1.20/reference/migration/3-0-to-3-1). This page is for the breaking v2.x to 3.0 upgrade. **v2 is unsupported as of 3.0 GA and fully deprecated on August 1, 2026 (UTC).** See the [v2 sunset page](/dist/docs/3.1.20/reference/v2-sunset) for the full timeline, AAO registry policy, and why v2 is not safe for interoperable production. **Starting from v2?** See the [v3 readiness checklist](/dist/docs/3.1.20/reference/migration/v3-readiness) for the 8 minimum requirements to pass storyboard testing before working through this full migration. **Upgrading from rc.3?** The [rc.3 → 3.0 prerelease upgrade notes](/dist/docs/3.1.20/reference/migration/prerelease-upgrades) cover additional breaking changes: capabilities model simplification, `account` required on `update_media_buy`, `preview_creative` schema flattening, `signal_id` required on signals, governance lifecycle changes, and the `pending_activation` status split. ## Migration checklist Each row is a breaking change. **Effort** indicates the typical work involved: * **Rename** — Field name changed, same semantics. Find-and-replace. * **Restructure** — Shape changed (e.g., string → object, single → array). Requires code changes. * **Remove** — Field existed in v2, removed in v3. Find-and-delete. * **New requirement** — Didn't exist in v2. Requires new implementation. | Area | Change | Effort | Details | | --------------------- | --------------------------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------- | | Channels | `native` removed | Restructure | [Channels migration](/dist/docs/3.1.20/reference/migration/channels) | | Channels | `video` split into `olv`, `linear_tv`, `cinema` | Restructure | [Channels migration](/dist/docs/3.1.20/reference/migration/channels) | | Channels | 10 new channels added (including `sponsored_intelligence`) | Rename | [Channels migration](/dist/docs/3.1.20/reference/migration/channels) | | Pricing | `fixed_rate` → `fixed_price` | Rename | [Pricing migration](/dist/docs/3.1.20/reference/migration/pricing) | | Pricing | `price_guidance.floor` → `floor_price` | Rename | [Pricing migration](/dist/docs/3.1.20/reference/migration/pricing) | | Pricing | Price guidance restructured | Restructure | [Pricing migration](/dist/docs/3.1.20/reference/migration/pricing) | | Creatives | `creative_ids` → `creative_assignments` with weights | Restructure | [Creatives migration](/dist/docs/3.1.20/reference/migration/creatives) | | Creatives | Asset discovery via `assets` array | New requirement | [Creatives migration](/dist/docs/3.1.20/reference/migration/creatives) | | Catalogs | `promoted_offerings` → `sync_catalogs` | Restructure | [Catalogs migration](/dist/docs/3.1.20/reference/migration/catalogs) | | Geo targeting | Flat arrays → system specification required | Restructure | [Geo targeting migration](/dist/docs/3.1.20/reference/migration/geo-targeting) | | Optimization | `optimization_goal` (single) → `optimization_goals` (array, discriminated union) | Restructure | [Optimization goals migration](/dist/docs/3.1.20/reference/migration/optimization-goals) | | Brand identity | `brand_manifest` → `brand` ref (`{ domain, brand_id }`) | Restructure | [Brand identity migration](/dist/docs/3.1.20/reference/migration/brand-identity) | | Capability discovery | `adcp-extension.json` → `get_adcp_capabilities` | Restructure | [Capability discovery](/dist/docs/3.1.20/protocol/get_adcp_capabilities) | | Signals | Delivery flattening, pricing restructure | Restructure | [Signals migration](/dist/docs/3.1.20/reference/migration/signals) | | Audiences | `external_id` promoted to required top-level field | Restructure | [Audiences migration](/dist/docs/3.1.20/reference/migration/audiences) | | Attribution | Integer day counts → `Duration` objects | Restructure | [Attribution migration](/dist/docs/3.1.20/reference/migration/attribution) | | Products | `buying_mode` required on every `get_products` request | New requirement | [get\_products reference](/dist/docs/3.1.20/media-buy/task-reference/get_products) | | Media buy status | `pending_activation` → `pending_start` | Rename | [Media buys](/dist/docs/3.1.20/media-buy/media-buys) | | Media buy status | `pending_creatives` added (no creatives assigned yet) | New requirement | [Media buys](/dist/docs/3.1.20/media-buy/media-buys) | | Task status | Legacy `task_status` and `response_status` fields MUST NOT be emitted alongside v3 `status` — remove both | Remove | [Task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) | | Capabilities | `compliance_testing` capability block on `get_adcp_capabilities` | Additive | [Capability discovery](/dist/docs/3.1.20/protocol/get_adcp_capabilities#compliance_testing) | | Idempotency | `idempotency_key` required on all mutating requests (UUID v4) | New requirement | [Security § Idempotency](/dist/docs/3.1.20/building/by-layer/L1/security) | | Request signing | RFC 9421 Ed25519 signing profile (optional in 3.0, mandatory under AdCP Verified) | Additive | [Security § Request signing](/dist/docs/3.1.20/building/by-layer/L1/security) | | Webhook signing | Unified on RFC 9421 profile, baseline-required for sellers; HMAC fallback deprecated (removed in 4.0) | New requirement | [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) | | Webhook idempotency | `idempotency_key` required on every webhook payload | New requirement | [Webhooks § Reliability](/dist/docs/3.1.20/building/by-layer/L3/webhooks) | | Governance | `governance_context` is a signed JWS verified via governance agent JWKS | Restructure | [Governance](/dist/docs/3.1.20/governance/campaign) | | IO approval | `MediaBuy.pending_approval` removed — approval is a task-layer object | Restructure | [Media buy lifecycle](/dist/docs/3.1.20/media-buy/media-buys) | | Regulatory invariants | GDPR Art 22 / EU AI Act Annex III enforced via schema `if/then` | New requirement | [Governance § Annex III obligations](/dist/docs/3.1.20/governance/annex-iii-obligations) | `buying_mode` on `get_products` is checked by storyboard testing. `brief` is the baseline mode; sellers that declare `wholesale` or `refine` in capabilities must handle those mode semantics. See the [v3 readiness checklist](/dist/docs/3.1.20/reference/migration/v3-readiness) for details. ## New in v3 — required vs optional These capabilities are new in v3. None existed in v2, so there's nothing to migrate — but you should know which ones affect your integration. | Capability | Required? | Who needs it | | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | Accounts protocol (`sync_accounts`, `list_accounts`) | Required for all buyers — use `sync_accounts` when `require_operator_auth: false` (buyer-declared accounts), `list_accounts` when `true` and the seller exposes an account-id namespace | All buyers — the seller's account model determines which task to call, not whether accounts are needed | | Brand Protocol (`brand.json`) | Recommended | All buyers — provides brand identity for creative generation | | Governance (content standards, property lists) | Optional | Buyers who need brand suitability enforcement | | Sponsored Intelligence | Optional | Buyers working with AI platforms that support conversational handoffs | | Registry API | Optional | Buyers who want programmatic agent/brand discovery | | Campaign Governance | Optional | Organizations with compliance or approval workflows | ## Running v2 and v3 side by side Dual-support is a temporary migration tool, not a long-term posture. After August 1, 2026, v3-only is the required configuration — see the [v2 sunset page](/dist/docs/3.1.20/reference/v2-sunset). During migration, sellers can accept both v2 and v3 traffic and buyers can route to each seller on the correct version: 1. **Check seller capabilities** — Call `get_adcp_capabilities` on each seller. A successful response means the seller supports v3; buyers declare their version via `adcp_major_version` and sellers advertise the versions they accept via `major_versions`. See [version negotiation](/dist/docs/3.1.20/reference/versioning#version-negotiation) for the full flow. A seller that does not respond to `get_adcp_capabilities` is v2-only. 2. **Branch by seller** — Route v3-capable sellers through your v3 integration and v2-only sellers through your existing v2 code. 3. **Migrate incrementally** — Start with the rename changes (pricing fields, channel updates), then tackle structural changes (creative assignments, optimization goals), then adopt new capabilities (accounts, governance) as needed. ## Breaking migrations (v2 → v3.0) `native` removed, `video` split, 10 new channels Field renames and price guidance restructure Creative assignments with weights and asset discovery `promoted_offerings` to first-class `sync_catalogs` System specification for global geo support Single goal to array with discriminated union `brand_manifest` to `brand` ref via `brand.json` Delivery flattening and pricing restructure `external_id` promotion to required field Integer days to structured `Duration` objects ## 3.1 badge prep guides You can stay on 3.0 without breaking compatibility. To claim the 3.1 badge, complete this short prep checklist. Additive prep for buyers, sellers, agents, SDKs, and compliance workflows Move build capability discovery and pricing from formats to transformers Split body-level buy status from envelope task status on success responses # Migrating to `media_buy_status` (3.1) Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/media-buy-status Move from body-level `status` to `media_buy_status` on create_media_buy and update_media_buy success responses. Additive in 3.1; legacy field removed in 3.2; nested status cascade follows in 4.0. # Migrating to `media_buy_status` (3.1) AdCP 3.1 splits two enums that 3.0 collided at the same response root key: * **Envelope `status`** — TaskStatus (`submitted` / `working` / `input-required` / `completed` / `canceled` / `failed` / `rejected` / `auth-required` / `unknown`). Required from beta.2 ([#4876](https://github.com/adcontextprotocol/adcp/issues/4876)). * **Body `media_buy_status`** — MediaBuyStatus (`pending_creatives` / `pending_start` / `active` / `paused` / `completed` / `rejected` / `canceled`). **New in 3.1.** Under MCP flat-on-the-wire serialization both fields share the response root. In 3.0 they were both named `status`; the body-level MediaBuyStatus was silently destroyed when the envelope stamped a TaskStatus at the same path. No validator caught it. 3.1 splits them. ## What changed | Surface | 3.0 | 3.1 | | ----------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | `create_media_buy` success response | `status` (MediaBuyStatus) at root | `media_buy_status` (MediaBuyStatus) at root; legacy `status` is `deprecated: true` | | `update_media_buy` success response | Same | Same | | `get_media_buys` items | `media_buys[].status` (MediaBuyStatus) | Unchanged in 3.1 — renamed to `media_buys[].media_buy_status` in 4.0 ([#4905](https://github.com/adcontextprotocol/adcp/issues/4905)) | | `get_media_buy_delivery` items | `media_buy_deliveries[].status` (MediaBuyStatus) | Unchanged in 3.1 — renamed in 4.0 ([#4905](https://github.com/adcontextprotocol/adcp/issues/4905)) | | `core/media-buy.json` | `status` (MediaBuyStatus) | Unchanged in 3.1 — renamed in 4.0 ([#4905](https://github.com/adcontextprotocol/adcp/issues/4905)) | ## Before (3.0) ```json theme={null} { "status": "completed", "media_buy_id": "mb_12345", "status": "active", "packages": [...] } ``` Two keys named `status` collide at the JSON root under MCP flat serialization — the body-level `MediaBuyStatus: 'active'` value is silently destroyed by the envelope `TaskStatus: 'completed'`. No validator catches it. ## After (3.1) ```json theme={null} { "status": "completed", "media_buy_id": "mb_12345", "media_buy_status": "active", "packages": [...] } ``` Two distinct fields. Envelope `status` carries the task-lifecycle state at the root; body `media_buy_status` carries the buy's lifecycle state alongside. ## 3.1 conformance * **Sellers** SHOULD emit `media_buy_status` on `create_media_buy` and `update_media_buy` success responses. MAY continue emitting the deprecated top-level `status: MediaBuyStatus` during the 3.1 deprecation window. * **Buyers** MUST prefer `media_buy_status` when present. MAY fall back to legacy `status` for compatibility with sellers still on the legacy form. * **3.0 sellers and buyers** continue to work unchanged. No `required[]` swap, no rename, no breakage. * **Compliance storyboards** assert `path: "media_buy_status"`. A 3.1 seller emitting only the legacy `status` is schema-valid but fails 3.1 storyboard certification. The storyboard is the binding conformance check; the schema `deprecated: true` marker is advisory. * **Sellers emitting both fields** MUST emit identical values for `media_buy_status` and the deprecated `status`. Divergent emission (e.g., `status: "active", media_buy_status: "paused"`) passes JSON Schema validation but is a conformance violation — 3.1 storyboards enforce equality via `field_value_or_absent` assertions on `status` alongside the canonical `media_buy_status` checks. The `if/then` JSON Schema constraint was evaluated and deferred: the migration window is short, codegen toolchain compat is uncertain, and the storyboard gate is sufficient. See [#4908](https://github.com/adcontextprotocol/adcp/issues/4908). ## SDK behavior The legacy `status` field carries `deprecated: true` (JSON Schema 2020-12). Propagation through codegen varies: | Toolchain | Propagation | | ------------------------------------------------- | ---------------------------------------------------------------------------------- | | TypeScript (`json-schema-to-typescript`) | `@deprecated` JSDoc on the field. Reliable. | | Python (`datamodel-code-generator` v2+) | `deprecated=True` on the `Field(...)` arg. Older pinned versions silently drop it. | | Go (`quicktype` and similar) | Generally not propagated. | | `@adcp/client` 3.1+, Python `adcp` SDK, `adcp-go` | Canonical `media_buy_status` is the typed shape SDK users consume. | If your toolchain doesn't surface the deprecation, the storyboard gate is your enforcement signal. ## When the legacy field disappears * **3.2** ([#4906](https://github.com/adcontextprotocol/adcp/issues/4906)): the deprecated top-level `status: MediaBuyStatus` is **removed** from `CreateMediaBuySuccess` and `UpdateMediaBuySuccess`. After 3.2, top-level `status` on these responses unambiguously carries envelope TaskStatus only. The deprecation window is short by design — the storyboard gate already forces 3.1-conformant sellers off the legacy field. * **4.0** ([#4905](https://github.com/adcontextprotocol/adcp/issues/4905)): the nested `status` cascade lands — `media_buys[].status` on `get-media-buys-response`, `media_buy_deliveries[].status` on `get-media-buy-delivery-response`, and `status` on `core/media-buy.json` rename to `media_buy_status`. Genuinely breaking (a `required[]` swap), held to the major. ## Forward-compatible buyer code Code that needs to span 3.0, 3.1, and 4.0 sellers: ```js theme={null} // Prefer media_buy_status (3.1+), fall back to status (3.0 + 4.0 nested-surface compat) const mediaBuyStatus = response.media_buy_status ?? response.status; const buyLifecycleStatus = mediaBuy.media_buy_status ?? mediaBuy.status; ``` ## Related * [create\_media\_buy reference](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) — canonical response examples * [Media buy lifecycle](/dist/docs/3.1.20/media-buy/media-buys/lifecycle) — the MediaBuyStatus state machine * [Envelope task-status](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle) — TaskStatus semantics # Migrating optimization goals Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/optimization-goals Migrate AdCP optimization goals from beta.3 to rc.1. Replaces singular optimization_goal with a multi-goal array using discriminated unions and priority ordering. # Migrating optimization goals AdCP 3.0 rc.1 replaces the singular `optimization_goal` object with an `optimization_goals` array. Each goal is a discriminated union on `kind`, supporting multi-goal packages with priority ordering. ## What changed | beta.3 | rc.1 | Notes | | ----------------------------------- | ------------------------------------------------------- | ---------------------------------------- | | `optimization_goal` (single object) | `optimization_goals` (array) | Array of discriminated union | | Implicit single goal | `priority` field | 1 = highest priority | | One goal type | Two kinds: `metric` and `event` | Discriminated on `kind` field | | No reach optimization | `reach` metric with `reach_unit` and `target_frequency` | Products declare `supported_reach_units` | ## Goal kinds Every goal has a `kind` discriminator: * **`metric`** — Seller-native delivery metrics (clicks, views, reach, engagements, etc.) * **`event`** — Conversion tracking tied to event sources configured via `sync_event_sources` ### Metric goals ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/optimization-goal.json", "kind": "metric", "metric": "clicks", "target": { "kind": "cost_per", "value": 2.50 }, "priority": 1 } ``` Supported metrics: `clicks`, `views`, `completed_views`, `viewed_seconds`, `engagements`, `follows`, `saves`, `profile_visits`, `reach`. `attention_seconds` and `attention_score` are accepted but **deprecated** (slated for removal at next major) — use `kind: "vendor_metric"` with an explicit vendor binding instead. **How these surface in delivery:** `viewed_seconds` is reported via `viewability.viewed_seconds` (the same `standard` threshold governs both `viewable_rate` and `viewed_seconds`). Vendor-attested metrics — attention, panel-based brand lift, emissions, retail-media partner metrics — flow through the `kind: "vendor_metric"` goal kind on the optimization side and `vendor_metric_values[]` on the delivery side, keyed on `(vendor, metric_id)`. See [`kind: vendor_metric`](/dist/docs/3.1.20/media-buy/conversion-tracking/#kind-vendor_metric) for the goal shape and the three-precondition rejection rule. Target types: * `cost_per` — Target cost per metric unit (e.g., \$2.50 CPC) * `threshold_rate` — Target rate threshold (e.g., 0.02 for 2% CTR) ### Event goals ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/optimization-goal.json", "kind": "event", "event_sources": [ { "event_source_id": "es_web_pixel", "event_type": "purchase", "value_field": "order_total", "value_factor": 1 } ], "target": { "kind": "per_ad_spend", "value": 4.0 }, "attribution_window": { "post_click": { "interval": 7, "unit": "days" }, "post_view": { "interval": 1, "unit": "days" } }, "priority": 2 } ``` Event target types: * `cost_per` — Target cost per conversion (CPA) * `per_ad_spend` — Target return on ad spend (ROAS). Requires `value_field`. * `maximize_value` — Maximize total conversion value. Requires `value_field`. ### Reach goals Reach is a metric goal with additional fields: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/optimization-goal.json", "kind": "metric", "metric": "reach", "reach_unit": "individuals", "target_frequency": { "min": 2, "max": 5, "window": { "interval": 7, "unit": "days" } }, "priority": 1 } ``` `reach_unit` values: `individuals`, `households`, `devices`, `accounts`, `cookies`, `custom`. `target_frequency` requires at least one of `min` or `max`, plus `window` as a Duration object (e.g., `{"interval": 7, "unit": "days"}` or `{"interval": 1, "unit": "campaign"}`). ## Multi-goal packages Multiple goals are ordered by `priority` (1 = highest). The seller optimizes for higher-priority goals first, using lower-priority goals as tiebreakers. **beta.3:** ```json theme={null} { "optimization_goal": { "metric": "clicks", "target_cpc": 2.50 } } ``` **rc.1:** ```json theme={null} { "optimization_goals": [ { "kind": "metric", "metric": "clicks", "target": { "kind": "cost_per", "value": 2.50 }, "priority": 1 }, { "kind": "event", "event_sources": [ { "event_source_id": "es_web_pixel", "event_type": "purchase" } ], "target": { "kind": "cost_per", "value": 25.00 }, "priority": 2 } ] } ``` ## Product capabilities Products declare optimization support via `metric_optimization`: ```json test=false theme={null} { "metric_optimization": { "supported_metrics": ["clicks", "views", "completed_views", "reach"], "supported_reach_units": ["individuals", "households"], "supported_view_durations": [6, 15, 30], "supported_targets": ["cost_per", "threshold_rate"] }, "max_optimization_goals": 3 } ``` * `supported_metrics` — Which metrics the product can optimize for * `supported_reach_units` — Required when `reach` is in supported\_metrics * `supported_view_durations` — Seconds for `completed_views` metric * `supported_targets` — Target kinds available. When omitted, only target-less goals (maximize volume) are allowed * `max_optimization_goals` — Maximum number of goals per package ## Migration steps Replace `optimization_goal` (singular) with `optimization_goals` (array) in all request construction code. Wrap existing goals with `"kind": "metric"` or `"kind": "event"`. Metric goals use seller-native metrics; event goals reference event sources from `sync_event_sources`. Replace flat target fields (e.g., `target_cpc`) with the discriminated `target` object: `{ "kind": "cost_per", "value": 2.50 }`. Set `priority: 1` for single-goal packages. For multi-goal packages, assign ascending priority values (1 = highest). When reading optimization goals from responses (e.g., `get_media_buy`), switch on `kind` to determine the goal type before accessing type-specific fields. Before submitting goals, check `metric_optimization.supported_metrics` and `max_optimization_goals` on the product. Sellers reject unsupported metrics and goals exceeding the limit. Run requests against `optimization-goal.json` schema. The discriminated union enforces the correct fields per kind. Configure event sources, send conversion events, and optimize delivery goals. *** **Related:** [Channels](/dist/docs/3.1.20/reference/migration/channels) | [Pricing](/dist/docs/3.1.20/reference/migration/pricing) | [Signals](/dist/docs/3.1.20/reference/migration/signals) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Prerelease upgrade notes Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/prerelease-upgrades Breaking and additive changes between AdCP 3.0.0 release candidates. # Prerelease upgrade notes If you adopted a prerelease version, review the relevant section below before upgrading. If you are migrating from v2, see the [main migration guide](/dist/docs/3.1.20/reference/migration). ## rc.3 → 3.0 ### Breaking for rc.3 adopters | Area | rc.3 | 3.0 | What to do | | ----------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `idempotency_key` on mutating requests | Optional | **Required** on every mutating request (schema `^[A-Za-z0-9_.:-]{16,255}$`; UUID v4 for Verified). Sellers declare `adcp.idempotency = { supported: true/false }` on capabilities. | Generate fresh key per logical operation. Persist keys across agent instances. Declare `adcp.idempotency` on `get_adcp_capabilities` (sellers). When `supported: true`, handle `IDEMPOTENCY_CONFLICT` and `IDEMPOTENCY_EXPIRED`; conformance probes require a mutated-payload replay to return CONFLICT. When `supported: false`, use natural-key checks instead of blind retries. See [Security § Idempotency](/dist/docs/3.1.20/building/by-layer/L1/security). | | Webhook signing | HMAC-SHA256 with `push_notification_config.authentication` (required) | RFC 9421 profile (baseline-required for sellers); HMAC fallback available through 3.x via `authentication.credentials` | Publish a signing JWK in your JWKS at `jwks_uri` (referenced from `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhooks; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. If you want webhook-only key material, publish a second `request-signing` JWK with a distinct `kid`. Drop `push_notification_config.authentication` from new configs; buyers opt into legacy HMAC via `authentication.credentials`. Receivers verify against the sender's JWKS. The entire `authentication` object (HMAC + Bearer) is removed in 4.0. | | `idempotency_key` on webhook payloads | Not standardized (fragile `(task_id, status, timestamp)` tuple dedup) | **Required** — sender-generated UUID v4 on every payload | Sellers: generate a cryptographically-random UUID v4 per event. Receivers: dedupe on `idempotency_key` with 24h minimum TTL, sender-scoped cache. Schemas affected: `mcp-webhook-payload`, `collection-list-changed-webhook`, `property-list-changed-webhook`, `artifact-webhook-payload`, `revocation-notification`. | | `revocation-notification.notification_id` | Field name on rights revocation payload | Renamed to `idempotency_key` | Find-and-replace in your rights-revocation receivers. | | `MediaBuy.pending_approval` status | Present | Removed — approvals are explicit approval tasks | Remove `pending_approval` from media-buy state filters. Consume approval tasks from the task surface. | | Budget autonomy | `budget.authority_level` enum (`agent_full \| agent_limited \| human_required`) | Removed. Split into `budget.reallocation_threshold` (number) + `plan.human_review_required` (boolean) | Rewrite: `agent_full` → `reallocation_unlimited: true`; `agent_limited` → `reallocation_threshold: `; `human_required` → `human_review_required: true`. Regulated verticals (fair housing, lending, employment, pharmaceutical) enforce `human_review_required: true` via schema `if/then`. | | `inventory-lists` specialism | Present | Renamed to `property-lists`; `collection-lists` split out as separate specialism | Update specialism claims. Agents that governed collection lists should now claim both `property-lists` and `collection-lists`. | | Compliance path taxonomy | `/compliance/{v}/domains/` | `/compliance/{v}/protocols/` | Update any internal references to compliance paths. Runner and catalog use `protocols/` exclusively. | | `governance_context` carrier | Opaque string | Signed JWS | Switch to JWS format. Verify signature via governance agent JWKS (resolved via `sync_governance`). Bind to `sub`, `aud`, `phase`, `exp`. | | Media buy status | `pending_activation` | Removed — replaced by `pending_creatives` and `pending_start` | Replace `pending_activation` in status filters, comparisons, and state machine logic. Schema: `enums/media-buy-status.json`. See details below. | | Capabilities model | Redundant boolean gates (`features.content_standards`, `brand.identity`, `trusted_match.supported`, etc.) | Removed — object presence is the signal | Remove boolean capability checks. Test for object presence instead. Schema: `protocol/get-adcp-capabilities-response.json`. See [capabilities migration](#capabilities-model-simplification) below. | | `reporting_capabilities` | Optional on products | Required on every product | Ensure all products returned from `get_products` include `reporting_capabilities`. Schema: `core/product.json`. | | `account` on `update_media_buy` | Optional | Required | Pass `account` on all `update_media_buy` calls, matching `create_media_buy` behavior. Schema: `media-buy/update-media-buy-request.json`. | | `preview_creative` schema | oneOf union | Flat object with `request_type` discriminant | Update request builders to use the flat schema with `request_type` field. Schema: `creative/preview-creative-request.json`. See [preview\_creative migration](#preview_creative-schema-flattening) below. | | `signal_id` on `get_signals` response | Optional on signal items | Required | Ensure all signal items in `get_signals` responses include `signal_id`. Schema: `signals/get-signals-response.json`. | | `GOVERNANCE_DENIED` error | Not in error code enum | Added as correctable error | Handle `GOVERNANCE_DENIED` in error handling logic. Schema: `enums/error-code.json`. | | Governance lifecycle | `media_buy_id` as lifecycle correlator | Removed — `governance_context` is sole lifecycle correlator | Replace `media_buy_id` in governance schemas with `governance_context`. Handle `purchase_type` field on `check_governance` and `report_plan_outcome`. Schema: `governance/check-governance-request.json`. See [governance migration](#governance-lifecycle-migration) below. | | Geo capability fields | `supported_geo_levels`, `supported_metro_systems`, `supported_postal_systems` (from #2143) | Removed — use typed objects (`geo_countries`, `geo_regions`, `geo_metros`, `geo_postal_areas`) | If you adopted the flat array shape from #2143, revert to typed geo objects with `additionalProperties: false`. Schema: `protocol/get-adcp-capabilities-response.json`. | | `comply_test_controller` schema | oneOf union | Flat object with `scenario` discriminant and if/then validation | Update request builders to use the flat schema with `scenario` field instead of oneOf variants. | | Compliance testing surface | An interim rc.4 build accepted `"compliance_testing"` as a `supported_protocols` value | Removed before GA. Compliance testing is declared via a top-level `compliance_testing: { scenarios: [...] }` capability block, not via `supported_protocols`. | Remove `"compliance_testing"` from `supported_protocols` if present. Agents implementing `comply_test_controller` add a top-level `compliance_testing: { scenarios: [...] }` block instead. | | Specialism IDs | `broadcast-platform`, `social-platform`, `property-governance`, `collection-governance` | Renamed/merged: `sales-broadcast-tv`, `sales-social`, `inventory-lists` (merges property + collection governance) | Update specialism claims in `get_adcp_capabilities.specialisms`. See [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog). | | `audience-sync` parent protocol | Under `governance` | Moved to `media-buy` | If claiming `audience-sync`, add `media_buy` to `supported_protocols`. | | Sponsored Intelligence scope | `sponsored_intelligence` as a specialism | Promoted to full protocol in `supported_protocols` | Move from `specialisms` to `supported_protocols`. | ### Media buy status migration `pending_activation` was a single state covering two distinct conditions. It has been split: | Condition | rc.3 status | 3.0 status | | ------------------------------------------- | -------------------- | -------------------- | | Buy approved, no creatives assigned | `pending_activation` | `pending_creatives` | | Buy ready to serve, waiting for flight date | `pending_activation` | `pending_start` | | Buy is serving | `active` | `active` (unchanged) | **What to change:** 1. **Status filters** — Replace `pending_activation` with both `pending_creatives` and `pending_start` in `status_filter` arrays on `get_media_buys` and `get_media_buy_delivery`. 2. **Status comparisons** — Any `if (status === 'pending_activation')` needs to branch on which condition you're checking. If you want "not yet serving," check for both `pending_creatives` and `pending_start`. If you want "ready but waiting for flight date," check only `pending_start`. 3. **State machine transitions** — `rejected` is now valid from both `pending_creatives` and `pending_start` (previously only from `pending_activation`). `pending_creatives` → `pending_start` happens when creatives are assigned via `sync_creatives`. 4. **Legacy alias** — `pending` continues to be accepted as an alias for `pending_start` in delivery response status filters. See the [canonical lifecycle diagram](/dist/docs/3.1.20/media-buy/media-buys#lifecycle-states) for the full state machine. ### Capabilities model simplification PR #2143 removed redundant boolean capability fields. Object presence now signals support — if you have the object, you have the capability. **Removed fields and replacements:** | Removed field | What to do instead | | ----------------------------------------------------------- | --------------------------------------------------------- | | `media_buy.reporting` | Use product-level `reporting_capabilities` (now required) | | `features.content_standards` | Check for `content_standards` object presence | | `features.audience_targeting` | Check for `audience_targeting` object presence | | `features.conversion_tracking` | Check for `conversion_tracking` object presence | | `content_standards_detail` | Renamed to `content_standards` | | `brand.identity` | Implied by brand protocol support | | `trusted_match.supported` | Check for `trusted_match` object presence | | `targeting.device_platform` / `targeting.device_type` | Implied by `media_buy` protocol support | | `targeting.audience_include` / `targeting.audience_exclude` | Implied by `audience_targeting` presence | **Before (rc.3):** ```json theme={null} { "features": { "content_standards": true, "audience_targeting": true }, "trusted_match": { "supported": true, "uid_types": ["email_sha256"] } } ``` **After (3.0):** ```json theme={null} { "content_standards": { ... }, "audience_targeting": { ... }, "trusted_match": { "uid_types": ["email_sha256"] } } ``` ### preview\_creative schema flattening The `preview_creative` request is flattened from a oneOf union to a single object with a `request_type` discriminant. Three modes: | `request_type` | Required field | Purpose | | -------------- | ------------------------------ | ---------------------------- | | `single` | `creative_manifest` | Preview one creative | | `batch` | `requests` (array, 1-50 items) | Preview multiple creatives | | `variant` | `variant_id` | Replay a post-flight variant | **Before (rc.3):** ```json theme={null} { "creative_manifest": { "format_id": { ... }, "assets": { ... } } } ``` **After (3.0):** ```json theme={null} { "request_type": "single", "creative_manifest": { "format_id": { ... }, "assets": { ... } } } ``` Schema: `schemas/creative/preview-creative-request.json` ### Governance lifecycle migration `media_buy_id` is removed from governance schemas. `governance_context` is an opaque string that serves as the sole lifecycle correlator across `sync_plans`, `check_governance`, `report_plan_outcome`, and `get_plan_audit_logs`. **Before (rc.3):** ```json theme={null} { "media_buy_id": "mb_456", "planned_delivery": { ... } } ``` **After (3.0):** ```json theme={null} { "governance_context": "campaign_2024_q4_nova", "purchase_type": "media_buy", "planned_delivery": { ... } } ``` Schema: `schemas/governance/check-governance-request.json` ### context and ext fields All request and response schemas across governance, collection, property, sponsored-intelligence, and content-standards protocols now include optional `context` and `ext` fields for application metadata and protocol extensions. ### Additive changes in 3.0 * **RFC 9421 request signing profile (optional in 3.0, mandatory under AdCP Verified)** — Ed25519 HTTP Message Signatures with canonicalized covered-component list. Published test vectors at `static/compliance/source/test-vectors/request-signing/`. sf-binary encoding and URL canonicalization pinned for bit-identical canonical inputs. 15-step verification checklist with `keyid` cap-before-crypto. * **Webhook signing unified on RFC 9421** — Baseline-required for sellers emitting webhooks. Sellers publish a signing JWK in their JWKS at `jwks_uri`; new signers use `adcp_use: "request-signing"` for webhook delivery, while deprecated `webhook-signing` keys remain accepted during the compatibility window. Use a distinct `kid` if you want webhook-only key material. 14-step webhook verifier checklist in the [Security guide](/dist/docs/3.1.20/building/by-layer/L1/security). HMAC-SHA256 remains a legacy fallback through 3.x (the entire `authentication` object is removed in 4.0). * **Required `idempotency_key` on every webhook payload** — Sender-generated UUID v4 across all five webhook payload schemas. Replaces fragile `(task_id, status, timestamp)` dedup. `revocation-notification.notification_id` renamed to `idempotency_key` for protocol-wide consistency. * **`check_governance` on every spend-commit** — Governance invocation is required at commit, not just at plan approval. Closes the loophole where partial spends could skip governance. * **Experimental status mechanism** — `status: experimental` marker for fields and tasks in production use but not yet under full stability guarantees. `custom` pricing-model escape hatch on signals. * **`submitted` branch on `create_media_buy`** — Seller has accepted the payload for processing but has not yet confirmed the order. Distinct from `pending_creatives` and `pending_start`. * **Time semantics + `activate_signal` idempotency** — Unifies time-field semantics across the protocol. `activate_signal` added to the required-idempotency table. * **Known limitations + privacy-considerations reference pages** — New `/docs/reference/known-limitations` and `/docs/reference/privacy-considerations`. Platform-agnostic lint prevents vendor-specific language from creeping into the spec. * **Signed JWS `governance_context`** — Governance decisions are now cryptographically verifiable. Sellers resolve the governance agent's JWKS via `sync_governance` and verify `sub` / `aud` / `phase` / `exp` before honoring the decision. * **Universal security storyboard** — Every agent runs `/compliance/{version}/universal/security.yaml` (unauth rejection, API key, OAuth/RFC 9728, audience binding). Agents declaring signing also run the `signed_requests` harness. * **Cross-instance state persistence** — Architecture spec requires persistent state (tasks, media buys, plans, signed artifacts, idempotency keys) across horizontally-scaled instances. * **Security implementation guide** — New `docs/building/by-layer/L1/security.mdx` documents threat model, three-principal model (brand / operator / agent), and verification paths. Retires ambiguous "principal" terminology. * **GDPR Art 22 / EU AI Act Annex III as schema invariants** — New registry policy `eu_ai_act_annex_iii`. `requires_human_review` on policies and categories. Schema-level enforcement of `human_review_required: true` for regulated verticals. * **Operating an Agent guide** — New doc for publishers without engineering teams — three paths: partner, self-host, build. * **Release cadence policy** — Named cadence: patch monthly, minor quarterly, major annual if needed. v2 EOL August 1, 2026. * **CHARTER.md** — Formal governance charter published. * **Collection lists** — Program-level brand safety using distribution identifiers (IMDb, Gracenote, EIDR) for cross-publisher matching. New targeting overlay fields (`collection_list`, `collection_list_exclude`). New genre taxonomy enum. * **Broadcast TV support** — Ad-ID identifiers, broadcast spot formats (:15, :30, :60), Agency Estimate Number, measurement windows (Live, C3, C7), delivery data completeness (`is_final`, `measurement_window`). * **Offline reporting delivery** — `reporting_delivery_methods` on capabilities, `reporting_bucket` on accounts, `supports_offline_delivery` on product `reporting_capabilities`. Avro and ORC added as file format options. * **TMPX exposure tracking** — Country-partitioned identity and macro connectivity for the Trusted Match Protocol execution layer. * **TMP provider registration** — `provider-registration.json` schema, `GET /health` endpoint, dual discovery models (static config and dynamic API), per-provider latency budget semantics. * **TMP multi-identity Identity Match** — `identity-match-request` replaces single `user_token` + `uid_type` with an `identities` array (minItems 1, maxItems 3). Router filters per provider and re-signs with RFC 8785 JCS canonicalization; cache key adds `consent_hash`. Adds `rampid_derived` to the `uid-type` enum. Breaking relative to prior pre-release TMP drafts only; TMP remains pre-release in 3.0 and stabilizes in 3.1.0. * **GOVERNANCE\_DENIED error** — New correctable error code for governance-rejected operations. * **context/ext fields** — Optional `context` and `ext` on all request/response schemas across governance, collection, property, SI, and content-standards protocols. * **Compliance testing capability** — Agents include a `compliance_testing: { scenarios: [...] }` block in `get_adcp_capabilities` declaring which `comply_test_controller` scenarios they support. The block's presence is the signal — compliance testing is NOT a `supported_protocols` value. Storyboard runners use the block to determine whether deterministic testing steps can be validated. * **Specialisms + compliance catalog** — Storyboards ship in the protocol at `/compliance/{version}/` (universal + protocols + specialisms + test-kits). New `specialisms` field on `get_adcp_capabilities` with 19 values across 6 protocols. Per-version protocol tarball at `/protocol/{version}.tgz`. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog). * **Structured measurement terms** — `measurement_terms` on products and media buys for billing vendor, IVT threshold, and viewability floor negotiation. `cancellation_policy` on guaranteed products. `viewability-standard` enum. `TERMS_REJECTED` error code. * **Unified vendor pricing** — `pricing_options[]` on `list_creatives`, `build_creative`, `get_creative_features`, and `property-list`. Shared `vendor-pricing-option.json` schema. * **Per-request version declaration** — `adcp_major_version` on all v3 request schemas. `VERSION_UNSUPPORTED` error code. Multi-version sellers supporting v2 clients must detect v2 payloads by structural cues, not by this field (v2 schemas do not have it). * **Broadcast forecast schema** — `measurement_source`, `packages`, and `guaranteed_impressions` on `DeliveryForecast`. New `forecast-range-unit` and `forecastable-metric` enums. * **Broadcast station identifiers** — `station_id` and `facility_id` identifier types. `linear_tv` property type. * **Brand schema extensions** — Generic `agents` array on `brand.json`. Visual tokens (`border_radius`, `elevation`, `spacing`, extended color roles). Structured font definitions. * **Per-item error schema** — `sync_creatives`, `sync_catalogs`, and `sync_event_sources` response errors now use `error.json` ref. * **Property relationship field** — `relationship` on brand.json property definitions (`owned`, `direct`, `delegated`, `ad_network`) for bilateral verification with `adagents.json` delegation types. * **`sales` agent type restored** — `sales` restored to `brand-agent-type` enum. Sales agents (SSPs, publishers) are distinct from buying agents (DSPs, buyer platforms). * **Required tasks reference** — New reference page consolidating required, conditional, and optional tasks across all AdCP protocols by agent role. * **Storyboard validation fixes** — 20+ validation bugs fixed across 11 storyboard files: corrected field paths (`creatives[0].action`, `media_buy_deliveries`, `renders[0].preview_url`), added missing `value:` to `field_value` checks, added `value` property to storyboard validation schema. *** ## rc.1 → rc.2 ### Potentially breaking for rc.1 adopters | Area | rc.1 | rc.2 | What to do | | ------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | | Account auth model | `account_resolution` capability | Removed — `require_operator_auth` now determines account model | Update capability parsing and auth/account branching logic | | Creative library task boundary | `list_creatives` / `sync_creatives` documented under Media Buy | Creative library operations live in the Creative Protocol | Route library reads/writes through Creative Protocol assumptions, even when a sales agent implements both protocols | | Sandbox capability discovery | `media_buy.features.sandbox` | `account.sandbox` | Read sandbox support from the account capability block | | DOOH flat rate parameters | `flat_rate.parameters` without discriminator | `flat_rate.parameters.type: "dooh"` required when parameters are present | Add the discriminator in request builders and validators | | Deprecated governance task docs | `delete_content_standards`, `get_property_features` documented | Removed | Use `update_content_standards`, property lists, and `get_adcp_capabilities` instead | ### Additive changes in rc.2 * **Creative generation and preview** — `build_creative` adds `include_preview`, `preview_inputs`, `preview_quality`, `preview_output_format`, `quality`, `item_limit`, and multi-format `target_format_ids`. Buyers can now preview inline, choose draft vs production generation, and request multiple output formats in one call. * **Creative library retrieval** — `build_creative` also supports library retrieval using `creative_id`, optional `concept_id`, `media_buy_id`, `package_id`, and `macro_values`, so ad servers and creative platforms can resolve stored creatives into delivery-ready manifests. * **Creative capability discovery** — Creative agents can declare `supports_generation`, `supports_transformation`, and `has_creative_library`. `list_creatives` now uses library-oriented fields like `include_snapshot`, `has_served`, and `items`. * **Product discovery and planning** — `get_products` adds `exclusivity`, `preferred_delivery_types`, and `time_budget`, with `incomplete` in the response. Products may omit `delivery_measurement`, and packages can now carry per-package `start_time` / `end_time`. * **Compliance and governance** — Creative disclosures add persistence semantics, and campaign governance introduces `sync_plans`, `check_governance`, `report_plan_outcome`, and `get_plan_audit_logs`. * **Accounts and sandbox ergonomics** — `sync_accounts` adds `payment_terms`, and sandbox now participates in the natural account key for buyer-declared account references. *** ## Need help? * **Community**: [Slack](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) — best for quick questions from other implementers * **Issues**: [GitHub Issues](https://github.com/adcontextprotocol/adcp/issues) — for bugs, spec questions, or migration edge cases * **Full v2 → v3 migration**: [Migration guide](/dist/docs/3.1.20/reference/migration) # Migrating pricing Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/pricing Migrate AdCP pricing from v2 to v3. Covers field renames, the hard constraint vs soft hint separation, and updated pricing option schemas. # Migrating pricing AdCP 3.0 renames pricing fields for clarity and separates hard constraints (prices the publisher enforces) from soft hints (historical data to help buyers bid). ## What changed | v2 field | v3 field | Change type | | ---------------------- | ------------- | ------------------ | | `fixed_rate` | `fixed_price` | Renamed | | `price_guidance.floor` | `floor_price` | Moved to top level | The `pricing_model`, `currency`, `pricing_option_id`, and `price_guidance` percentiles (`p25`, `p50`, `p75`, `p90`) are unchanged. ## Hard constraints vs soft hints v3 makes an explicit semantic distinction: **Hard constraints** — publisher-enforced prices that cause bid rejection if violated: * `fixed_price` — the exact price per unit (fixed-price deals) * `floor_price` — minimum acceptable bid (auction pricing) These are mutually exclusive. A pricing option has either `fixed_price` (guaranteed rate) or `floor_price` (auction with minimum), never both. **Soft hints** — historical percentiles to help buyers calibrate bids: * `price_guidance.p25` — 25th percentile of recent winning bids * `price_guidance.p50` — median of recent winning bids * `price_guidance.p75` — 75th percentile of recent winning bids * `price_guidance.p90` — 90th percentile of recent winning bids ## Deal type mapping These fields map to standard programmatic deal types: | Deal type | AdCP field | Description | | ---------------------------- | ------------- | ------------------------------------------------ | | Programmatic Guaranteed (PG) | `fixed_price` | Fixed CPM, guaranteed delivery | | Preferred Deal | `fixed_price` | Fixed CPM, non-guaranteed (buyer has first look) | | Private Marketplace (PMP) | `floor_price` | Auction with minimum bid | | Open Auction | Neither | No floor or fixed price — open bidding | PG and Preferred Deals both use `fixed_price`. The distinction between them is in delivery commitment, not pricing — PG guarantees delivery volume while Preferred Deals offer first-look access without volume guarantees. ## Fixed-price deals **v2:** ```json theme={null} { "pricing_option_id": "cpm_usd_fixed", "pricing_model": "cpm", "currency": "USD", "fixed_rate": 25.00 } ``` **v3:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpm-option.json", "pricing_option_id": "cpm_usd_fixed", "pricing_model": "cpm", "currency": "USD", "fixed_price": 25.00 } ``` Rename `fixed_rate` to `fixed_price`. No structural changes. ## Auction pricing **v2:** ```json theme={null} { "pricing_option_id": "cpm_usd_auction", "pricing_model": "cpm", "currency": "USD", "price_guidance": { "floor": 10.00, "p50": 15.00, "p75": 18.00 } } ``` **v3:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpm-option.json", "pricing_option_id": "cpm_usd_auction", "pricing_model": "cpm", "currency": "USD", "floor_price": 10.00, "price_guidance": { "p50": 15.00, "p75": 18.00 } } ``` Two changes: 1. `price_guidance.floor` moves to top-level `floor_price` 2. `price_guidance` retains only the percentile hints ## Price guidance object The v3 `price_guidance` object contains only statistical percentiles: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/price-guidance.json", "p25": 8.50, "p50": 15.00, "p75": 18.00, "p90": 22.00 } ``` All fields are optional. Publishers include whichever percentiles they can provide. ## Flat-rate pricing **v2** (DOOH with parameters, no type discriminator): ```json theme={null} { "pricing_option_id": "dooh_times_square", "pricing_model": "flat_rate", "currency": "USD", "fixed_rate": 50000.00, "parameters": { "duration_hours": 24, "sov_percentage": 100, "estimated_impressions": 1500000 } } ``` **v3:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/flat-rate-option.json", "pricing_option_id": "dooh_times_square", "pricing_model": "flat_rate", "currency": "USD", "fixed_price": 50000.00, "parameters": { "type": "dooh", "duration_hours": 24, "sov_percentage": 100, "estimated_impressions": 1500000 } } ``` Two changes: 1. `fixed_rate` renames to `fixed_price` (same as other pricing models) 2. `parameters` gains a required `"type": "dooh"` discriminator for DOOH inventory Sponsorship flat\_rate options that had no `parameters` in v2 continue to omit it in v3. Note that `fixed_price` is optional on flat-rate options — when absent, the flat-rate is auction-based (uncommon but valid for some DOOH inventory). ## Minimum spend v3 adds an optional `min_spend_per_package` field to all pricing options: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpm-option.json", "pricing_option_id": "cpm_usd_premium", "pricing_model": "cpm", "currency": "USD", "floor_price": 15.00, "min_spend_per_package": 5000.00, "price_guidance": { "p50": 22.00, "p75": 28.00 } } ``` This lets publishers declare minimum spend requirements per package. ## Transition period handling During migration, readers may encounter both old and new field names. Consider checking for both: ```javascript test=false theme={null} const price = option.fixed_price ?? option.fixed_rate; const floor = option.floor_price ?? option.price_guidance?.floor; ``` v3 writers should only emit the new field names. Old field names (`fixed_rate`, `price_guidance.floor`) are not recognized by v3 schema validation. Remove the v2 fallback once all upstream sellers have migrated to v3 schemas. ## Migration steps Rename `fixed_rate` to `fixed_price` in all pricing options. Move `floor` from inside `price_guidance` to top-level `floor_price`. Remove `floor` from `price_guidance` objects (only percentiles remain). Update code to look for new field names. Consider temporary fallback for both during transition. Verify floor enforcement works with the new field location. Each pricing model has its own schema in `/schemas/3.1.20/pricing-options/`. How products declare pricing options, channels, and capabilities. *** **Related:** [Channels](/dist/docs/3.1.20/reference/migration/channels) | [Geo targeting](/dist/docs/3.1.20/reference/migration/geo-targeting) | [Creatives](/dist/docs/3.1.20/reference/migration/creatives) | [Catalogs](/dist/docs/3.1.20/reference/migration/catalogs) | [Attribution](/dist/docs/3.1.20/reference/migration/attribution) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # Migrating signals Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/signals Migrate AdCP signals from beta.3 to rc.1. Covers deliver_to flattening, structured pricing options, and simplified usage reporting fields. # Migrating signals AdCP 3.0 rc.1 makes three changes to the Signals Protocol: delivery target flattening, structured pricing options, and usage reporting simplification. ## Deliver-to flattening The nested `deliver_to` object in `get_signals` requests is replaced by two top-level fields. | beta.3 | rc.1 | Notes | | ------------------------- | -------------- | ------------------ | | `deliver_to.destinations` | `destinations` | Moved to top level | | `deliver_to.countries` | `countries` | Moved to top level | **beta.3:** ```json test=false theme={null} { "signal_spec": "in-market auto intenders", "deliver_to": { "destinations": [ { "agent_url": "https://dsp.example.com", "seat_id": "seat_123" } ], "countries": ["US", "CA"] } } ``` **rc.1:** ```json test=false theme={null} { "signal_spec": "in-market auto intenders", "destinations": [ { "agent_url": "https://dsp.example.com", "seat_id": "seat_123" } ], "countries": ["US", "CA"] } ``` *** ## Pricing options The legacy `pricing` object (with a single `cpm` field) is replaced by a `pricing_options` array. Each option is a discriminated union on `model`. | beta.3 | rc.1 | Notes | | -------------------------- | ---------------------------------------- | ------------------------------ | | `pricing: { cpm: 2.50 }` | `pricing_options[]` | Array of pricing model objects | | Implicit pricing selection | `pricing_option_id` on `activate_signal` | Explicit buyer commitment | | No idempotency | `idempotency_key` on `report_usage` | Prevents duplicate billing | ### Three pricing models **CPM** — Fixed cost per thousand impressions: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/signal-pricing-option.json", "pricing_option_id": "po_auto_cpm", "model": "cpm", "cpm": 2.50, "currency": "USD" } ``` **Percent of media** — Percentage of media spend, with optional CPM cap: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/signal-pricing-option.json", "pricing_option_id": "po_auto_pom", "model": "percent_of_media", "percent": 15, "max_cpm": 5.00, "currency": "USD" } ``` **Flat fee** — Fixed charge per reporting period (monthly licensed segments): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/signal-pricing-option.json", "pricing_option_id": "po_auto_flat", "model": "flat_fee", "amount": 10000.00, "period": "monthly", "currency": "USD" } ``` ### Activation with pricing When activating a signal, pass the selected `pricing_option_id`: ```json test=false theme={null} { "signal_agent_segment_id": "luxury_auto_intenders", "destinations": [ { "type": "agent", "agent_url": "https://dsp.example.com", "account": { "account_id": "acct_pinnacle" } } ], "pricing_option_id": "po_auto_cpm" } ``` *** ## Usage reporting `report_usage` adds `idempotency_key` and removes the `kind` and `operator_id` fields. | beta.3 | rc.1 | Notes | | ------------------- | ----------------- | --------------------------------------------------------------------------------- | | `kind` field | Removed | Usage records are self-describing via `signal_agent_segment_id` or `standards_id` | | `operator_id` field | Removed | Account reference provides operator identity | | No idempotency | `idempotency_key` | Client-generated UUID prevents duplicate billing on retries | **rc.1 usage report:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/account/report-usage-request.json", "idempotency_key": "550e8400-e29b-41d4-a716-446655440000", "reporting_period": { "start": "2025-03-01T00:00:00Z", "end": "2025-03-31T23:59:59Z" }, "usage": [ { "account": { "account_id": "acct_pinnacle_signals" }, "signal_agent_segment_id": "luxury_auto_intenders", "pricing_option_id": "po_auto_cpm", "impressions": 4200000, "media_spend": 21000.00, "vendor_cost": 2100.00, "currency": "USD" } ] } ``` The `pricing_option_id` in the usage record must match the one passed at activation, allowing the vendor to verify the correct rate was applied. ## Migration steps Move `deliver_to.destinations` and `deliver_to.countries` to top-level fields in `get_signals` requests. Update signal response parsing to read `pricing_options` (array) instead of `pricing` (object). Switch on `model` field to determine the pricing type. When calling `activate_signal`, pass the selected `pricing_option_id` from the signal's `pricing_options` array. Generate a unique key (UUID) for each `report_usage` call. Retries with the same key are idempotent. Remove `kind` and `operator_id` from usage records. Usage type is determined by the presence of `signal_agent_segment_id` (signals) or `standards_id` (governance). Store the `pricing_option_id` at activation time and pass it in `report_usage` records so the vendor can verify billing. Run requests against `get-signals-request.json`, `activate-signal-request.json`, and `report-usage-request.json` schemas. Full reference for signal discovery, activation, usage reporting, and pricing models. *** **Related:** [Pricing](/dist/docs/3.1.20/reference/migration/pricing) | [Optimization goals](/dist/docs/3.1.20/reference/migration/optimization-goals) | [AdCP 3.0 overview](/dist/docs/3.1.20/reference/whats-new-in-v3) # v3 readiness checklist Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/migration/v3-readiness The 8 minimum requirements for seller agents to pass AdCP v3 storyboard testing. # v3 readiness checklist AdCP storyboard testing requires v3 protocol support. Agents that only support v2 will fail. This page covers the minimum changes to unblock integration testing with v3 buyers — not the full migration. For the complete list, see the [migration guide](/dist/docs/3.1.20/reference/migration). Storyboard testing will hard-fail any agent that does not declare v3 support. Complete these 8 items first, then work through the [full migration checklist](/dist/docs/3.1.20/reference/migration). v2 is fully deprecated on August 1, 2026 (UTC) — see the [v2 sunset page](/dist/docs/3.1.20/reference/v2-sunset). *** ## 1. Implement `get_adcp_capabilities` v3 buyers call this task first to discover what your agent supports. Without it, buyers cannot determine your protocol version, supported channels, pricing models, or features. This is the single most important change — it's how buyers (and storyboard testing) distinguish v3 agents from v2. Return at minimum: `major_versions: [3]`, `supported_protocols`, and your `features` object. Task specification and response schema. *** ## 2. Update channel taxonomy v3 replaces v2's 9 channels with 20 planning-oriented channels. Buyers send v3 channel values — your agent must recognize them. | Common v2 value | v3 replacement | | --------------- | --------------------------------------------------- | | `video` | `olv`, `linear_tv`, or `cinema` | | `audio` | `radio` or `streaming_audio` | | `native` | Removed — native inventory is now part of `display` | | `retail` | `retail_media` | `display`, `social`, `ctv`, `podcast`, and `dooh` are unchanged. Complete mapping table and examples. *** ## 3. Rename pricing fields Two field renames — same semantics, different names: | v2 field | v3 field | | ---------------------- | ------------------------- | | `fixed_rate` | `fixed_price` | | `price_guidance.floor` | `floor_price` (top-level) | Buyers validate against v3 schemas. The old field names cause schema validation failures. Before/after examples and price guidance restructuring. *** ## 4. Support `creative_assignments` `creative_ids` (string array) is replaced by `creative_assignments` (object array) with delivery weighting and placement targeting. ```json theme={null} // v2 { "creative_ids": ["cr_001", "cr_002"] } // v3 { "creative_assignments": [ { "creative_id": "cr_001", "weight": 70 }, { "creative_id": "cr_002", "weight": 30 } ] } ``` Weighted assignments, placement targeting, and asset discovery. *** ## 5. Accept `brand` ref instead of `brand_manifest` Buyers pass brand identity as a reference (`{ domain, brand_id }`) instead of an inline manifest. Your agent resolves brand data from `brand.json` or the registry at execution time. ```json theme={null} // v2 { "brand_manifest": { "name": "Acme", "logo": "..." } } // v3 { "brand": { "domain": "acme.example.com", "brand_id": "acme_main" } } ``` BrandRef schema, resolution flow, and migration steps. *** ## 6. Handle `buying_mode` on `get_products` `buying_mode` is now required on every `get_products` request. `brief` is the baseline mode for curated product discovery. If your agent declares `wholesale` or `refine` in `media_buy.buying_modes`, it must handle those mode semantics as well. Full request schema including buying\_mode. *** ## 7. Remove `buyer_ref` — use `idempotency_key` v3 removes `buyer_ref`, `buyer_campaign_ref`, and `campaign_ref` from all requests and responses. Seller-assigned `media_buy_id` and `package_id` are now the only canonical identifiers. If your agent relied on `buyer_ref` for deduplication, use the new `idempotency_key` field instead. `idempotency_key` (UUID v4) is **required** on every mutating request — agents MUST reject requests that omit it with `INVALID_REQUEST`, and MUST return `IDEMPOTENCY_CONFLICT` when a key is reused with a different payload. See the [idempotency implementation guide](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency) for normative semantics. If your agent used `buyer_ref` for internal tracking or correlation (e.g. mapping to campaign IDs, session traces, or UI state), use the `context` field instead. `context` is an opaque object echoed unchanged in every response and webhook — agents must never parse or act on it. For `create_media_buy` package correlation, put per-package tracking in `packages[i].context` (for example `context.buyer_ref`), not deprecated top-level `buyer_ref`; 3.1+ sellers MUST echo `product_id` on explicit package responses, and package context remains the fallback for legacy sellers that do not. | v2 field | v3 replacement | | ------------------------------------- | ----------------------------------------------- | | `buyer_ref` | Removed — use `media_buy_id` (seller-assigned) | | `buyer_campaign_ref` | Removed | | `campaign_ref` | Removed | | `buyer_ref` as implicit dedup | Explicit `idempotency_key` on mutating requests | | `buyer_ref` as correlation / tracking | `context` (opaque, echoed unchanged) | ```json theme={null} // v2 — buyer provides their own ref { "buyer_ref": "camp-2024-q3", "start_time": "..." } // v3 — seller-assigned ID, explicit idempotency, context for tracking { "idempotency_key": "550e8400-e29b-41d4-a716-446655440000", "context": { "campaign": "camp-2024-q3", "trace_id": "abc-123" }, "start_time": "..." } ``` For package-level line-item correlation, keep the buy-level context separate from each package's fallback correlation handle: ```json test=false theme={null} { "idempotency_key": "550e8400-e29b-41d4-a716-446655440001", "context": { "internal_campaign_id": "camp-2024-q3" }, "brand": { "domain": "example-brand.test" }, "packages": [ { "product_id": "prod_weekday_display", "pricing_option_id": "cpm_usd_auction", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_image" } ], "budget": 12000, "context": { "buyer_ref": "line-001" } }, { "product_id": "prod_weekend_video", "pricing_option_id": "cpm_usd_auction", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_30s" } ], "budget": 18000, "context": { "buyer_ref": "line-002" } } ], "start_time": "2026-07-01T00:00:00Z", "end_time": "2026-07-31T23:59:59Z" } ``` *** ## 8. Implement `sync_accounts` v3 buyers establish billing relationships before placing buys. Your agent must accept `sync_accounts` calls and return an account reference that buyers include on subsequent requests. Account provisioning, lifecycle, and the sync\_accounts task. *** ## After these 8 items Once these are in place, run storyboard testing against your agent. The existing tracks (products, media buy, creative) validate v3 schemas in detail and will surface any remaining field-level issues. For the complete migration — including geo targeting, optimization goals, signals, audiences, and attribution — see the [full migration guide](/dist/docs/3.1.20/reference/migration). # What's New in AdCP 3.1 Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/whats-new-in-3-1 Adopter overview of AdCP 3.1 — distributed brand.json, dependency-impact webhooks, wholesale feed mirroring, release-precision version negotiation, brand response signing, canonical creative formats, vendor-attested measurement, action discovery, and more. Additive over 3.0; use wire pin 3.1 for the stable release. **Status: 3.1 is released.** Current stable minor: 3.1, wire pin `adcp_version: "3.1"`. The 3.0 line remains supported for existing integrations pinned to `"3.0"`. AdCP 3.1 is a minor release. Every 3.1 change is **additive** over 3.0: new fields are optional, no required field was removed, no shape changed in a way that breaks a 3.0-conformant client. **No breaking changes for 3.0-conformant agents.** To adopt 3.1, pin production traffic to `"3.1"` after confirming the agent advertises that value in `supported_versions`. For implementation prep, use the [3.0 to 3.1 migration guide](/dist/docs/3.1.20/reference/migration/3-0-to-3-1). This page is the curated adopter overview for the 3.1 minor release. Need the full 3.1 change list, per-PR detail, or migration tables? Use [Release Notes § Version 3.1.0](/dist/docs/3.1.20/reference/release-notes#version-3-1-0), the authoritative version record, and [Migrating from 3.0 to 3.1](/dist/docs/3.1.20/reference/migration/3-0-to-3-1), the role-based upgrade checklist. For long-form normative reference, follow the link on each headline below. **Looking for the major v2 → v3 changes instead?** See [What's New in AdCP 3](/dist/docs/3.1.20/reference/whats-new-in-v3). This page covers the 3.0 → 3.1 minor delta only. ## Final 3.1 feature set If you only need the stable 3.1 shape, read this list first: * **Versioning and validation.** Release-precision `adcp_version` pins, stable wire value `"3.1"`, `supported_versions` advertisement, envelope echo, and version-scoped verification badges. * **Brand trust.** Distributed `brand.json`, sub-brand self-publishing through `brand_refs[]`, typed brand constraints, authorized operator scoping, and required signed responses for `verify_brand_claim` / `verify_brand_claims`. * **Signals and product targeting.** Enriched signal definitions, `SignalRef`, wholesale signal enumeration, product-scoped `included_signals`, selectable `signal_targeting_options`, grouped buy-time `signal_targeting_groups`, and clarified owned-vs-marketplace conformance. * **Wholesale feed mirroring.** Conditional fetch tokens, public/account `cache_scope`, product and signal wholesale-feed webhooks, and repair-by-read semantics for storefronts, registries, and federated marketplaces. * **Creative formats.** Canonical `format_kind` declarations, publisher format catalogs, `v1_format_ref` dual-emission, hosted audio/video `duration_ms_exact` plus one-sided `duration_ms_range`, published-post references, and video placement semantics. * **Creative generation.** `list_transformers`, account-scoped transformer selection, strict typed `config`, catalog and variant fan-out, advisory evaluator ranking over `creative-feature-result[]`, spend controls, content macros, free-text params, and per-output pricing receipts. * **Media-buy operations.** Dependency impairments, buyer-visible `webhook_activity[]`, action discovery, proposal-lifecycle cleanup, currency-scoped product discovery, sponsored/social placement fields, and SI availability status. * **Measurement and billing.** Vendor-attested `vendor_metric` goals, reach-window semantics, `viewability.viewed_seconds`, windowed delivery recovery, finality flags on delivery and usage, and out-of-band creative billing declarations. * **Runtime hardening.** Request idempotency on every task, `IDEMPOTENCY_IN_FLIGHT`, open error-code decoding with recovery classification, auth error split, credential-in-payload rejection, webhook operation-id echo, and flat MCP envelope tolerance. * **SDK and compliance readiness.** Named schemas for code generators, async-response refs, intentionally open payload markers, capability-gated storyboard coverage, packaged compliance bundle closure, and drift checks for release artifacts. ## Why upgrade 3.1 is the production-hardening release. 3.0 shipped the protocol surface — discovery, buy lifecycle, signals, creative library, brand identity. 3.1 closes the operational gaps that surfaced when real agents started running buys against real publishers: * **You can debug your webhooks now.** Buyer agents inspect their own recent delivery fires via `webhook_activity[]` on `get_media_buys` — HTTP status, fire time, idempotency\_key — instead of guessing why their gateway returned 5xx. * **You can see why a buy is impaired.** When a creative gets pulled, an audience is suspended, a catalog item is withdrawn, or an event source goes quiet, the buy's `health` flips to `impaired` and `impairments[]` lists every offline dependency with its package\_ids and remediation hint. Both as a snapshot on `get_media_buys` and as a push fire via `notification-type: impairment`. * **You can mirror wholesale product feed and wholesale signals feed without burning bandwidth.** Conditional-fetch tokens (`if_wholesale_feed_version` — ETag-style), wholesale enumeration on signals (symmetric with products), and account-level wholesale feed webhooks let storefronts, federated marketplaces, and registries hold an up-to-date local replica of every connected agent's buyable products and signals without re-fetching unchanged feed payloads on every poll. Two-layer cache model (`cache_scope: "public" | "account"`) means most accounts dedupe into a single shared cache. * **You can compose seller-offered signals on media products.** Products can declare `included_signals` for bundled/planned signal metadata, `signal_targeting_allowed` for package-level signal selection, optional inline `signal_targeting_options` for product-specific menus and pricing, and `signal_targeting_rules` for include/exclude/grouping limits. Buyers apply selections through grouped `targeting_overlay.signal_targeting_groups`, while wholesale products can omit inline options and use `get_signals` as the selectable signal feed. * **You can bind goals to vendor-attested measurement.** Optimization goals can now reference `(vendor, metric_id)` pairs from real measurement vendors (DV, IAS, Adelaide, TVision, Lumen, Kantar, Upwave, Scope3, etc.) — not vendor-agnostic strings the seller can interpret however they want. The measurement-vendor catalog discovery surface itself is experimental in 3.1 (`measurement.core`). * **Brand verification responses are attestable.** `verify_brand_claim` and `verify_brand_claims` now return a required `signed_response` payload-envelope JWS so downstream partners can retain and verify the brand's answer without relying on transport-session context. * **You can pin a release and stop fighting drift.** Release-precision `adcp_version` (`"3.1"` for the stable release) on every request; sellers advertise their full `supported_versions` set and echo what they actually served. SDK constructor pins are now a real thing. * **Sub-brands self-publish.** A brand can publish its own canonical `brand.json` on its own domain while the corporate house declares ownership via a portfolio pointer — same reciprocal pattern as IAB's `ads.txt` / `sellers.json`. * **Creative formats have a canonical vocabulary.** 12 canonical `format_kind` values + a publisher catalog discovery surface + a projection-ref mechanism. Creative agents also advertise buildable canonical outputs through `creative.supported_formats[]`; targetable entries SHOULD include stable `capability_id` values for `build_creative` routing. * **Hosted audio/video durations use one range vocabulary.** `duration_ms_range` now covers bounded and one-sided ranges (`[null, 60000]` for "up to 60s", `[15000, null]` for "at least 15s"); fixed slots should use `duration_ms_exact`. No separate min/max duration fields were added. * **You can discover and select creative transformers.** A new `list_transformers` task surfaces the account-scoped, agent-offered build units — voices, models, styles — the creative analog of media-buy products, with an `expand_params` mode that returns the enumerable option values (e.g. your configured voices) on the same tool. `build_creative` selects one with `transformer_id`, configures it with a typed `config` bag (strict validation — unknown keys and out-of-range values are rejected with field-attributed errors; vendor knobs go in `ext`), and fans out across catalog items (`max_creatives`) and alternatives (`max_variants` + `variant_axis`, with `keep_mode` advisory). A new `BuildCreativeVariantSuccess` response member carries per-variant manifests, recommendation/rank, and a per-leaf pricing receipt. Pricing moves onto the transformer (`pricing_options` `per_unit`), settled via `report_usage`. * **Video and audio inventory can declare execution semantics.** Products and placements can declare OpenRTB-aligned `video_placement_types` and `audio_distribution_types` so buyers can distinguish instream/accompanying/interstitial/standalone video and music streaming/FM-AM broadcast/podcast/catch-up/web-radio audio without changing buyer-facing channels. * **Verification badges are version-scoped.** Public 3.1 badge issuance can run alongside active 3.0 badges; buyers pinned to a release read the matching badge version. * **Action discovery and proposals are mechanical.** Products advertise `allowed_actions[]`; media buys carry `available_actions[]`; proposals use `proposal_status` to say whether finalize is still required before `create_media_buy(proposal_id)`. * **Billing has finality.** Row-level `is_final` + `finalized_at` on delivery; matching `final` + `finalized_at` + `measurement_window` on `report_usage`. Buyers know when the number stops moving and can reconcile invoices. Plus a long tail of error-code clarity, auth tightening, idempotency rules, TMP IdentityMatch upgrades, and `adagents.json` scaling work — see the headline list below. ## At a glance | Area | 3.0 | 3.1 | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`brand.json`** | Inline `brands[]` under a single house document | Distributed: brands self-publish on their own domains; houses declare ownership via `brand_refs[]`; mutual-assertion trust; typed `trademarks[]` | | **Brand verification** | brand.json discovery only | `verify_brand_claim` / `verify_brand_claims` — federated authoritative verification with required `signed_response` payload-envelope JWS evidence | | **Dependency impact** | No protocol surface for "a resource the buy depends on went offline" | `media_buy.health` + `impairments[]` snapshot; `notification-type: impairment` webhooks; `propagation_surfaces` capability; `impairment.coherence` compliance invariant | | **Webhook foundation** | Specced per-feature | One persistent-channel contract: snapshot/log duality, `notification_id` typed at envelope, per-account + per-resource subscription model | | **Webhook observability** | No buyer-side delivery visibility | `webhook_activity[]` on `get_media_buys` — buyers self-service debug their own missed fires | | **Wholesale feed mirroring** | Re-fetch wholesale on every poll to detect changes | ETag-style `wholesale_feed_version` / `if_wholesale_feed_version` conditional fetch on `get_products` / `get_signals`; `cache_scope` (public/account) on every response for two-layer cache layering | | **Wholesale signals** | `get_signals` required `signal_spec` or deprecated `signal_ids` — no protocol-conformant way to enumerate the full priced signals feed | `discovery_mode: "wholesale"` symmetric with `get_products buying_mode: "wholesale"`; paginated full wholesale signals feed enumeration with `signal_ref` identity and `pricing_options[]` populated | | **Signal identity** | `SignalId` / `signal_id.source` (`catalog` or `agent`) was the primary signal identity shape | `SignalRef` / `signal_ref.scope` is canonical: `data_provider` for provider-published adagents.json signals, `signal_source` for source-native signals, and `product` for product-local media-buy options. Legacy `signal_id` remains accepted during the migration window | | **Product signal metadata** | `data_provider_signals` mixed legacy bundled metadata with no selectable package-level signal surface | `data_provider_signals` is deprecated. Use `included_signals` for non-selectable bundled/planned signals and `signal_targeting_options` for selectable product-scoped signal options with pricing, activation handles, defaults, and grouping hints | | **Package signal targeting** | No grouped buy-time surface for seller-offered named signals; storefronts overloaded audience fields or brief text | `targeting_overlay.signal_targeting_groups`: top-level `operator: "all"` with child `any` include groups and `none` exclusion groups. Product `signal_targeting_rules` declares selection mode, direct vs seller-planned resolution, and group limits | | **Wholesale feed webhooks** | None | Account-level webhooks registered via `sync_accounts.accounts[].notification_configs[]` carry `product.*` / `signal.*` / `wholesale_feed.bulk_change` change payloads with `applies_to.scope`; standard webhook signing and SSRF guards apply | | **Creative formats** | Format-by-name with per-publisher variants | 12 canonical `format_kind` values + publisher catalog discovery (`adagents.json formats[]`) + `v1_format_ref` for dual emission + size flexibility (fixed / multi-size / responsive); creative agents advertise buildable outputs in `creative.supported_formats[]` and SHOULD include `capability_id` on targetable entries | | **Hosted audio/video duration constraints** | Fixed durations or closed ranges only | `duration_ms_exact` for fixed slots; `duration_ms_range` for bounded and one-sided ranges such as `[null, 60000]` or `[15000, null]`; `[null, null]` is invalid | | **Creative transformers** | `build_creative` built to a target format; render knobs implicit; format-attached `Format.input_format_ids` / `output_format_ids` / `pricing_options` | `list_transformers` discovers account-scoped build units (voices/models/styles) with an `expand_params` option-enumeration mode; `build_creative` selects one via `transformer_id`, takes a strictly-validated typed `config`, and fans out with `max_creatives` (per-catalog-item) + `max_variants`/`variant_axis` + advisory `keep_mode`; new `BuildCreativeVariantSuccess` member returns per-variant manifests, recommendation/`rank`, and per-leaf pricing receipts; rate lives on `transformer.pricing_options` (`per_unit`), settled via `report_usage`. The shipped `BuildCreativeSuccess` / `BuildCreativeMultiSuccess` are unchanged | | **Version negotiation** | Integer `adcp_major_version` per request | Release-precision `adcp_version` (e.g. `"3.1"` for the stable release) + `adcp.supported_versions` advertisement + envelope echo. Integer field remains as backwards-compatible legacy | | **Optimization goals** | `event` + `metric` kinds, vendor-agnostic | New `vendor_metric` kind — bind goals to vendor-attested metrics; `vendor_metric_optimization` per-product capability; three-precondition rejection rule | | **Capability declarations** | Per-protocol basics | New: `media_buy.buying_modes` for wholesale products, `signals.discovery_modes` for wholesale signals, `wholesale_feed_versioning`, `wholesale_feed_webhooks`, `supported_optimization_metrics`, `supported_target_kinds`, `media_buy.frequency_capping`, `media_buy.propagation_surfaces`, `creative.bills_through_adcp`, `capabilities.idempotency.in_flight_max_seconds` | | **Video and audio execution discovery** | Video and audio products relied on free-text descriptions, placement names, or overloaded channels | `video_placement_types` and `audio_distribution_types` on products, placements, and `get_products.filters` using AdCP-native names for OpenRTB `video.plcmt` and `audio.feed` values | | **Currency-scoped discovery** | Buyers could filter budget currency but not the currencies they can transact in for media product pricing | `pricing_currencies` on `get_products.filters`; sellers match product-level `pricing_options`, prune returned product pricing options to requested currencies, and exclude products whose mandatory product-scoped signal charges are not satisfiable in those currencies | | **Delivery reporting** | `reach` without window semantics; viewability is rate only | `reach_window` (cumulative / period / rolling); `viewability.viewed_seconds`; windowed pull recovery via `time_granularity` + `include_window_breakdown` | | **Billing surface** | Authority via `billing_measurement`; no finality marker | Row-level `is_final` + `finalized_at` on delivery; `final` + `finalized_at` + `measurement_window` on `report_usage`; `creative.bills_through_adcp` capability + `BILLING_OUT_OF_BAND` error | | **Action discovery** | No structured action vocabulary on buys/products | `allowed_actions[]` on Product (advisory template); `available_actions[]` on `get_media_buys` / `create_media_buy` / `update_media_buy`; proposal executability comes from `proposal_status`, not action-mode hacks | | **Auth + security** | Single `AUTH_REQUIRED` error; no transport-channel rule | `AUTH_REQUIRED` split into `AUTH_MISSING` (correctable) + `AUTH_INVALID` (terminal); `CREDENTIAL_IN_ARGS` rejects credentials in request payload; request-signing `protocol_methods_*` namespace | | **Idempotency** | Per-call replay only | Rule 9 (concurrent retries) + Rule 10 (downstream reconciliation); `IDEMPOTENCY_IN_FLIGHT` error code; `capabilities.idempotency.in_flight_max_seconds` | | **Async envelope** | Two-shape submitted envelope for create-style tasks | Three-shape envelope extended to `sync_audiences` | | **TMP IdentityMatch** | Basic request/response | `serve_window_sec` frequency-cap data flow; `seller_agent_url` required on request; optional `package_ids` | | **`adagents.json`** | Authoritative-only discovery | Managed-network scale (20 MB cap + `publisher_domains[]` compact form); ads.txt `managerdomain` fallback; tightened `revoked_publisher_domains[]` semantics | | **Schema housekeeping** | — | Named reusable schemas for SDK generators; `x-adcp-hoist` opt-in marker; `x-adcp-open-payload` marker for intentionally open JSON payload fields; open-string compliance scenarios; `allowed_values` on text-asset-requirements; `vast_tracker` + `daast_tracker` asset types; optional `currency`/`total_budget` on create/update responses | | **Compliance suite** | Per-tool scenarios | Capability-gated scenarios for `frequency_cap_enforcement`, `per_creative_attribution`, `metric_mode`, ROAS, `audience_buy_flow`, `event_dedup_flow`, `performance_buy_flow`, and `product_signal_targeting`; storyboard `requires` runtime gate; `comply_test_controller` sandbox gate; version-scoped badge evidence; packaged-reference validation for public compliance bundles | ### Capability-slot migration note When using SDK helpers such as `definePlatform`, keep unsupported capability slots absent from `get_adcp_capabilities`. A missing slot is an honest scope boundary: storyboards and local test vectors targeting that slot should grade `not_applicable`, not failed. Current workaround: prerelease/custom runners should add explicit skip gates for vectors outside the declared slot scope. The runner-side follow-up is tracked in `adcp-client#2244`. ## Headline features ### Distributed `brand.json` — sub-brands self-publish A brand can now publish its **own** canonical `brand.json` on its own domain while the corporate house declares ownership via a portfolio pointer (`brand_refs[]`). The hierarchy stays one level deep — only houses declare ownership. Trust resolves via mutual assertion: both sides reciprocate. Identity attributes (logos, colors, tone, tagline) trust on the leaf's TLS alone; relationship trust (governance propagation, billable inclusion) gates on the reciprocal entry. Same shape as IAB's `ads.txt` / `sellers.json` / `app-ads.txt` reciprocal-publication pattern, applied to brand identity. Plus: typed `trademarks[]` with optional `status`, `license_type`, `licensor_domain`, `countries`, `nice_classes` (cross-industry disambiguation). Compliance fields resolve strictest-of (brand-level can tighten, never weaken) while identity fields stay brand-wins. → Normative spec: [`brand.json` § Distributed publishing](/dist/docs/3.1.20/brand-protocol/brand-json#distributed-publishing) · PR [#4505](https://github.com/adcontextprotocol/adcp/pull/4505) ### `verify_brand_claim` / `verify_brand_claims` — federated brand verification Two new brand-protocol tasks let partners ask a brand authoritatively whether a claim belongs to it: a brand agent published at the brand's own domain, queried by anyone who needs to verify trademark ownership, ad-creative claims, or asset rights. Federated by design — every brand agent answers for its own brand only. Reframes the email-based self-healing SHOULD from #4505 as a richer pull-based DRM-for-brand-identity surface. RC4 locks the trust envelope: successful `verify_brand_claim` and `verify_brand_claims` responses require `signed_response`, a payload-envelope JWS over the canonical task-body response. The signature binds the answer to the designated task, resolved brand tenant, responding agent URL, caller/request hash, and `iat`/`exp` freshness window. Verifiers resolve the key with `adcp_use: "response-signing"` and reject any mismatch between the unsigned response fields and `signed_response.payload.response`. → Spec: [Brand Protocol § verify\_brand\_claim](/dist/docs/3.1.20/brand-protocol/tasks/verify_brand_claim) · [Security § Designated-task response signing](/dist/docs/3.1.20/building/by-layer/L1/security#designated-task-response-signing) · PRs [#4540](https://github.com/adcontextprotocol/adcp/pull/4540), [#4603](https://github.com/adcontextprotocol/adcp/pull/4603), [#5192](https://github.com/adcontextprotocol/adcp/pull/5192) ### Dependency-impact webhooks and snapshot coherence When a resource a media buy depends on transitions to an offline state — an audience suspended, a creative suspended/rejected post-approval, a catalog item withdrawn, an event source quiet, a property depublished — buyers see it through two parallel surfaces: * **Snapshot.** `media_buy.health` flips from `ok` to `impaired`; `media_buy.impairments[]` lists every offline resource with its package\_ids, transition, reason\_code, and remediation hint. The next `get_media_buys` read shows current truth. * **Log.** `notification-type: impairment` webhooks fire with `notification_id = impairment_id` and the same payload shape, configured via `push_notification_config`. Either path is complete; buyers reconcile via the snapshot when push and pull disagree. Sellers declare which surfaces they use via `capabilities.media_buy.propagation_surfaces` (`["snapshot"]`, `["webhook"]`, `["snapshot", "webhook"]`, or `["out_of_band"]`). The `impairment.coherence` compliance invariant grades the contract end-to-end (forward, inverse, and health-iff rules; relaxes on terminal-status buys). → Spec: [Media Buy Lifecycle § Health & impairments](/dist/docs/3.1.20/media-buy/media-buys/lifecycle#health-impairments) · [Snapshot and log contract](/dist/docs/3.1.20/protocol/snapshot-and-log) · RFC #2853 · PRs #4588, #4601, #4677, #4685, #4690 ### Webhook foundation + buyer-side delivery visibility 3.1 codifies one persistent-channel contract for every push surface: snapshot is authoritative, push is at-least-once and unordered, dedupe via `idempotency_key`, correlate state via `notification_id` (now typed at the envelope level on `mcp-webhook-payload.json`), replay = re-read the snapshot. Future webhook RFCs reference the foundation instead of re-deriving it. The subscription model extends to per-account so creative-library-level events (creative state changes) fire even when no media buy directly references the creative. For production debugging, buyers can opt-in to `webhook_activity[]` on `get_media_buys` — recent fires for the buys they see, with HTTP status, fire time, and `idempotency_key`. No more black-box "the publisher fired but my gateway returned 5xx and I can't see it." Pure self-service: buyers debug their own integration without operator round-trips. → Spec: [Snapshot and log contract](/dist/docs/3.1.20/protocol/snapshot-and-log) · [Webhooks § Persistent channel contract](/dist/docs/3.1.20/building/by-layer/L3/webhooks#persistent-channel-contract) · RFC #4582 · PRs #4601, #4701, #4730 ### Wholesale feed mirroring — conditional fetch, wholesale signals, webhooks Three companion proposals let consumers (storefronts, federated marketplaces, registries, agency brand stacks) maintain a near-real-time local mirror of every connected AdCP agent's buyable wholesale product feed and wholesale signals feed without burning bandwidth on per-poll wholesale fetches. Independent and complementary — agents MAY adopt any subset; consumers fall back to wholesale polling against agents that don't. Terminology: this section uses **wholesale feed** for seller-side products and signals from `get_products` / `get_signals`. It is distinct from `sync_catalogs`, which uploads buyer-provided campaign input feeds to a seller account. * **Conditional fetch (`if_wholesale_feed_version`).** Every `get_products` / `get_signals` response returns an opaque `wholesale_feed_version` token. Pass it back on the next call and the seller MAY short-circuit with `unchanged: true` — no product or signal payload, no per-page diff. ETag/HTTP semantics. Optional companion `pricing_version` for sellers that move rate cards independently of structural metadata; `if_pricing_version` requires `if_wholesale_feed_version` (schema-enforced via `dependencies`). Backward-compatible: pre-v3.1 agents that ignore the tokens just return full payloads. * **Wholesale signals (`discovery_mode: "wholesale"`).** Callers can omit `signal_spec` / `signal_refs` / deprecated `signal_ids` and enumerate a signals agent's full priced signals feed, paginated. Symmetric with `get_products` `buying_mode: "wholesale"`, closing the gap that previously forced storefronts and marketplaces into hacky probe queries to mirror the signals feed. * **Wholesale feed webhooks.** Account-level webhooks registered through `sync_accounts.accounts[].notification_configs[]` emit `product.{created,updated,priced,removed}`, `signal.{created,updated,priced,removed}`, and `wholesale_feed.bulk_change` notifications. Each webhook carries `core/wholesale-feed-webhook.json`: the actual changed product/signal payload or bulk-change summary, the post-change `wholesale_feed_version`, and `applies_to.scope` for cache invalidation. There is no polling event task; consumers repair missed or distrusted pushes through `get_products` / `get_signals`. **Cache layering is the load-bearing design choice.** Every response declares `cache_scope: "public" | "account"` (schema-required — the safety property of the two-layer cache depends on it). When the request had no `account`, MUST be `"public"`. When the request had `account`, the seller declares `"public"` (this account prices off the rate card — buyer dedupes with the unauthenticated view) or `"account"` (custom overrides — buyer caches under the account key). Most accounts at most sellers price off the public layer, so a consumer holding N account caches typically dedupes into one public cache + a small number of overlays. Events carry `applies_to.scope` (with optional `account_ids[]`) so consumers invalidate the right cache layer — public events cascade to all overlays; account events touch only the named overlays. Sellers MAY downgrade an account from `"account"` back to `"public"` by returning a public-scope response on a previously-account-scoped tuple, signalling "this account no longer has overrides; drop the overlay." **Security posture is honest.** The advisory-payload framing makes explicit that re-verifying a feed event against `get_products` / `get_signals` defends against transport tampering only — a compromised agent operator re-confirms its own lie. Operator-compromise defense lives in the existing trust anchors that gate spend (signed `create_media_buy` response, `adagents.json` pinned signing keys for marketplace-signal provenance), with content-signing of feed events deferred to the 4.0 R-1 root-of-trust track. Treat events as cheap mirror invalidation, not as the basis for any decision that commits dollars or authority. Capability declarations: `wholesale_feed_versioning` (conditional fetch + `pricing_version_separate` + `cache_scope_account`), `wholesale_feed_webhooks` (webhook change payloads), `media_buy.buying_modes` and `signals.discovery_modes` (wholesale support). Agents that advertise `product.*` webhook events must also advertise wholesale `get_products`; agents that advertise `signal.*` events must also advertise wholesale `get_signals`; `wholesale_feed.bulk_change` must name only a feed family backed by one of those repair paths. JSON Schema for the webhook envelope at `core/wholesale-feed-webhook.json`, wrapping `core/wholesale-feed-event.json` (discriminated on event\_type with 9 branches + `appliesTo` / `removalReason` `$defs`). → Spec: [Wholesale feed webhooks](https://github.com/adcontextprotocol/adcp/blob/main/specs/wholesale-feed-webhooks.md) · [`get_products` § Wholesale feed versioning](/dist/docs/3.1.20/media-buy/task-reference/get_products#wholesale-feed-versioning) · [`get_products` § Cache layering](/dist/docs/3.1.20/media-buy/task-reference/get_products#cache-layering) · [`get_signals` § Wholesale signals feed](/dist/docs/3.1.20/signals/tasks/get_signals#wholesale-signals-feed) · PRs [#4761](https://github.com/adcontextprotocol/adcp/pull/4761) (conditional fetch), [#4762](https://github.com/adcontextprotocol/adcp/pull/4762) (wholesale signals), [#4763](https://github.com/adcontextprotocol/adcp/pull/4763) (feed webhooks), [#4767](https://github.com/adcontextprotocol/adcp/pull/4767) (cluster implementation) ### Product-scoped signal targeting — included vs selectable signals 3.1 adds a product-scoped signal targeting contract for seller-offered signals on media buys. This closes the gap between broad signal discovery and the actual package-level buy surface: * **`included_signals`** describes signals already bundled into, included in, or seller-planned into a product. These are descriptive product metadata, not buyer-selectable controls. * **`data_provider_signals` is deprecated.** It remains for compatibility as legacy bundled metadata, but new implementations use `included_signals` for non-selectable signals and `signal_targeting_options` for selectable ones. * **`signal_targeting_allowed`** tells buyers the product has a package-level `signal_targeting_groups` surface. The default is false. * **`signal_targeting_options`** is the inline selectable menu when the product needs product-specific pricing, activation handles, defaults/fixed selections, grouping hints, or a brief/refine-selected subset. Wholesale products can omit this field and use `get_signals` as the selectable feed. * **`signal_targeting_rules`** declares the product-specific composition contract: direct targeting vs seller-planned resolution, optional/required/fixed selection, min/max counts, and grouping limits. This belongs on the product because a single seller may route products through different ad servers or planning layers. Buyers apply selected signals with `packages[].targeting_overlay.signal_targeting_groups`. The portable baseline is intentionally simple: top-level `operator: "all"` with child `operator: "any"` groups for includes and child `operator: "none"` groups for exclusions. For binary signals, the signal expression uses `value: true`; exclusion is represented by the parent `none` group, not by `value: false`. Signal identity is also normalized. New payloads use `signal_ref`: * `scope: "data_provider"` + `data_provider_domain` + `signal_id` for signals defined in a provider's published adagents.json signals. * `scope: "signal_source"` + `signal_source_url` + `signal_id` for source-native signals not published in upstream adagents.json signals. * `scope: "product"` + `signal_id` for product-local options meaningful only inside the selected product/package context. Legacy `SignalId` / `signal_id.source` remains accepted during the minor-version migration window, including on `get_signals`, audience selectors, legacy flat signal targeting, and wholesale signal events, but `SignalRef` is the canonical shape for new clients. Legacy flat `targeting_overlay.signal_targeting` remains schema-valid but deprecated; new package-level composition uses `signal_targeting_groups`. → Spec: [Product discovery § Signal targeting](/dist/docs/3.1.20/media-buy/product-discovery/media-products#signal-targeting) · [Targeting § signal\_targeting\_groups](/dist/docs/3.1.20/media-buy/advanced-topics/targeting#signal_targeting_groups) · [`get_signals`](/dist/docs/3.1.20/signals/tasks/get_signals) · PR [#5009](https://github.com/adcontextprotocol/adcp/pull/5009) ### Canonical creative formats — live, 12 canonicals, backwards-compatible * **Published-post reference creatives.** Existing social/publisher posts are represented as canonical `video_hosted`, `image`, or `native_in_feed` formats with `asset_source: "publisher_owned_reference"` and a `published_post` slot. Products can declare `required_connections[]` for downstream platform grants such as advertiser account and publisher identity connections. Missing or expired grants use `AUTHORIZATION_REQUIRED` with `error.details.missing_connections[]`; recoverable dependency loss moves creatives to `suspended` rather than policy rejection. Catalog-driven retail media remains `sponsored_placement` with `source_catalog`. Live in 3.1, additive over 3.0. Products carry `format_options[]`: a list of `ProductFormatDeclaration` entries with a `format_kind` discriminator from the canonical enum. **12 canonicals:** `image`, `html5`, `display_tag`, `video_hosted`, `video_vast`, `audio_hosted`, `audio_daast`, `image_carousel`, `native_in_feed`, `responsive_creative`, `sponsored_placement`, `agent_placement`. The enum also includes `custom` as the escape hatch for adopter-defined shapes that do not fit the canonicals. Three canonicals (`sponsored_placement`, `responsive_creative`, `agent_placement`) plus `custom` are tagged **experimental** within the framework; the remaining canonicals are non-experimental. The promotion queue for new canonicals is tracked in [#3666](https://github.com/adcontextprotocol/adcp/issues/3666). **Backwards compatibility.** The v1 `format_ids` path still works. `ProductFormatDeclaration` carries an optional `v1_format_ref: [{agent_url, id}]` array so v2 declarations link to one or more v1 named formats — sellers can dual-emit during the migration window. SDKs treat the enum as **open at parse time**: unknown future canonicals don't fail validation; SDKs may surface a local routing status such as `declared_only`, but that status is not a 3.1 wire field. **Publisher catalogs.** `list_creative_formats(publisher_domain="…")` returns the publisher's authoritative format list by reading `/.well-known/adagents.json formats[]`, falling back to the AAO community mirror, then to agent-derived. Response carries `source: "publisher" | "aao_mirror" | "agent_derived"` so buyers know which tier produced the list. **Size flexibility.** Display canonicals declare size in three modes: fixed (`width`+`height`), multi-size (`sizes: [{w,h}]` — mirrors OpenRTB `banner.format[]`), or responsive (`min_width`/`max_width`/`min_height`/`max_height`). Mutually exclusive. **Hosted audio/video duration ranges.** `audio_hosted` and `video_hosted` use `duration_ms_exact` for fixed-duration slots and `duration_ms_range` for bounded or one-sided ranges. Either endpoint of `duration_ms_range` MAY be `null`: `[null, 60000]` means "up to 60 seconds" and `[15000, null]` means "at least 15 seconds". `[null, null]` is invalid, and `duration_ms_exact` takes precedence if both duration fields are present. → Spec: [Canonical formats](/dist/docs/3.1.20/creative/canonical-formats) · PRs [#3307](https://github.com/adcontextprotocol/adcp/pull/3307), [#4770](https://github.com/adcontextprotocol/adcp/pull/4770), [#5323](https://github.com/adcontextprotocol/adcp/pull/5323) ### Creative transformers — discover build capability, select, fan out, variant 3.1 introduces **transformers**: the creative analog of media-buy products. A transformer is an agent-offered, account-scoped, selectable unit of build capability — a voice, a model, a style, a director — with a typed configuration surface and per-account pricing. The set is account-specific and dynamic (your configured voices aren't a global enum), so discovery flows agent → buyer the same way `get_products` surfaces account-scoped inventory. * **`list_transformers`** is the new discovery surface. It returns the transformers a creative agent offers for your account, each with `input_format_ids` / `output_format_ids`, a typed param schema, and (with `include_pricing`) a `per_unit` rate card. Its `expand_params` mode returns the account-scoped enumerable option values for a param — your actual configured voices, for example — on the same tool, instead of forcing you to hold a stale local list. Offered only by agents that set `creative.supports_transformers: true` in `get_adcp_capabilities`. * **`build_creative` selects and configures a transformer.** Pass `transformer_id` to pick one — the target format(s) MUST be a subset of its `output_format_ids` — and a typed `config` bag keyed to the transformer's params. Validation is strict: the agent MUST reject unknown keys and out-of-range values with field-attributed errors; vendor-specific knobs go in `ext`. * **Two fan-out axes.** `max_creatives` is the item/catalog axis: N distinct creatives, one per catalog item ("5 of 150" sampling) — distinct from `item_limit`, which caps the items used *within* one creative. `max_variants` (default 1) produces alternatives per creative along a `variant_axis` (`voice` | `theme` | `best_of_n` | `transformer_config` | `custom`, with optional `values[]` and `label`). `keep_mode` (`keep_all` | `keep_one` | `keep_some`) is advisory. Resolutions and quality levels are the **format** axis (`target_format_ids`), not variants. * **New `BuildCreativeVariantSuccess` response member** (`oneOf` member 3 of 6) carries `creatives[]`, each `{ build_creative_id, catalog_item_ref?, variants[] }`; each variant is `{ build_variant_id, creative_manifest, variant_axis_value?, recommended, rank?, ... }` with a per-leaf pricing receipt (`pricing_option_id` + `vendor_cost` + `currency` + `consumption`). When the build reports cost (the aggregate `vendor_cost` is present), every produced leaf carries its own `vendor_cost` + `currency` (schema-enforced). Top-level `items_total` / `items_returned` plus an aggregate `vendor_cost`. The shipped `BuildCreativeSuccess` / `BuildCreativeMultiSuccess` are **unchanged**. * **Best-of-N is variants + `keep_mode` + `recommended`/`rank`.** `build_variant_id` is its own namespace — never reuse `preview_id` (preview renders) or served `variant_id` (delivery). You **pay for all produced variants** (`per_unit` × N); keeping is a client act of trafficking the chosen `build_variant_id`(s). A kept variant lazily earns a `creative_id` (added to the library / first trafficked) which flows to `report_usage`. Per-**format** generation is atomic; per-**item** (catalog fan-out) is non-atomic. * **Evaluator ranking reuses creative-feature discovery.** Agents with `creative.supports_evaluator: true` use the existing `get_adcp_capabilities.governance.creative_features` catalog as the evaluator feature-discovery surface. `rank_by`, `feature_requirement`, and `variants[].eval.features[]` all refer to that same feature vocabulary; `evaluator_id` is a pre-provisioned account preset, not an ID from that catalog. `feature_agent.agent_url` selects an allowlisted external scoring path and `feature_id` disambiguates the requested feature subject to the seller's accepted-verifier entry. Sellers SHOULD echo external judge usage per leaf with fields such as `eval.calls_used` / `eval.seconds_used` when `agent_url` evaluation runs under an `eval_budget`. * **Pricing moves onto the transformer.** The rate lives on `transformer.pricing_options` (`per_unit`), echoed inline as a per-leaf receipt on `build_creative`, and settled via `report_usage`. `Format.pricing_options` is **deprecated** in favor of `transformer.pricing_options`. **Deprecations (3.1, removed in 4.0).** `Format.input_format_ids`, `Format.output_format_ids`, and `Format.pricing_options`, plus the `input_format_ids` / `output_format_ids` filters on `list_creative_formats`, are deprecated and all redirect to `list_transformers`. SDKs honor them through 3.1–3.x; they are removed at 4.0. Migrate format-attached input/output/pricing reads to `list_transformers`. Full migration (incl. the discovery-degradation, per-output-pricing, and best-of-N spend hazards): [Migration › Creative transformers](/dist/docs/3.1.20/reference/migration/creative-transformers). → Spec: [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers) · [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) · [`get_adcp_capabilities` § creative features / evaluator support](/dist/docs/3.1.20/protocol/get_adcp_capabilities) ### Release-precision version negotiation — pin your release Every request and response now carries `adcp_version` (release-precision: `"3.1"` for the stable release); sellers advertise their full `supported_versions` set on `get_adcp_capabilities` and echo the release they actually served at the envelope root. SDKs pin via a constructor option (`adcpVersion: "3.1"` JS, `adcp_version="3.1"` Python, `WithAdcpVersion("3.1")` Go) and emit both the new string and the integer `adcp_major_version` mirror for compatibility with sellers that only read the legacy field. The integer remains functional through all of 3.x — additive ship, no required changes for 3.0-conformant agents. `VERSION_UNSUPPORTED` is typed with `error.data.supported_versions[]` echoed so retry doesn't require an out-of-band lookup. → Spec: [Versioning § Version negotiation](/dist/docs/3.1.20/reference/versioning#version-negotiation) · PR [#3493](https://github.com/adcontextprotocol/adcp/pull/3493) ### Vendor-attested measurement — `vendor_metric` goals + per-product capabilities Optimization goals now support a third `kind: "vendor_metric"` shape — bind goals to vendor-attested metrics like attention (DV, IAS, Adelaide, TVision, Lumen), panel-based brand lift (Kantar, Upwave, Cint), emissions (Scope3, Good-Loop), and retail-media partner metrics. Closes the gap where 3.0's vendor-agnostic enum values like `attention_seconds` were meaningless without a vendor binding. Sellers declare per-product `vendor_metric_optimization` with `supported_metrics[]` (the `(vendor, metric_id)` pairs the bidding stack can steer toward). A three-precondition rejection rule on goal acceptance — discovery, capability, reporting coherence — ensures the goal is steerable AND reportable end-to-end. Plus seller-level `supported_optimization_metrics` and `supported_target_kinds` on `conversion_tracking` for capability-gated compliance scenarios. The measurement-vendor catalog that defines `measurement.metrics[]` is experimental in 3.1. Vendors implementing it must declare `measurement.core` in `experimental_features`; buyers should treat catalog discovery as a 3.x experimental surface until measurement tasks and compliance storyboards are frozen. → Spec: [Optimization goals § `vendor_metric` kind](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting#vendor-metric-goals) · PRs [#4668](https://github.com/adcontextprotocol/adcp/pull/4668), [#4669](https://github.com/adcontextprotocol/adcp/pull/4669), [#4649](https://github.com/adcontextprotocol/adcp/pull/4649) ### Delivery reporting — `reach_window`, `viewed_seconds`, windowed pulls Three additive surfaces close reporting gaps. **`reach_window`** declares the measurement window for `reach` and `frequency` (cumulative / period / rolling) — buyers MUST NOT sum reach across rows without it. **`viewability.viewed_seconds`** reports average in-view duration per measurable impression, the reporting-side counterpart to the `viewed_seconds` optimization goal. **Windowed pull recovery** on `get_media_buy_delivery` accepts `time_granularity` + `include_window_breakdown: true`, returning `windows[]` slices shape-aligned with `reporting_webhook` payloads at the same granularity — a buyer who missed a webhook fire reconstructs identical data by polling. Capability-scoped via `reporting_capabilities.windowed_pull_granularities`; sellers can honestly declare asymmetric webhook-vs-pull frequencies. → Spec: [Delivery metrics reference](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) · PRs [#4618](https://github.com/adcontextprotocol/adcp/pull/4618), [#4601](https://github.com/adcontextprotocol/adcp/pull/4601) ### Billing surface — authority, finality, and out-of-band Two complementary changes close the billing-grade reporting story. **Authority + finality flags:** `get_media_buy_delivery` responses now carry row-level `is_final` + `finalized_at` on `media_buy_deliveries[*]` and on each `by_package[*]` — buyers know when a number stops moving and is safe for invoice reconciliation. Symmetric on `report_usage`: each usage record carries `final` (default `true`), `finalized_at`, and `measurement_window`. **`bills_through_adcp` + `BILLING_OUT_OF_BAND`:** creative agents declare via `capabilities.creative.bills_through_adcp` whether they bill on-protocol or out-of-band (flat license, SaaS, bundled enterprise — CM360 is the canonical case). Buyers pre-filter; sellers in out-of-band mode reject `report_usage` calls with the new `BILLING_OUT_OF_BAND` error rather than silently accepting. → Spec: [Billing measurement](/dist/docs/3.1.20/media-buy/advanced-topics/accountability#billing-measurement) · [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) · PRs [#4735](https://github.com/adcontextprotocol/adcp/pull/4735), [#4561](https://github.com/adcontextprotocol/adcp/pull/4561) ### Action discovery — `allowed_actions` and `available_actions` Structured action vocabulary for buy lifecycle mutations. Products advertise `allowed_actions[]` as an advisory template (which mutations the product *generally* supports, with `modes[]` and `allowed_statuses[]`). Media buys carry `available_actions[]` on `get_media_buys` / `create_media_buy` / `update_media_buy` responses — the current set of valid mutations for *this* buy in its current state. Buyers pre-flight which mutations are valid instead of issuing a call and getting `INVALID_STATE`. Finer-grained values added to `media-buy-valid-action` enum; legacy coarse values retained through 3.x for backwards compat (removed in 4.0). 3.1 removes the pre-GA `requires_proposal` action mode. Proposal lifecycle now has one path: `proposal_status` says whether finalization is required; `finalize` is seller commitment to firm pricing/terms/hold; `create_media_buy(proposal_id)` is buyer acceptance/execution. If an `update_media_buy` request exceeds the current quoted envelope, sellers return `REQUOTE_REQUIRED` instead of modeling a proposal-required action mode. Buyers with cached prerelease action metadata containing `requires_proposal` must discard it and re-read the current product or buy action surface. 3.1 does not define an amendment-quote artifact for updates. → Spec: [Media Buy Lifecycle § Action discovery](/dist/docs/3.1.20/media-buy/media-buys/lifecycle#action-discovery) · [Product discovery § Proposals](/dist/docs/3.1.20/media-buy/product-discovery/media-products#proposals) · PR [#4514](https://github.com/adcontextprotocol/adcp/pull/4514) ### Auth + security tightening Four complementary changes: **`AUTH_REQUIRED` split** into `AUTH_MISSING` (correctable — retry with credentials) and `AUTH_INVALID` (terminal — credentials presented and rejected; rotate or escalate; do NOT auto-retry). Recovery classifications now match operator reality. **`CREDENTIAL_IN_ARGS`** new error code: sellers MUST reject requests that smuggle buyer-principal credentials into the task payload instead of the transport authentication channel — closes a prompt-injection exfiltration surface. **Request-signing `protocol_methods_*` namespace** — RFC 9421 signing scope tightened to the AdCP method surface only. **`comply_test_controller` sandbox gate** — every controller call MUST carry `account.sandbox: true` and the seller MUST verify against its persisted account record, not trust the field. Defense-in-depth boundary between sandbox and production. → Spec: [Error handling § Recovery Classification](/dist/docs/3.1.20/building/by-layer/L3/error-handling#recovery-classification) · PRs [#3739](https://github.com/adcontextprotocol/adcp/pull/3739), [#4057](https://github.com/adcontextprotocol/adcp/pull/4057), [#4326](https://github.com/adcontextprotocol/adcp/pull/4326), [#4382](https://github.com/adcontextprotocol/adcp/pull/4382)/[#4392](https://github.com/adcontextprotocol/adcp/pull/4392) ### Idempotency — Rules 9 + 10 + `IDEMPOTENCY_IN_FLIGHT` Two new rules close production-edge cases. **Rule 9 (concurrent retries):** when a buyer retries before the original call has produced a cached response, the seller MAY return `IDEMPOTENCY_IN_FLIGHT` (new error code) instead of blocking — useful when the first call invokes a slow downstream system (SSP, ad server, payment provider). Buyers MUST treat it as transient and MUST NOT mint a fresh `idempotency_key`. **Rule 10 (downstream reconciliation):** explicit guidance on how buyers reconcile when an `IDEMPOTENCY_EXPIRED` response arrives and they have evidence the original succeeded — perform a natural-key check (e.g., `get_media_buys` by `context.internal_campaign_id`) before generating a fresh key. **`capabilities.idempotency.in_flight_max_seconds`** new capability — seller declares how long an in-flight call can take so buyers tune retry pacing. → Spec: [Calling an agent § Idempotency](/dist/docs/3.1.20/protocol/calling-an-agent) · PRs [#4402](https://github.com/adcontextprotocol/adcp/pull/4402), [#4409](https://github.com/adcontextprotocol/adcp/pull/4409) ### TMP IdentityMatch upgrades Three additive changes: **`serve_window_sec`** new required field on responses (1–300 seconds) — router caches the eligibility decision for this many seconds before re-querying. Replaces the prior `ttl_sec` framing with frequency-cap-data-flow-aware semantics. **`seller_agent_url`** now required on requests so the router can route the decision back to the originating seller. **`package_ids`** moved from required to optional — routers can ask "is this user eligible at all?" without enumerating packages. → Spec: [TMP IdentityMatch implementation](/dist/docs/3.1.20/trusted-match/identity-match-implementation) · PRs [#4070](https://github.com/adcontextprotocol/adcp/pull/4070), [#3687](https://github.com/adcontextprotocol/adcp/pull/3687) ### `adagents.json` — managed-network scale, manager-domain fallback, revocation semantics Three production-scale improvements. **Managed-network scale:** authoritative `adagents.json` now caps at 20 MB; managers publishing large agent networks switch to a compact `publisher_domains[]` form that lists owned domains without inlining every property. **Manager-domain fallback:** when a publisher's authoritative `adagents.json` is absent, crawlers fall back to the `managerdomain` declared in `ads.txt` — closes the discovery gap for S3 / CloudFront-hosted publishers that can't return a 404 directly. **Revocation semantics:** `revoked_publisher_domains[]` is now strictly time-bound — revocation is a published fact with a discoverable timestamp, not a silent removal. Tightens trust propagation across the managed network. → Spec: [`adagents.json` reference](/dist/docs/3.1.20/governance/property/adagents) · PRs [#4504](https://github.com/adcontextprotocol/adcp/pull/4504), [#4173](https://github.com/adcontextprotocol/adcp/pull/4173), [#4536](https://github.com/adcontextprotocol/adcp/pull/4536) ### Compliance suite — capability-gated scenarios Capability-gated storyboard scenarios let sellers run *only* what they claim. New scenarios for `frequency_cap_enforcement`, `per_creative_attribution`, `metric_mode` + ROAS (using a `contains:` matcher), `audience_buy_flow`, `event_dedup_flow`, and `performance_buy_flow` (capability-gated CPA buys). Plus a new `requires` runtime gate on Storyboards that conditions execution on declared capabilities — no more all-or-nothing scenarios. The full set is enumerated on the [Compliance catalog](/dist/docs/3.1.20/building/verification/compliance-catalog). **Late pre-GA compliance update:** money-moving seller specialisms now exercise baseline `sync_governance` registration before spend-committing flows: `sales-guaranteed`, `sales-non-guaranteed`, `sales-broadcast-tv`, `sales-catalog-driven`, `sales-social`, and the generative seller flow under `creative-generative`. This does not make every seller governance-aware; `governance-aware-seller` remains the opt-in claim for `check_governance` consultation and propagation. Existing pre-GA sellers claiming these specialisms must implement `sync_governance` registration and reject payloads with more than one `governance_agents` entry to remain conformant in 3.1 grading. **Compliance packaging closure:** packaged compliance artifacts are now self-contained. Webhook receiver envelope vectors live under the versioned compliance tree, and release validation fails when authored vector/test-kit references do not resolve inside the packaged `/compliance/{version}/` bundle or protocol tarball. Signal conformance is also split by obligation: `signal-owned` and the baseline signals protocol are discovery-only (`get_signals`), while `signal-marketplace` requires `activate_signal`. → Spec: [Compliance catalog](/dist/docs/3.1.20/building/verification/compliance-catalog) · PRs [#4312](https://github.com/adcontextprotocol/adcp/pull/4312), [#4642](https://github.com/adcontextprotocol/adcp/pull/4642), [#4664](https://github.com/adcontextprotocol/adcp/pull/4664), [#4722](https://github.com/adcontextprotocol/adcp/pull/4722), [#4727](https://github.com/adcontextprotocol/adcp/pull/4727), [#4731](https://github.com/adcontextprotocol/adcp/pull/4731), [#5187](https://github.com/adcontextprotocol/adcp/pull/5187) ### Final-spec clarifications (WG-review batch) Normative tightenings landed as the spec settled through prerelease validation. Mostly low-risk — adopters who'd already inferred reasonable defaults will continue working — but worth knowing about because the 3.1 grader checks them. * **`PROPOSAL_NOT_FOUND` error code** (#4043). Completes the proposal-lifecycle error catalog (alongside `PROPOSAL_EXPIRED` and `PROPOSAL_NOT_COMMITTED`). Sellers MUST return it when a referenced `proposal_id` isn't recognized — wrong tenant, evicted from cache, or never finalized. Recovery: correctable. * **Forward-compatible `error.code` decoding** (#4227). Receivers MUST treat `error.code` as an **open enum** — decode unknown codes without rejecting, classify recovery from `error.recovery`, default to `transient` when recovery is absent. Senders from 3.1 onward MUST populate `error.recovery` on every error. Unblocks additive-in-patch for error codes on future maintenance lines without breaking pinned-version receivers. * **`idempotency_key` required on every AdCP task request** (#4399). Closes a longstanding gap where the spec said dedupe via idempotency\_key but didn't require buyers to send one. Sellers MAY reject requests missing the key after 3.1 GA. * **MCP tool wrappers MUST tolerate envelope fields** (#4399). The protocol envelope (`status`, `context_id`, `context`, `task_id`, `timestamp`, `replayed`, `adcp_error`, `governance_context`, `idempotency_key`) on MCP requests now goes through the wrapper layer instead of being rejected as "unexpected fields." Closes a wrapper-layer bug where adopters had to omit envelope fields to call MCP successfully. * **MCP serialization normalization** (#2911). Drops `payload.required` from the protocol-envelope schema; adds the `context` field at envelope level; clarifies the flat-sibling MCP wire shape (envelope and body fields at the root, no nested `payload:` key). Adopters who'd implemented the de-facto flat shape are unaffected. * **Idempotency replay returns historical snapshot** (#4371). When a buyer retries a stateful create call (e.g., `create_media_buy`) within the replay window, the seller MUST return the **historical snapshot** of state-tracking fields (`status`, `confirmed_at`, etc.) — not the current state. Otherwise an at-most-once retry mutates the response from underneath the buyer. * **`refine[]` finalize-exclusivity + multi-finalize atomicity** (#4107). `get_products` `refine[]` semantics tightened: when any entry uses `action: "finalize"`, every entry in the array MUST be proposal-scoped with `action: "finalize"`; sellers reject mixes of finalize and non-finalize refinement with `INVALID_REQUEST`. Multi-finalize across multiple proposals is allowed only when the seller can guarantee atomic commit across every named proposal. Sellers that cannot guarantee that atomicity MUST reject the multi-finalize array with `MULTI_FINALIZE_UNSUPPORTED` (preferred) or `INVALID_REQUEST`, and buyers can then sequence single-proposal finalize calls if they accept the looser commit guarantee. * **`pending_creatives` status disambiguation** (#4196). The description now explicitly states buyer action is required — sellers awaiting creative sync MUST surface what's missing (creative count, deadline) in the response message rather than just emitting the status enum value. Clarifies adopter-facing UX without changing the wire shape. * **`notices` advisory channel on runner-output-contract** (#4418). Storyboard runners surface non-failure advisories (e.g., "agent still advertises a deprecated specialism, but the storyboard passed") through a structured `notices[]` field on the run output. Replaces ad-hoc `skip.detail` prose carrying advisory text — unparseable for graders and dashboards. * **Governance body-level `status` renamed** (#4897). `check_governance` response: `status` → `verdict` (enum unchanged: `approved` / `denied` / `conditions`). `report_plan_outcome` response: `status` → `outcome_state` (enum unchanged: `accepted` / `findings`). `get_plan_audit_logs` entries cascade: `entries[].status` → `entries[].verdict` for consistency. Frees the top-level `status` key for the envelope task-status under MCP flat-on-the-wire serialization (#4876, #2911). Migration: rename the property in every emitter and consumer of these three response shapes; values do not change. Governance is an experimental surface per `x-status`, so this is a sanctioned 3.1 wire-shape adjustment ahead of GA. * **Media-buy body-level `status` collision — additive-deprecate** (#4895). `create_media_buy` and `update_media_buy` success responses gain a new top-level `media_buy_status` field. The legacy top-level `status: MediaBuyStatus` form is marked `deprecated: true` and removed in **3.2** (#4906); compliance storyboards already require the new field. Nested `status` on `get-media-buys-response`, `get-media-buy-delivery-response`, and `core/media-buy.json` are out of scope here and addressed in the **4.0** cascade (#4905). Full migration: [Migration › `media_buy_status`](/dist/docs/3.1.20/reference/migration/media-buy-status). * **Proposal lifecycle, signal privacy metadata, and measurement locks.** `proposal_status` is the per-proposal source of truth, `supports_proposals` is a conformance grading declaration, `finalize` is seller commitment, and `create_media_buy(proposal_id)` is buyer execution. The pre-GA `requires_proposal` action mode is removed in favor of `REQUOTE_REQUIRED` for updates outside the quoted envelope; buyers with cached prerelease action metadata containing `requires_proposal` must invalidate it and re-read the relevant product or buy surface. Signal definitions do not declare Global Privacy Control support, and projected `consent_basis` / `art9_basis` values on `get_signals` rows remain provider-declared signal-definition posture. The measurement catalog remains experimental and agents implementing it declare `measurement.core` in `experimental_features`. → Full batch shipped as one commit: PR [#4796](https://github.com/adcontextprotocol/adcp/pull/4796) (`4c124545f1`). See per-issue links for the full prose. ### Misc schema additions **`media_buy.frequency_capping` capability declaration** (#4670) — seller declares which frequency-cap surfaces it honors. **SDK generation ergonomics** (#5168) — common inline object and array item shapes now have stable core schema names so SDKs do not invent local wrapper names. `x-adcp-open-payload` marks intentionally open JSON payload fields for generators that need to preserve open maps rather than collapsing them into closed typed models. **Open compliance scenario strings** (#5168) — `comply_test_controller.scenario`, `list_scenarios.scenarios[]`, and `compliance_testing.scenarios[]` are `string` rather than a closed enum; SDKs SHOULD parse them as strings and MAY layer known-value helpers locally. SDKs that previously generated a literal union for these fields should widen to `string` and keep default handling for unknown scenario names. **`x-adcp-hoist` opt-in marker** (#4630) — canonically-shared object schemas declare themselves as hoistable into shared types. **`allowed_values` on text-asset-requirements** (#4333) — closed-set text assets (CTA, etc.) declare the allowed values so buyers can constrain generation. **`vast_tracker` + `daast_tracker` asset types** (#3051) — video and audio tracker assets. **Optional `currency` + `total_budget`** on `create_media_buy` / `update_media_buy` success responses (#4417). **Async envelope to `sync_audiences`** (#4571) — three-shape submitted envelope (Success / Error / Submitted) extended from create-style tasks. → See [Release Notes § Version 3.1.0](/dist/docs/3.1.20/reference/release-notes#version-3-1-0) for the per-PR detail. ## Adopter action | If you are… | What you need to do | | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A 3.0-conformant production agent | Nothing required to remain on 3.0 — 3.1 changes are additive and a 3.0 client keeps working. When you are ready to claim 3.1, bump your SDK pin to `"3.1"` to pick up the new fields and emit `adcp_version` for forward-compatibility with future minors. | | A buyer running production campaigns | Bump your SDK and pass `adcpVersion: "3.1"` on construction after the seller advertises `"3.1"` in `supported_versions`. Implement `webhook_activity[]` reads when you suspect a missed fire. Read `media_buy.health` + `impairments[]` on every `get_media_buys` poll. Implement the `impairment.coherence` invariant in your reconciliation pipeline. | | A seller running production buys | Surface `media_buy.health` + `impairments[]` whenever a referenced resource transitions offline. Declare `capabilities.media_buy.propagation_surfaces` honestly. Implement `webhook_activity[]` so buyers can debug their integration end-to-end without operator round-trips — buyer self-service is integration-friction reduction, not seller charity. Mark `is_final` on every delivery row so buyers know when to reconcile. | | A seller claiming money-moving specialisms | Implement `sync_governance` on the account surface, register one governance agent per account using the brand/operator account reference, and reject registrations with multiple `governance_agents` entries. `check_governance` calls are still only required when also claiming `governance-aware-seller`. | | A brand agent implementing `verify_brand_claim` or `verify_brand_claims` | Return `signed_response` on every success response. Publish a per-brand `adcp_use: "response-signing"` JWK, keep unsigned response fields byte-equivalent to `signed_response.payload.response`, and retain bulk audit evidence with the original request and result index. | | A seller using proposals or action discovery | Route proposal execution from `proposal_status`, not `supports_proposals` or action modes. Do not emit `requires_proposal`; use `REQUOTE_REQUIRED` when an update exceeds the quoted envelope and the buyer must rediscover terms or create a separate buy. | | A buyer with cached prerelease `action_mode: "requires_proposal"` values | Treat the cached value as unknown. Do not map it to `requires_approval`; that mode is an asynchronous human-approval gate and has no proposal artifact. If the cached value was used for proposal executability, re-read the proposal through `get_products` and branch on `Proposal.proposal_status`: `draft` means finalize first, `committed` is ready for `create_media_buy(proposal_id)`. If it came from product `allowed_actions[]` or buy `available_actions[]`, invalidate that cached action metadata and re-read the current product or buy action surface. | | A prerelease 3.1 signals adopter that previously saw `data_subject_rights.gpc_honored` | Remove the field from signal-level rights routing. 3.1 does not declare Global Privacy Control support on signal definitions; GPC remains a serve-time publisher/bidstream concern. Use provider policy, registry disclosures, and CCPA/state-law opt-out routing such as `ccpa_opt_out_url` for implementation guidance, but do not infer GPC handling from `data_subject_rights`. | | A sub-brand team that wants self-publish authority | Stand up `/.well-known/brand.json` at your own domain as a Brand Canonical Document. Declare `house_domain: ""`. Ask the parent house team to reciprocate via `brand_refs[]`. | | A consumer maintaining a wholesale product feed and wholesale signals feed mirror (storefront, federated marketplace, registry) | Bootstrap via `get_products buying_mode: "wholesale"` and/or `get_signals discovery_mode: "wholesale"`; persist the returned `wholesale_feed_version` + `cache_scope`. On subsequent polls send `if_wholesale_feed_version` and skip the full payload when the seller responds `unchanged: true`. If the agent declares `wholesale_feed_webhooks.supported`, register change webhooks through `sync_accounts.accounts[].notification_configs[]`; apply the webhook payload to the mirror and track `applies_to.scope` to invalidate the right cache layer (public vs. account overlay). Handle `wholesale_feed.bulk_change` or missed pushes by re-reading `get_products` / `get_signals`. | | A signals agent | Declare `signals.discovery_modes: ["brief", "wholesale"]` to expose your full priced signals feed for mirroring. Return `cache_scope` on every response (REQUIRED — schema-enforced). Pre-3.1 callers without `discovery_mode` continue to get brief-mode behavior. If you only claim `signal-owned`, `get_signals` discovery is enough; claim `signal-marketplace` only when you also implement `activate_signal`. | | A sales / signals agent serving wholesale feed mirrors at scale | Declare `wholesale_feed_webhooks.supported: true` and support webhook registration through `sync_accounts.accounts[].notification_configs[]`, with standard account-level webhook signing, endpoint proof-of-control before activation, SSRF guards, account/caller authorization checks, and the notification-config fan-out cap. Keep `event_types[]` consistent with declared repair reads: `product.*` requires wholesale `get_products`, `signal.*` requires wholesale `get_signals`. The webhook emitter MUST include the actual changed product/signal payload or bulk-change summary and apply the same per-caller scope filter as your wholesale task at event-emission time — multi-tenant agents that can't reliably scope events per-principal MUST NOT declare the capability. | | A measurement vendor (attention, brand lift, emissions, retail) | Publish your experimental `measurement.metrics[]` catalog at your AdCP agent and declare `experimental_features: ["measurement.core"]`. Sellers declaring `vendor_metric_optimization` per product can now bind optimization goals to your `(vendor, metric_id)` pairs. | | A creative agent | Declare `capabilities.creative.bills_through_adcp` honestly. If you bill out of band, reject `report_usage` calls with `BILLING_OUT_OF_BAND` rather than silently accepting. | | A creative agent offering transformers | Declare `creative.supports_transformers: true` and serve `list_transformers` (including the `expand_params` option-enumeration mode). On `build_creative`, validate the typed `config` strictly — reject unknown keys and out-of-range values with field-attributed errors, route vendor knobs through `ext`, and verify each target format is a subset of the transformer's `output_format_ids`. Return `BuildCreativeVariantSuccess` with per-leaf pricing receipts when fanning out across catalog items or variants. Move rate cards from `Format.pricing_options` to `transformer.pricing_options` and settle through `report_usage`. | | A seller or creative agent declaring hosted audio/video formats | Use `duration_ms_exact` for fixed-duration slots and `duration_ms_range` for bounded or one-sided ranges. `[null, 60000]` means "up to 60 seconds"; `[15000, null]` means "at least 15 seconds"; `[null, null]` is invalid. Do not add separate bare min/max duration fields. | | Maintaining SDK fixtures, compliance mirrors, or release packaging | Keep compliance vector and test-kit references inside the packaged `/compliance/{version}/` tree. Treat missing bundle-relative references as release blockers. | | An SDK author | Pin `published_version` to the published 3.1 artifact you bundle; emit `adcp_version` (release-precision string) plus `adcp_major_version` (integer mirror); normalize semver values to release-precision before wire emission (`"3.1.0"` → `"3.1"`); surface `VERSION_UNSUPPORTED` as a typed error rather than auto-downshifting. | ## Migration **Bottom line: no breaking changes; additive only; you should bump.** All 3.1 changes are **additive** over 3.0. New fields are optional, no required field was removed, and no shape changed in a way that breaks a 3.0-conformant client. A buyer running against a 3.1 seller without upgrading their SDK keeps working — they just won't see the new fields. A seller running 3.0 schemas against a 3.1 buyer keeps working — the buyer's new fields are silently ignored. But the new surfaces solve real production problems, and the longer you stay on 3.0 the more you're operating without the production-hardening 3.1 added: webhook delivery debug, dependency-impact observability, billing finality flags, action discovery, vendor-attested measurement, release-precision negotiation. **Bump as soon as your SDK is ready.** The one publisher-visible behavior change is on `brand.json` `trademarks[]`: free-text `status` / `countries` values now validate against typed enum / ISO 3166-1 alpha-2 — non-conforming values surface as schema errors. If your `trademarks[]` published unrestricted free text, normalize the values before claiming 3.1. Prerelease 3.1 adopters should also update prerelease-only integrations: brand verification success responses now require `signed_response`; proposal/action code must remove the temporary `requires_proposal` action mode and use `REQUOTE_REQUIRED` for update repricing; buyers with cached `requires_proposal` values must invalidate and re-read the relevant proposal, product, or buy surface rather than mapping them to `requires_approval`; signal definitions do not declare Global Privacy Control support; `signal-owned` conformance is discovery-only while `signal-marketplace` remains the activation claim; hosted audio/video declarations should use one-sided `duration_ms_range` rather than separate bare min/max fields; and measurement catalog discovery remains experimental behind `measurement.core`. These were prerelease cleanup items before GA, not 3.0 breaking changes. **Validator obligation for 3.1 SDKs.** The wholesale feed mirroring work tightens one shape within 3.1: `cache_scope` is schema-required on every `get_products` / `get_signals` response (the safety property of the two-layer cache depends on it — see [Cache layering](/dist/docs/3.1.20/media-buy/task-reference/get_products#cache-layering)). Pre-3.1 sellers correctly omit the field and remain conformant to their declared version. SDKs that validate strictly against the 3.1 schema MUST select the validator based on the server-declared `adcp_version` (the same release-precision mechanism 3.1 ships in version negotiation): for responses with `adcp_version` starting `3.0`, the 3.1 `cache_scope`-required constraint MUST be relaxed. This is a tightening within 3.1, not a 3.0 break — but SDKs that hardcode the 3.1 schema without version-pinned validation will reject correct 3.0 traffic. Version-pinned validation is the right pattern for every 3.x→3.(x+1) tightening; cache\_scope is the first time it's load-bearing. For the per-PR detail, see [Release Notes § Version 3.1.0](/dist/docs/3.1.20/reference/release-notes#version-3-1-0). For the version-negotiation cadence and the 3.1 → 3.2 → 4.0 timeline, see [Versioning & Governance § Migration timeline](/dist/docs/3.1.20/reference/versioning#migration-timeline). # AdCP 3.0 Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/reference/whats-new-in-v3 What's new in AdCP 3.0: brand identity and rights, creative workflow upgrades, governance, sponsored intelligence, collections and installments, 20 media channels, and migration guides from v2. AdCP 3.0 expands the protocol beyond media buying into brand identity, governance, media planning, and conversational brand experiences. **v2 is unsupported as of 3.0 GA and fully deprecated on August 1, 2026 (UTC).** See the [v2 sunset page](/dist/docs/3.1.20/reference/v2-sunset) for the timeline and AAO registry policy. ## At a glance | Area | v2.x | v3.x | | ----------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Protocol scope** | Media Buy, Signals, Creative | Adds Brand Protocol, Governance, Sponsored Intelligence (experimental) | | **Brand identity** | No standard mechanism | `brand.json` + community brand registry | | **Governance** | No brand suitability protocol | Property lists, content standards, campaign governance, policy registry, and brand calibration | | **Sponsored Intelligence** (experimental) | No conversational brand protocol | Consent-first brand sessions in AI assistants | | **Accounts Protocol** | No formal account model | Named protocol layer: `sync_accounts`, `list_accounts`, `report_usage`, brand registry-grounded identity | | **Catalogs** | `promoted_offerings` creative asset | First-class `sync_catalogs` task with 13 catalog types | | **Media planning** | Products only | Proposals with budget allocations + delivery forecasts | | **Brand rights** | No licensing protocol | `get_rights`, `acquire_rights`, `update_rights` with HMAC-authenticated webhooks — shipping [experimental](/dist/docs/3.1.20/reference/experimental-status) in 3.0 | | **Visual guidelines** | No structured brand visuals | `visual_guidelines` on `brand.json` for generative creative systems | | **Creative workflows** | Basic build / preview / sync separation | Inline preview, multi-format `build_creative`, library retrieval, quality tiers, and catalog `item_limit` | | **Creative libraries** | Media Buy-centric creative reads/writes | `list_creatives` / `sync_creatives` as Creative Protocol operations plus `supports_generation`, `supports_transformation`, and `has_creative_library` discovery | | **Creative governance** | No creative evaluation protocol | `get_creative_features` for security scanning, quality, content categorization | | **Disclosure matching** | Position-only disclosure handling | Position + persistence-aware disclosure matching (`continuous`, `initial`, `flexible`) | | **Collections and installments** | No content programming model | `shows` on products with distribution IDs, installment lifecycle, break-based inventory | | **Planning ergonomics** | No `time_budget`, `preferred_delivery_types`, `exclusivity`, or per-package flights | `time_budget` + `incomplete`, `preferred_delivery_types`, `exclusivity`, optional `delivery_measurement`, and package-level `start_time` / `end_time` | | **Channel model** | 9 channels | 20 planning-oriented channels (including `sponsored_intelligence`) with broadcast TV support (creative identifiers, measurement windows, spot formats) | | **Capability discovery** | Agent card extensions | Runtime `get_adcp_capabilities` task | | **Sandbox discovery** | Mixed capability locations | `require_operator_auth` for auth model, `account.sandbox` for sandbox support, sandbox in natural account references | | **Creative assignment** | Simple ID arrays | Weighted assignments with placement targeting | | **Geo targeting** | Implicit US-centric | Explicit named systems (global) | | **Keyword targeting** | No keyword support | `keyword_targets` with match types and bid prices | | **Optimization** | Single optimization goal | Multi-goal `optimization_goals` array with metric and event types | | **Delivery reporting** | Aggregate delivery only | Opt-in dimension breakdowns (geo, device, audience, placement, keyword) | | **Signal pricing** | Simple CPM | Structured pricing models: CPM, percent of media, flat fee, per-unit, and `custom` escape hatch for non-standard constructs | | **Device targeting** | No form-factor targeting | `device_type` (desktop, mobile, tablet, ctv, dooh, unknown) distinct from `device_platform` (OS) | | **Proximity targeting** | No point-based geo targeting | `geo_proximity` with travel time, radius, and GeoJSON methods | | **Refinement** | Free-text with `proposal_id` | Typed change-request array with seller acknowledgment | | **Error handling** | Unstructured errors | `recovery` field (transient, correctable, terminal) + 18 standard error codes | | **AI provenance** | No provenance model | `provenance` object with IPTC source types, C2PA references, regulatory disclosures | | **Creative compliance** | No compliance on briefs | `required_disclosures`, `prohibited_claims`, disclosure positions | | **Agent ergonomics** | Full payloads on every call | `fields` projection, opt-in breakdowns, pre-flight capability filtering | | **Signal lifecycle** | Activate only | `activate` / `deactivate` action on `activate_signal` | *** ## New capabilities ### Trust surface: idempotency, request signing, and signed governance 3.0 makes agent-to-agent transactions safe for real money by turning three operational disciplines into first-class protocol primitives. **`idempotency_key` is required on every mutating request.** Buyers generate a fresh key per logical operation — the schema requires `^[A-Za-z0-9_.:-]{16,255}$`; AdCP Verified additionally requires a cryptographically-random UUID v4. Sellers declare dedup semantics on `get_adcp_capabilities` as either `adcp.idempotency = { supported: true, replay_ttl_seconds: <1h–7d, 24h recommended> }` or `{ supported: false }`. When `supported: true`, replay is safe: the seller returns `replayed: true` on exact replay, `IDEMPOTENCY_CONFLICT` when the same key accompanies a different payload, and `IDEMPOTENCY_EXPIRED` after the TTL. **When `supported: false`, sending an `idempotency_key` is a no-op — naive retries double-process**, and buyers MUST use natural-key checks (e.g., `get_media_buys` plus request context such as `context.internal_campaign_id` or package context such as `context.buyer_ref`) before retrying spend-committing operations. Clients MUST NOT assume a default; a seller missing this block is non-compliant. Since `supported: true` is a trust-bearing claim, conformance runners probe it with a deliberate payload-mutation replay — sellers claiming support MUST pass this probe before the declaration is considered verified. **RFC 9421 HTTP Message Signatures are optional in 3.0 and mandatory under AdCP Verified.** Agents sign mutating requests with Ed25519 over a canonicalized covered-component list (method, target URI, `content-digest`, protocol-level fields). The spec pins sf-binary encoding and URL canonicalization so independent implementations produce bit-identical canonical inputs. A 15-step verification checklist defines the seller's path: `alg` allowlist, `keyid` cap-before-crypto (defense against unbounded verification), JWKS resolution via SSRF-validated fetch, `jti` replay dedup, audience binding. Published test vectors at `static/compliance/source/test-vectors/request-signing/` let implementers validate correctness offline. **Webhooks are signed under the same RFC 9421 profile — baseline-required for sellers.** Webhook authentication unifies on the AdCP 9421 profile as a symmetric variant of request signing: the seller signs outbound webhook requests with a key published in its JWKS at `jwks_uri` (discoverable via `brand.json` `agents[]`). New signers use `adcp_use: "request-signing"` for webhook delivery; deprecated `adcp_use: "webhook-signing"` keys remain accepted during the compatibility window. Operators that want webhook-only key material publish a distinct `request-signing` `kid`. No shared secret crosses the wire. The buyer verifies the signature using the seller's JWKS. A 14-step webhook verifier checklist — documented in the [Security guide](/dist/docs/3.1.20/building/by-layer/L1/security) — covers trust-anchor scoping, downgrade-and-injection resistance, and per-keyid replay dedup (100K per keyid, 10M aggregate); verification failures return typed reason codes defined there. HMAC-SHA256 remains a legacy fallback through 3.x (opt-in via `push_notification_config.authentication.credentials`); the entire `authentication` object is removed in 4.0. **Every webhook payload carries a required `idempotency_key`.** Webhooks use at-least-once delivery, so receivers must dedupe. Every webhook payload — MCP, collection-list changes, property-list changes, content-standards artifacts, rights revocations — carries a sender-generated, cryptographically-random UUID v4 `idempotency_key` stable across retries of the same event. Same name and format as the request-side field. Predictable keys allow pre-seeding a receiver's dedup cache to suppress legitimate events, so sellers MUST generate keys from a cryptographic source. **`governance_context` is a signed JWS.** When a governance agent approves a plan it returns a JWS — not an opaque string — signed with the governance agent's key. The buyer echoes it in the media-buy envelope. Sellers verify the signature using the governance agent's JWKS (resolved via `sync_governance`) and bind decisions to a specific buyer, plan, phase, and time without a round-trip. Stale or forged decisions are rejected at the transport layer. When a governance agent is configured on the plan, sellers MUST invoke `check_governance` before committing budget and MUST reject a spend-commit lacking a valid `governance_context` with `PERMISSION_DENIED`. **The compliance runner validates all of this.** Every agent runs `/compliance/{version}/universal/security.yaml` regardless of which protocols or specialisms it claims — covering unauthenticated rejection, API key enforcement, OAuth discovery per RFC 9728, and audience binding. Agents that declare signing run the `signed_requests` and `signed_webhooks` harnesses: positive flows, tampering (header injection, body mutation, timestamp skew), replay (`jti` reuse), and the `keyid`-cap-before-crypto path. Runner output is a structured, verifiable `runner-output.json` artifact with a hash chain over the test-kit corpus so tampering is detectable. **Cross-instance state persistence is now a spec requirement.** Agent state — tasks, media buys, plans, signed artifacts, idempotency keys — MUST be persistent across horizontally-scaled instances. In-memory-only state is non-compliant for production. See the [Security implementation guide](/dist/docs/3.1.20/building/by-layer/L1/security) for the full threat model, principal roles (brand / operator / agent), and step-by-step verification paths. Threat model, signing profile, verification paths, and the universal security storyboard. *** ### Specialisms and storyboard-driven compliance Storyboards — scripted compliance scenarios that agents must pass — now live in the protocol at `/compliance/{version}/`, alongside schemas and task definitions. Agents declare two things in `get_adcp_capabilities`: * **`supported_protocols`** — broad domain claims (`media_buy`, `creative`, `signals`, `governance`, `brand`, `sponsored_intelligence`). Each commits the agent to the domain's baseline storyboard. * **`specialisms`** — 19 narrow capability claims across the 6 domains. Examples: `sales-guaranteed`, `sales-broadcast-tv`, `creative-generative`, `property-lists`, `signal-marketplace`, `brand-rights`. Each rolls up to one parent protocol. Compliance runs have three tiers: **universal** storyboards every agent runs (capability discovery, schema validation, error compliance), **domain baselines** for each declared protocol, and **specialism storyboards** for each narrow claim. A per-version protocol tarball at `/protocol/{version}.tgz` lets clients bulk-sync schemas, storyboards, and examples in one request. **AdCP Verified is self-attested in 3.0.** Agents run the storyboard suite and publish a signed `runner-output.json` with a hash chain over the test-kit corpus. AAO does not audit the output or gate issuance — the Verified stamp means "this agent published passing runner output," not "an auditor confirmed the claim." Verifying parties (buyers, integrators, regulators) can re-run any claim against the referenced storyboards and compare output hashes. The compliance runner and the storyboards are themselves software shipping at 3.0 — they have bugs, coverage gaps, and in a few places they encode our best guess at spec intent where the working group is still refining the exact rule. When an implementer sees a storyboard failure, three things are possible: their agent has a bug, the storyboard has a bug, or the spec is ambiguous. All three are legitimate issues to file. **Why self-attested and not audited at 3.0:** the reference implementations AAO operates — the training agent and the `@adcp/sdk` / Python / Go SDKs — aren't fully clean against the 3.0 storyboard suite yet. The training agent currently passes 32 of 55 applicable storyboards; SDK coverage is similar. We are working those pass rates to 100% over the 3.0 → 3.1 window on a 4–6 week cadence. When the reference implementations pass cleanly and the remaining spec ambiguities are resolved, **the formal AdCP Verified program launches with 3.1** — agents will be able to submit runner output for independent re-execution by AAO, with a public registry of verified agents. Self-attestation in 3.0 is the bridge, not the end state. Full index of domains and specialisms with status flags and storyboard sources. *** ### Brand Protocol Buy-side identity through `/.well-known/brand.json`. Just as publishers use `adagents.json` to declare properties and authorized agents, brands use `brand.json` to declare their identity, brand hierarchy, and authorized operators. | Sell side | Buy side | | --------------- | -------------------------------- | | Publisher | **House** (corporate entity) | | Property | **Brand** (advertising identity) | | `adagents.json` | **`brand.json`** | Four variants: **House Portfolio** (full brand hierarchy inline), **Brand Agent** (dynamic via MCP), **House Redirect** (sub-brand to house domain), and **Authoritative Location** (hosted URL). Given any domain, the protocol resolves to a canonical brand: ``` shoes.novabrands.example.com -> fetch /.well-known/brand.json -> { "house": "novabrands.example.com" } -> fetch novabrands.example.com/.well-known/brand.json -> search brands[] for property matching "shoes.novabrands.example.com" -> Result: { house: "novabrands.example.com", brand_id: "nova_athletics" } ``` **Use cases:** creative generation (resolve domain to brand identity), brand verification (check `authorized_operators`), reporting roll-up (group campaigns by house). Full specification including brand.json variants, resolution flow, and brand identity. *** ### Brand rights lifecycle Three tasks for licensing and usage rights between brands and content owners: | Task | Purpose | | ---------------- | ----------------------------------------------- | | `get_rights` | Discover available rights for a brand's content | | `acquire_rights` | Request and negotiate rights acquisition | | `update_rights` | Modify active rights (extend, restrict, revoke) | Rights include generation credentials (API keys or tokens for accessing licensed content), creative approval webhooks (HMAC-SHA256 authenticated callbacks when creatives are submitted for review), and revocation notifications. The protocol distinguishes actionable rejections (fix and resubmit) from final rejections (do not retry). Structured `visual_guidelines` on `brand.json` complement rights by giving generative creative systems structured rules for on-brand asset production: photography style, graphic elements, composition, motion, logo placement, colorways, type scale, and restrictions. Task reference for rights discovery, acquisition, and management. *** ### Collections and installments Products can now reference persistent content programs — podcasts, TV series, YouTube channels — via `collections`, which follows the same publisher-scoped selector pattern as `publisher_properties`. Collections are declared in the publisher's `adagents.json` and products reference them by publisher domain and collection ID. Buyers resolve full collection objects from `adagents.json`. Collections include distribution identifiers for cross-seller matching, installment lifecycle states (scheduled, tentative, live, postponed, cancelled, aired, published), break-based ad inventory configuration, talent linking to `brand.json`, and international content rating systems. Shows support relationships (spinoff, companion, sequel, prequel, crossover) and derivative content (clips, highlights, recaps) for comprehensive content modeling. Full specification including collection schemas, installment lifecycle, and break-based inventory. *** ### Registry API The AgenticAdvertising.org registry provides a public REST API for resolving brands and properties, discovering agents, and validating authorization. Most endpoints require no authentication. | Capability | Endpoint | Description | | ------------------- | ----------------------------------------------- | ------------------------------------------------ | | Brand resolution | `/api/brands/resolve` | Resolve domain to canonical brand | | Property resolution | `/api/properties/resolve` | Resolve publisher domain to property info | | Agent discovery | `/api/registry/agents` | List registered agents with capabilities | | Authorization check | `/api/registry/validate/property-authorization` | Real-time authorization validation | | Search | `/api/search` | Search across brands, publishers, and properties | | Community brands | `/api/brands/save` | Contribute brand data (auth required) | The registry complements the protocol: resolve entities via the REST API, then transact via MCP/A2A tasks. Complete endpoint reference with authentication and rate limits. *** ### Proposals and delivery forecasts Publishers can return **proposals** alongside products — structured media plans with percentage-based budget allocations that buyers can execute directly via `create_media_buy`. Proposals encode publisher expertise, replacing ad-hoc product lists with actionable buying strategies. They can be refined through session continuity — subsequent `get_products` calls within the same session carry conversation history. **Delivery forecasts** attach to proposals and allocations. Each forecast contains budget points with metric ranges (low/mid/high), showing how delivery scales with spend. Three forecast methods: **`estimate`** (rough approximation), **`modeled`** (predictive models), **`guaranteed`** (contractually committed). Forecasts can predict delivery metrics (impressions, reach, GRPs) and outcomes (purchases, leads, app installs). TV and radio forecasts use `demographic_system` and `demographic` for GRP-based planning. Complete documentation including budget curves, CTV, retail media, and broadcast audio examples. *** ### Accounts **Migrating from v2?** Accounts are entirely new in v3 — there is no v2 equivalent to migrate from. Start with [Accounts and Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) for the setup guide. Formal billing relationships between buyers and sellers via `sync_accounts`. **Four entities:** | Entity | Question | How identified | | ------------ | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | **Brand** | Whose products are advertised? | House domain + brand\_id via `brand.json` | | **Account** | Who gets billed? | [Account reference](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) — `account_id` from `list_accounts` or natural key | | **Operator** | Who operates on the brand's behalf? | Domain (e.g., `acmeagency.example.com`) | | **Agent** | What software places buys? | Authenticated session | **Two billing models:** `operator` (operator or brand buying direct is invoiced) and `agent` (agent consolidates billing). **Two trust models:** agent-trusted (default, agent declares brands/operators) and operator-scoped (seller requires operator-level credentials). **Workflow:** `get_adcp_capabilities` -> `sync_accounts` -> `get_products` with `account` -> `create_media_buy` with `account`. Accounts Protocol overview: identity verification, billing models, and settlement. *** ### Catalogs First-class catalog lifecycle with `sync_catalogs`. Thirteen catalog types: structural (`offering`, `product`, `inventory`, `store`, `promotion`) and industry-vertical (`hotel`, `flight`, `job`, `vehicle`, `real_estate`, `education`, `destination`, `app`). Vertical types have canonical item schemas drawn from Google Ads, Meta, LinkedIn, and Microsoft feed specs. Formats declare what catalogs they need via `catalog_requirements`. Creatives reference synced catalogs by `catalog_id` instead of embedding items in assets. Catalogs declare `conversion_events` and `content_id_type` for attribution alignment. Complete documentation including catalog types, sync workflow, format requirements, and conversion events. *** ### Capability discovery `get_adcp_capabilities` replaces both `adcp-extension.json` and the MCP agent card with runtime capability discovery. It returns supported protocols, account billing models, portfolio information, targeting systems, and governance features — all schema-validated. **Agent cards and `adcp-extension.json` are no longer needed.** Buyers discover sellers through `adagents.json` and call `get_adcp_capabilities` at runtime. If your v2 integration reads capability data from agent card extensions, switch to `get_adcp_capabilities`. Full task reference with request/response schemas. *** ### Governance Protocol Brand suitability and inventory curation. Governance agents manage **property lists** (curated sets of properties for targeting or exclusion) and **content standards** (brand suitability policies with per-category block/allow rules). Buyers pass property lists to `get_products` for filtered inventory discovery, and use `calibrate_content` for collaborative alignment between brand and governance agent. Governance agents can enforce `provenance_required` on creative policy and support third-party AI content verification via the `verification` array on provenance claims. Campaign governance extends this with plan-level policy and budget enforcement via `sync_plans`, `check_governance`, `report_plan_outcome`, and `get_plan_audit_logs`. Governance agents can operate in `audit`, `advisory`, or `enforce` mode, validate seller-side actions against delegated authority, and resolve standardized policies through the shared [policy registry](/dist/docs/3.1.20/governance/policy-registry). **Art 22 / Annex III as schema invariants.** For regulated verticals (credit, insurance pricing, recruitment, housing), AdCP 3.0 enforces mandatory human oversight at the protocol layer, not in deployer policy PDFs. `policy_categories` intersecting `fair_housing | fair_lending | fair_employment | pharmaceutical_advertising` makes `plan.human_review_required: true` a schema requirement — buyers cannot opt out. Governance agents MUST escalate every action on such plans regardless of budget. See [Annex III & Art 22 obligations](/dist/docs/3.1.20/governance/annex-iii-obligations). Full specification including property lists, content standards, and calibration. *** ### Sponsored Intelligence Protocol Conversational brand experiences in AI assistants. SI defines how AI assistants invoke brand agents for rich engagement (text, voice, UI components) without breaking the conversational flow. Sessions follow a consent-first model: user expresses interest, grants consent, then the brand agent engages conversationally with optional transaction handoff. **Experimental.** Sponsored Intelligence is part of AdCP 3.0 as an experimental surface (feature id `sponsored_intelligence.core`) — session lifecycle, UI components, identity/consent, and capability negotiation may change between 3.x releases with at least 6 weeks' notice. Sellers implementing SI MUST declare `sponsored_intelligence.core` in `experimental_features`. See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full contract and the [3.1.0 roadmap](https://github.com/adcontextprotocol/adcp/issues/2201) for planned changes. Full specification including session lifecycle, implementing agents, and implementing hosts. *** ### Published signal definitions for data providers Data providers can publish signal definitions via `adagents.json` `signals[]`, following the same pattern as publishers declaring properties. | Publishers | Data providers | | ------------------------------------ | ---------------------------------------- | | Declare **properties** | Declare **signals** | | Use `property_ids` / `property_tags` | Use `signal_ids` / `signal_tags` | | Buyers verify via `publisher_domain` | Buyers verify via `data_provider_domain` | Signals now have explicit `value_type` (binary, categorical, numeric) with typed targeting, and structured `signal_id` objects that reference the data provider's published definitions. Complete implementation guide for publishing signal definitions. *** ## rc.1 to rc.2 highlights This page covers the full v2 → v3 shift. If you already adopted `3.0.0-rc.1`, these are the most important rc.2 changes to review before upgrading. ### Creative workflow and library changes `build_creative` now supports inline preview (`include_preview`), multi-format output (`target_format_ids`), quality tiers, catalog-driven `item_limit`, and library retrieval using `creative_id` with optional `concept_id`, `media_buy_id`, `package_id`, and `macro_values`. `preview_creative` also adds quality control. Creative library operations are now explicitly Creative Protocol tasks: `list_creatives` and `sync_creatives` live with the rest of the creative lifecycle, and capability discovery adds `supports_generation`, `supports_transformation`, and `has_creative_library` so buyers can route requests intentionally. ### Planning, accounts, and sandbox refinements `account_resolution` is removed; buyers now use `require_operator_auth` to determine the auth model and initial account-reference shape. Sandbox support moves to `account.sandbox`, and sandbox can participate in the natural account key for buyer-declared account flows. Product discovery adds `preferred_delivery_types`, `exclusivity`, and `time_budget`, with `incomplete` in responses when the seller cannot finish within the requested budget. Packages and product allocations can now carry per-package `start_time` / `end_time`, and `delivery_measurement` becomes optional on products. ### Compliance and governance refinements Creative compliance now includes disclosure persistence semantics in addition to position and duration, allowing formats to declare which persistence modes they can enforce. Campaign governance also lands in rc.2 with `sync_plans`, `check_governance`, `report_plan_outcome`, and `get_plan_audit_logs`, plus `governance_context` for canonical plan extraction. `sync_governance` provides a dedicated task for syncing governance agent endpoints to accounts, replacing the previous approach of embedding `governance_agents` on `sync_accounts` and `list_accounts` requests. For the exhaustive rc.2 change list, see the [Release Notes](/dist/docs/3.1.20/reference/release-notes#version-300-rc2) and [CHANGELOG.md](https://github.com/adcontextprotocol/adcp/blob/main/CHANGELOG.md#300-rc2). *** ## rc.3 to 3.0 highlights **Upgrading from rc.3?** See [rc.3 → 3.0 prerelease upgrade notes](/dist/docs/3.1.20/reference/migration/prerelease-upgrades) for the breaking changes table, before/after examples, and migration steps. ### Specialisms and compliance catalog Storyboards move into the protocol at `/compliance/{version}/` with a new `specialisms` field on `get_adcp_capabilities`. 21 specialisms roll up to 6 domains; four 3.1 archetypes ship with `status: preview`. See the [Compliance Catalog](/dist/docs/3.1.20/building/verification/compliance-catalog). Renames: `broadcast-platform` → `sales-broadcast-tv`, `social-platform` → `sales-social`. Merge: `property-governance` + `collection-governance` → `inventory-lists`. Promotion: `sponsored_intelligence` specialism → full protocol. ### Capabilities model simplification The capabilities model is streamlined for 3.0: redundant boolean gates are removed. If a `content_standards` object exists in `get_adcp_capabilities`, the agent supports content standards — no separate boolean needed. `reporting_capabilities` is now required on every product. Geo capability fields keep their typed shapes: `geo_countries` and `geo_regions` as booleans, `geo_metros` and `geo_postal_areas` as structured objects. See the [prerelease upgrade notes](/dist/docs/3.1.20/reference/migration/prerelease-upgrades#capabilities-model-simplification) for the full list of removed fields and migration steps. ### Governance across purchase types Campaign governance extends beyond media buys to cover brand rights licensing, signal activation, and creative services — any purchase where budget or policy rules apply. `governance_context` replaces `media_buy_id` as the identifier that ties governance actions together across a campaign's lifecycle. A `purchase_type` field on `check_governance` and `report_plan_outcome` distinguishes the governed activity. ### GOVERNANCE\_DENIED error and schema consistency `GOVERNANCE_DENIED` is added to the standard error codes with correctable recovery, enabling governance-rejected operations to return structured errors. All request/response schemas across governance, collection, property, sponsored-intelligence, and content-standards protocols gain optional `context` and `ext` fields for application metadata and protocol extensions. `signal_id` is now required on signal items in `get_signals` responses. The `comply_test_controller` schema is flattened from a oneOf union to a flat object with `scenario` discriminant. ### Per-request version declaration All request schemas now include `adcp_version` (release-precision, e.g., `"3.1"`), allowing v3 buyers to declare which release their payloads conform to. Sellers validate against their advertised `adcp.supported_versions` array on `get_adcp_capabilities` and echo `adcp_version` at the envelope root on every response. Unsupported releases return `VERSION_UNSUPPORTED`. When omitted, sellers default to their highest supported version. 3.1 also retains `adcp_major_version` (integer) as a backwards-compatible legacy field, but release-precision `adcp_version` is the primary wire field going forward. See [Version negotiation](/dist/docs/3.1.20/reference/versioning#version-negotiation) for the negotiation contract, migration timeline, and SDK pinning examples. Note: both fields are v3-only — v2 clients cannot set either (the fields do not exist in v2 schemas). Multi-version sellers detect v2 payloads by structural cues (missing `buying_mode`, `fixed_rate` vs `fixed_price`, `geo_postal_codes` vs `geo_postal_areas`, etc.) rather than by these fields. ### Collection lists Collection lists extend brand safety from properties to content programs. Like property lists, collection lists are curated sets — but they target shows, series, and other content programs across platforms using distribution identifiers (IMDb, Gracenote, EIDR) for cross-publisher matching. New targeting overlay fields `collection_list` and `collection_list_exclude` enable both inclusion and exclusion targeting. A new genre taxonomy enum normalizes genre classification across buyers and sellers. Task reference for collection list creation and management. ### Broadcast TV support Linear TV sellers can now fully participate in AdCP. This release adds the protocol primitives that distinguish broadcast from digital: * **Broadcast creative identifiers** — `industry_identifiers` on creative assets and manifests, with `creative-identifier-type` enum (`ad_id`, `isci`, `clearcast_clock`, `idcrea`). Broadcast creatives carry the traffic or clearance identifier used by the relevant workflow, which ties spots to rotation instructions and traffic systems. * **Broadcast spot formats** — Reference formats for :15, :30, and :60 spots. Video file only — no VAST, no impression trackers, no clickthrough URLs. The absence of tracker asset slots in a format signals that third-party pixel tracking is not supported. * **Agency Estimate Number** — `agency_estimate_number` on media buys and packages. The financial reference that links broadcast orders to agency media plans and billing. * **Measurement windows** — `measurement_windows` on `reporting_capabilities` for Live, C3, and C7 maturation. `measurement_window` on `billing_measurement` declares which window the guarantee is reconciled against. * **Delivery data completeness** — `is_final` and `measurement_window` on per-package delivery data. Buyers know whether numbers are provisional or closed, and which measurement window they represent. Applies to any channel with maturing data (broadcast, podcast, long-tail content). Channel guide covering spot formats, creative identifiers, measurement windows, and how broadcast differs from CTV. ### Structured measurement terms Guaranteed buys gain a formal negotiation surface: `measurement_terms` defines billing measurement vendor, IVT threshold, and viewability floor. Sellers declare defaults on products, buyers propose overrides at `create_media_buy`, sellers accept/reject/adjust. A `cancellation_policy` schema declares notice periods and penalties for guaranteed products. ### Unified vendor pricing Pricing models extend from signals to creative, governance, and property list agents. Creative agents return `pricing_options[]` on `list_creatives` and `build_creative` responses. Property lists carry `pricing_options[]`. All vendor pricing uses a shared `vendor-pricing-option.json` schema (cpm, percent\_of\_media, flat\_fee). ### Offline reporting delivery Sellers can declare offline reporting delivery methods (SFTP, S3, GCS, Azure Blob) in `get_adcp_capabilities` via `reporting_delivery_methods`. Accounts specify a `reporting_bucket` for file delivery. Products declare `supports_offline_delivery` in `reporting_capabilities`. File formats include CSV, JSON, Parquet, Avro, and ORC. ### Trusted Match Protocol extensions TMP gains a provider registration schema (`provider-registration.json`) formalizing provider endpoints, capabilities, lifecycle status (active/inactive/draining), and per-provider timeout budgets. A `GET /health` endpoint enables router-side health monitoring. TMPX adds exposure tracking with country-partitioned identity resolution and macro connectivity. Identity Match requests now accept an `identities` array (1-3 tokens per request) instead of a single `user_token` + `uid_type` pair. Publishers send every identity token they have; buyers resolve on whichever graph matches. The router filters `identities` per provider (minimum-necessary-data) and re-signs before forwarding — the forwarded set must be a subset of what the publisher sent. RFC 8785 JCS canonicalization is used for both signature and cache-key derivation, and `consent_hash` partitions the cache by consent state. `rampid_derived` is added to the `uid-type` enum. TMP remains pre-release in 3.0. The stable surface is targeted for 3.1.0. ### Brand schema extensions `brand.json` gains a generic `agents` array for declaring brand-associated agents, visual tokens (`border_radius`, `elevation`, `spacing`, extended color roles), and structured font definitions with `weight`, `style`, `stretch`, `optical_size`, and `usage` fields. ### Required tasks reference A new [Required tasks by protocol](/dist/docs/3.1.20/protocol/required-tasks) reference page consolidates required, conditional, and optional tasks across all AdCP protocols by agent role — a single page to verify your implementation covers the minimum surface. ### Experimental surfaces AdCP 3.0 ships a core of stable surfaces under the [3.x stability guarantees](/dist/docs/3.1.20/reference/versioning#3x-stability-guarantees), plus four surfaces that are part of the core protocol but not yet frozen. These surfaces are being co-developed with design partners — **OpenAds, Scope3, Yahoo, ONX, and Triton Digital** — running them in production engagements that shape the graduation path. Experimental surfaces carry `x-status: experimental` in their schemas, and sellers implementing them declare the feature id in `experimental_features` on `get_adcp_capabilities`. They may change between 3.x releases with at least 6 weeks' notice. | Surface | Feature id | Why experimental | | --------------------------------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Brand rights lifecycle](/dist/docs/3.1.20/brand-protocol/tasks/get_rights) | `brand.rights_lifecycle` | Legal-construct surface added late in the 3.0 cycle. First enterprise deployments will expose edge cases in partial rights, sublicensing, revocation, and dispute resolution. | | [Campaign governance](/dist/docs/3.1.20/governance/campaign/specification) | `governance.campaign` | Multi-party governance semantics (approval conflicts, audit provenance, tie-breaking under Embedded Human Judgment) are not yet settled. | | [Trusted Match Protocol](/dist/docs/3.1.20/trusted-match/) | `trusted_match.core` | Privacy architecture will evolve with regulator engagement; TMPX exposure tokens, country-partitioned identity, and Offer macros are expected to change. | | [Sponsored Intelligence](/dist/docs/3.1.20/sponsored-intelligence/overview) | `sponsored_intelligence.core` | Conversational brand experiences are a new advertising model. Session lifecycle, UI components, identity/consent object shape, and capability negotiation are expected to evolve as first-party AI hosts and brand agents integrate. | The experimental label is deliberately scoped — everything else in 3.0 follows the normal 6-month deprecation notice. See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full contract, graduation criteria, and client guidance. ### Signal pricing: custom escape hatch Vendor pricing gains a `custom` model alongside `cpm`, `percent_of_media`, `flat_fee`, and `per_unit`. Requires a human-readable `description` and a structured `metadata` object. Buyers SHOULD route custom pricing through operator review before commitment — automatic selection is not recommended. Motivation: performance kickers, tiered volume, hybrid (flat + CPM), and outcome-shared pricing are already appearing in the field. Shipping the escape hatch now avoids retrofit pain when real deployments hit a construct the enumerated models cannot express. Structured metadata keeps the model machine-inspectable without a schema change per new pattern. *** ## Breaking changes ### Media channel taxonomy v2's 9 channels are replaced by 20 planning-oriented channels. Five channels carried over unchanged (`display`, `social`, `ctv`, `podcast`, `dooh`). The remaining four are split, removed, or renamed. All code that reads or writes channel values must be updated. | v2 channel | v3 channel(s) | Notes | | ---------- | ---------------------------- | ---------------------------------------------------------------- | | `display` | `display` | Unchanged | | `video` | `olv`, `linear_tv`, `cinema` | Split by distribution context (`ctv` was already separate in v2) | | `audio` | `radio`, `streaming_audio` | Split by distribution (`podcast` was already separate in v2) | | `native` | Removed | Use format-level properties instead | | `social` | `social` | Unchanged | | `ctv` | `ctv` | Unchanged | | `podcast` | `podcast` | Unchanged | | `dooh` | `dooh` | Unchanged | | `retail` | `retail_media` | Renamed for clarity | New channels in v3 (no v2 equivalent): `search`, `linear_tv`, `radio`, `streaming_audio`, `ooh`, `print`, `cinema`, `email`, `gaming`, `retail_media`, `influencer`, `affiliate`, `product_placement`, `sponsored_intelligence`. The `gaming` channel covers intrinsic in-game ads, rewarded video, and playable ads. Rewarded video in gaming apps could also be classified as `olv` — use `gaming` when the inventory comes from a gaming budget. Complete mapping guide with examples for each v2 channel, multi-channel products, and capability discovery. *** ### Pricing option field renames v3 separates **hard constraints** (publisher-enforced prices) from **soft hints** (historical percentiles). `fixed_rate` becomes `fixed_price`, and `price_guidance.floor` moves to top-level `floor_price`. | v2 field | v3 field | Notes | | ---------------------- | ------------- | ---------------------------------------------- | | `fixed_rate` | `fixed_price` | Renamed for clarity (it's a price, not a rate) | | `price_guidance.floor` | `floor_price` | Moved to top level as hard constraint | These fields map to standard deal types: `fixed_price` corresponds to Programmatic Guaranteed (PG) and Preferred Deals, while `floor_price` corresponds to Private Marketplace (PMP) auctions. Open auction inventory omits both fields. Fixed-price vs auction examples, price guidance schema, flat-rate pricing, minimum spend, and transition period handling. *** ### Creative assignments with weighting `creative_ids` string arrays are replaced by `creative_assignments` objects that support delivery weighting and placement targeting. | v2 field | v3 field | | ----------------------------- | ----------------------------------------------------------------------------------- | | `creative_ids` (string array) | `creative_assignments` (object array with `creative_id`, `weight`, `placement_ids`) | Weighted assignments, placement targeting, asset discovery with the unified `assets` array, repeatable groups, and format cards. *** ### Geo targeting with named systems Metro and postal targeting values now carry explicit system information, supporting global markets. Metro values are grouped by system using `{ "system": "...", "values": [...] }` objects; native postal values use `{ "country": "...", "system": "...", "values": [...] }`. | v2 field | v3 field | | --------------------------------- | -------------------------------------------------- | | `geo_metros` (string array) | `geo_metros` (system/values objects) | | `geo_postal_codes` (string array) | `geo_postal_areas` (country/system/values objects) | v3 also adds `geo_metros_exclude` and `geo_postal_areas_exclude` for negative targeting (e.g., target the US except the New York DMA). Metro and postal system reference tables, exclusion targeting, capability discovery, and a full targeting example. *** ### Other targeting changes v3 adds several targeting fields beyond geo: | Field | Description | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `daypart_targets` | Time-of-day and day-of-week targeting windows | | `age_restriction` | Age-gating for restricted content | | `device_platform` | Operating system targeting (iOS, Android, Windows, tvOS, etc.) | | `device_type` | Device form factor targeting (desktop, mobile, tablet, ctv, dooh, unknown) | | `language` | Content language targeting | | `keyword_targets` / `negative_keywords` | Keyword targeting for search and retail media with match types (broad, phrase, exact) and per-keyword bid prices | | `device_type_exclude` | Negative device form factor targeting | | `geo_proximity` | Point-based proximity targeting via travel time isochrones, radius, or GeoJSON geometry | These fields are optional additions — they don't replace any v2 fields. *** ### Unified asset discovery Formats now use an `assets` array with `required` boolean instead of `assets_required`. See the [creatives deep dive](/dist/docs/3.1.20/reference/migration/creatives#asset-discovery). *** ### Catalogs replace promoted\_offerings The `promoted_offerings` creative asset type and `promoted_offering` string field are removed. Catalogs are now first-class protocol objects with their own sync lifecycle (`sync_catalogs`), format-level requirements (`catalog_requirements`), and conversion event alignment. | v2 field | v3 replacement | | ------------------------------------------------- | ------------------------------------- | | `promoted_offerings` (creative asset) | `catalogs` field on creative manifest | | `promoted_offering` (string on media-buy) | Removed — use `brand` + `brief` | | `promoted_offering` (string on creative-manifest) | Removed — use `catalogs` field | Before/after examples, sync\_catalogs workflow, catalog\_requirements discovery, and migration checklist. *** ### Brand identity unification Inline `brand_manifest` objects are replaced by brand references (`BrandRef`). Task schemas reference brands by `{ domain, brand_id }` instead of passing manifests inline. Brand data is resolved from `brand.json` or the registry at execution time. | v2/beta field | v3 rc.1 field | | -------------------------------- | -------------------------------- | | `brand_manifest` (inline object) | `brand` (`{ domain, brand_id }`) | Affects: `get_products`, `create_media_buy`, `build_creative`, and property list schemas. BrandRef schema, resolution flow, before/after examples, and migration steps. *** ### Product delivery forecasts `estimated_exposures` is replaced by a structured `forecast` field using the `DeliveryForecast` type. | v2/beta field | v3 rc.1 field | | ------------------------------- | --------------------------------------------------------------------------- | | `estimated_exposures` (integer) | `forecast` (DeliveryForecast with time periods, metric ranges, methodology) | *** ### Proposal refinement via buying mode `proposal_id` is removed from the `get_products` request. Refinement now uses `buying_mode: "refine"` with a typed `refine` array of change-requests (see [typed refinement](#typed-refinement-with-seller-acknowledgment)). Session continuity (`context_id` in MCP, `contextId` in A2A) carries conversation history across calls. Proposal execution via `create_media_buy` with `proposal_id` is unchanged. | v2/beta field | v3 rc.1 field | | --------------------------------------- | ------------------------------------- | | `proposal_id` on `get_products` request | Removed — use `buying_mode: "refine"` | *** ### Optimization goals redesign `optimization_goal` (singular object) is replaced by `optimization_goals` (array). Each goal is a discriminated union on `kind`: | v2/beta field | v3 rc.1 field | | ----------------------------------- | ---------------------------------------- | | `optimization_goal` (single object) | `optimization_goals` (array) | | Implicit single goal | `priority` field for multi-goal ordering | Two goal kinds: * **`metric`** — Seller-native delivery metrics (clicks, views, reach, engagements, etc.) with `cost_per` or `threshold_rate` targets * **`event`** — Conversion tracking with `event_sources` array and optional `value_field`/`value_factor` Goal kinds, reach optimization, multi-goal priority, product capabilities, and migration steps. *** ### Signals pricing restructure The legacy `pricing: { cpm }` object on signals is replaced by a structured `pricing_options` array with three pricing models. | v2/beta field | v3 rc.1 field | | ---------------------- | ---------------------------------------------------- | | `pricing.cpm` (number) | `pricing_options[]` (array of pricing model objects) | Three models: `cpm`, `percent_of_media` (with optional `max_cpm`), `flat_fee`. Pricing models, deliver\_to flattening, usage reporting, and migration steps. *** ### Signals deliver\_to flattening The nested `deliver_to` object in `get_signals` request is replaced with two top-level fields. | v2/beta field | v3 rc.1 field | | ------------------------- | -------------------------- | | `deliver_to.destinations` | `destinations` (top-level) | | `deliver_to.countries` | `countries` (top-level) | *** ### AudienceMember external\_id required `external_id` is promoted from a uid-type enum value to a required top-level field on AudienceMember. Every member must have a buyer-assigned stable identifier plus at least one matchable identifier. Before/after examples, uid-type changes, sync\_audiences usage, and migration steps. *** ### Typed refinement with seller acknowledgment `refine` is redesigned from a nested object with `overall`/`products`/`proposals` to a flat typed array. Each entry is discriminated by `scope`: | v2/beta field | v3 rc.1 field | | -------------------------------- | -------------------------------------------------- | | `refine.overall` (string) | `{ "scope": "request", "ask": "..." }` array entry | | `refine.products[].product_id` | `{ "scope": "product", "product_id": "..." }` | | `refine.proposals[].proposal_id` | `{ "scope": "proposal", "proposal_id": "..." }` | | `refine.products[].notes` | `ask` field | Product and proposal entries use the prefixed id field (`product_id`, `proposal_id`) matching AdCP's id naming convention throughout the protocol. `action` is optional on product and proposal entries and defaults to `"include"` — orchestrators only need to set it explicitly for `"omit"`, `"more_like_this"` (product), or `"finalize"` (proposal). Sellers respond with `refinement_applied` — a positionally-matched array where each entry reports `status` (`applied`, `partial`, `unable`) and optional `notes`. Entries echo the matching id field (`product_id` / `proposal_id`) for cross-validation. ```json theme={null} { "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "more video, less display" }, { "scope": "product", "product_id": "prod_video_premium", "ask": "add 16:9 format" }, { "scope": "product", "product_id": "prod_display_ros", "action": "omit" }, { "scope": "proposal", "proposal_id": "prop_balanced_v1", "action": "finalize" } ] } ``` **Seller migration note.** The shape change is breaking for sellers as well as buyers. Sellers that return `refinement_applied` MUST now echo `scope` and the matching id field (`product_id` for product scope, `proposal_id` for proposal scope) — the pre-rename `{status, notes}`-only entries are rejected by the tightened response schema. Missing `action` on incoming refine entries MUST be treated as `action: "include"`, not as a parse error. Re-issue your seller tests against the 3.0 request schema to catch any orchestrator code still emitting the generic `id` field — the validator now rejects it with `must NOT have additional properties`. Change-request types, seller acknowledgment, and before/after examples. *** ### Creative assignments restructured `SyncCreativesRequest.assignments` changed from a `{ creative_id: package_id[] }` map to a typed array with explicit fields. | v2/beta field | v3 rc.1 field | | -------------------------- | ----------------------------------------------------------------------------- | | `assignments` (object map) | `assignments` (array of `{ creative_id, package_id, weight, placement_ids }`) | *** ### Signals account and field consistency Two consistency changes to signals schemas: | v2/beta field | v3 rc.1 field | | ------------------------------------------------------------ | ----------------------------------------------------------- | | `account_id` (string) on `get_signals` and `activate_signal` | `account` (AccountReference object) | | `deployments` on `activate_signal` | `destinations` (renamed for consistency with `get_signals`) | *** ### Package catalogs as array | v2/beta field | v3 rc.1 field | | -------------------------------------------- | ---------------------------------- | | `catalog` (single Catalog object) on Package | `catalogs` (array of Catalog refs) | *** ### Brand tone structured format Brand `tone` is now an object type only — string format is removed. Structured tone includes `voice`, `attributes`, `dos`, and `donts` fields. Existing string values should migrate to `{ "voice": "" }`. | v2/beta field | v3 rc.2 field | | ------------------------- | ---------------------------------------------------- | | `tone` (string or object) | `tone` (object: `{ voice, attributes, dos, donts }`) | *** ### Account resolution removed `account_resolution` capability field is removed. `require_operator_auth` now determines the auth model and account reference style: `true` means account-id namespaces (discover via `list_accounts` when exposed, pass `account_id`), `false` means buyer-declared accounts (declare via `sync_accounts`, pass natural key). | v2/beta/rc.1 field | v3 rc.2 field | | ------------------------------- | ------------------------------------- | | `account_resolution` capability | Removed — use `require_operator_auth` | *** ### Privacy and consent AdCP does not define its own consent framework. Privacy signals (TCF 2.0, GPP, US Privacy String) should be passed via the brief's `ext` field or through transport-level headers. Sellers that require consent signals should declare this in `get_adcp_capabilities` using the extension mechanism. *** ## Removed in v3 | Removed | Replacement | | ----------------------------------------------------------- | --------------------------------------------------------------------- | | `adcp-extension.json` agent card | `get_adcp_capabilities` task | | `list_authorized_properties` task | `get_adcp_capabilities` portfolio section | | `assets_required` in formats | `assets` array with `required` boolean | | `preview_image` in formats | `format_card` object | | `creative_ids` in packages | `creative_assignments` array | | `geo_postal_codes` | `geo_postal_areas` | | `fixed_rate` in pricing | `fixed_price` | | `price_guidance.floor` | `floor_price` (top-level) | | `promoted_offerings` asset type | `catalogs` field on creative manifest | | `promoted_offering` on media-buy | Removed — use `brand` + `brief` | | `promoted_offering` on creative-manifest | `catalogs` field | | `brand_manifest` (inline object) | `brand` ref (`{ domain, brand_id }`) | | `estimated_exposures` on Product | `forecast` (DeliveryForecast) | | `proposal_id` on `get_products` request | Session continuity (`context_id` / `contextId`) | | `refine` object with `overall`/`products`/`proposals` | `refine` array of typed change-requests | | `creative_brief` on `build_creative` request | `brief` asset type in manifest `assets` map | | `supports_brief` capability | `supports_compliance` | | `creative-brief-ref.json` schema | Deleted — briefs are now asset types | | `deployments` on `activate_signal` | `destinations` | | `account_id` (string) on signals tasks | `account` (AccountReference) | | `report_usage.kind` and `report_usage.operator_id` | Removed | | `catalog` (singular) on Package | `catalogs` (array) | | `account_resolution` capability | `require_operator_auth` determines account model | | `X-Dry-Run` HTTP header | `sandbox: true` on account reference | | `X-Test-Session-ID` HTTP header | Removed — sandbox accounts provide test isolation | | `X-Mock-Time` HTTP header | Removed | | `delete_content_standards` task | Archive via `update_content_standards` instead | | `get_property_features` task | Property list filters + `get_adcp_capabilities` for feature discovery | | `tone` as string on brand.json | Object only: `{ voice, attributes, dos, donts }` | | `FormatCategory` enum / `type` on formats | Filter by `asset_types` or `format_ids` instead | | `broadcast-platform` specialism | Renamed to `sales-broadcast-tv` | | `social-platform` specialism | Renamed to `sales-social` | | `property-governance` + `collection-governance` specialisms | Merged into `inventory-lists` | | `sponsored_intelligence` specialism | Promoted to full protocol in `supported_protocols` | *** ## Migration checklists These breaking changes affect anyone reading or writing AdCP data: * [ ] Update channel enum values to [new taxonomy](/dist/docs/3.1.20/reference/migration/channels) * [ ] Rename `fixed_rate` -> `fixed_price` in [pricing options](/dist/docs/3.1.20/reference/migration/pricing) * [ ] Move `price_guidance.floor` -> `floor_price` ([pricing details](/dist/docs/3.1.20/reference/migration/pricing)) * [ ] Replace `creative_ids` with [`creative_assignments`](/dist/docs/3.1.20/reference/migration/creatives) * [ ] Add system specification to [metro/postal targeting](/dist/docs/3.1.20/reference/migration/geo-targeting) * [ ] Rename `geo_postal_codes` -> `geo_postal_areas` * [ ] Handle new `geo_metros_exclude` and `geo_postal_areas_exclude` fields * [ ] Update format parsing to use [`assets` array](/dist/docs/3.1.20/reference/migration/creatives#asset-discovery) * [ ] Replace `preview_image` reads with [`format_card`](/dist/docs/3.1.20/reference/migration/creatives#format-cards-replacing-preview_image) rendering * [ ] Replace `list_authorized_properties` calls with `get_adcp_capabilities` portfolio * [ ] Remove `promoted_offerings` from creative manifest assets and replace with [`catalogs` field](/dist/docs/3.1.20/reference/migration/catalogs) * [ ] Remove `promoted_offering` string from media buy and creative manifest objects * [ ] Update `optimization_goal` to [`optimization_goals`](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) (array of discriminated union) * [ ] Handle `external_id` as required field on AudienceMember * [ ] Replace `brand_manifest` with `brand` ref (`{ domain, brand_id }`) in all task calls * [ ] Replace `estimated_exposures` reads with `forecast` (DeliveryForecast) on products * [ ] Remove `proposal_id` from `get_products` requests — use session continuity for refinement * [ ] Update `refine` from object to typed array with `scope` discriminator * [ ] Handle `recovery` field on errors for retry/correction logic * [ ] Update `catalog` to `catalogs` (array) on packages * [ ] Update signals `account_id` to `account` (AccountReference) * [ ] Rename signals `deployments` to `destinations` * [ ] Pass `buying_mode` (now required) on `get_products` * [ ] Move `creative_brief` to `brief` asset type in manifest `assets` map * [ ] Handle `report_usage` without `kind` and `operator_id` fields * [ ] Update `SyncCreativesRequest.assignments` from object map to typed array * [ ] Migrate brand `tone` from string to object format (`{ voice, attributes, dos, donts }`) * [ ] Remove `account_resolution` reads — use `require_operator_auth` instead * [ ] Read sandbox support from `account.sandbox` instead of `media_buy.features.sandbox` * [ ] Remove `X-Dry-Run`, `X-Test-Session-ID`, and `X-Mock-Time` header handling — use `sandbox: true` on account references instead * [ ] Add `type: "dooh"` inside `flat_rate.parameters` when DOOH parameters are provided * [ ] Treat `list_creatives` and `sync_creatives` as Creative Protocol operations * [ ] Remove `delete_content_standards` calls — archive via `update_content_standards` * [ ] Remove `get_property_features` calls — use property list filters * [ ] Remove `format_types` / `type` filters — use `asset_types` or `format_ids` instead * [ ] Validate all requests/responses against v3 schemas Update all data structures to v3 format (channels, pricing, geo targeting), then implement new capabilities: * [ ] Implement `get_adcp_capabilities` task (including `account` capabilities) * [ ] Remove `adcp-extension.json` from agent card * [ ] Implement `sync_accounts` for account provisioning * [ ] Return proposals with delivery forecasts from `get_products` when applicable * [ ] Support property list filtering in `get_products` if integrating with governance agents * [ ] Handle catalogs synced via [`sync_catalogs`](/dist/docs/3.1.20/reference/migration/catalogs) with approval workflow * [ ] Declare `metric_optimization` capabilities on products * [ ] Declare `reporting` capabilities in `get_adcp_capabilities` for dimension breakdowns * [ ] Support `reporting_dimensions` parameter on `get_media_buy_delivery` * [ ] Return `refinement_applied` array when processing `refine` requests * [ ] Implement `rejected` status and `rejection_reason` on media buys * [ ] Support `fields` projection parameter on `get_products` * [ ] Declare `supported_pricing_models` in `get_adcp_capabilities` * [ ] Support `time_budget` on `get_products` and return `incomplete` when work cannot finish in budget * [ ] Declare sandbox support in `account.sandbox`, not `media_buy.features.sandbox` * [ ] Stop checking for `X-Dry-Run` header — honor `sandbox: true` on account references instead * [ ] Support `preferred_delivery_types`, `exclusivity`, optional `delivery_measurement`, and package-level `start_time` / `end_time` Update all requests and response handling to v3 format, then integrate new capabilities: * [ ] Resolve brands via [`brand.json`](/dist/docs/3.1.20/brand-protocol) before placing buys * [ ] Call `sync_accounts` to establish billing relationships * [ ] Update to call `get_adcp_capabilities` for runtime discovery * [ ] Evaluate proposals and delivery forecasts when returned by sellers * [ ] Use the [Registry API](/dist/docs/3.1.20/registry) for brand/property resolution and agent discovery * [ ] Pass property lists to filter inventory when working with governance agents * [ ] Invoke SI sessions when connecting users with brand agents * [ ] Sync catalogs via [`sync_catalogs`](/dist/docs/3.1.20/reference/migration/catalogs) before submitting creatives * [ ] Add `conversion_events` to catalogs for attribution tracking * [ ] Update `optimization_goal` to `optimization_goals` array in `create_media_buy` * [ ] Pass `pricing_option_id` when activating signals with pricing options * [ ] Use `reporting_dimensions` for dimension breakdowns in delivery reporting * [ ] Handle `refinement_applied` response for typed refinement feedback * [ ] Use `recovery` field on errors for automated retry/correction * [ ] Use `fields` projection on `get_products` for efficient discovery * [ ] Handle `rejected` status on media buys * [ ] Use `action: "deactivate"` on `activate_signal` for campaign cleanup * [ ] Integrate `get_rights` / `acquire_rights` for licensed content campaigns * [ ] Handle `visual_guidelines` from `brand.json` for creative generation * [ ] Read `require_operator_auth` and `account.sandbox` when choosing account and sandbox flows * [ ] Handle `time_budget` / `incomplete` for bounded-latency product discovery * [ ] Use creative capability flags (`supports_generation`, `supports_transformation`, `has_creative_library`) to route build vs library workflows * [ ] Submit plans to governance agents via `check_governance` when campaign governance is in use Update schema references from v2 to v3. Signals Protocol doesn't use media channels in its core model. * [ ] Update schema references from v2 to v3 * [ ] Ensure `get_adcp_capabilities` returns `major_versions: [3]` * [ ] Return structured `signal_id` objects in `get_signals` responses * [ ] Include `value_type` field in signal responses * [ ] Support `signal_ids` parameter in `get_signals` requests for ID-based lookup * [ ] Update from legacy `pricing` to structured `pricing_options` array * [ ] Handle top-level `destinations`/`countries` instead of nested `deliver_to` * [ ] Add `idempotency_key` support to `report_usage` * [ ] Support `action: "deactivate"` on `activate_signal` * [ ] Include `categories` and `range` metadata in signal entries * [ ] Update `account_id` to `account` (AccountReference) * [ ] Rename `deployments` to `destinations` Publish signal definitions via `adagents.json`. See [Data Provider Guide](/dist/docs/3.1.20/signals/data-providers). * [ ] Create published signal definitions in `/.well-known/adagents.json` * [ ] Define signals with `id`, `name`, `value_type`, and optional metadata * [ ] Add `signal_tags` for grouping and efficient authorization * [ ] Authorize signals agents using `signal_ids` or `signal_tags` authorization types * [ ] Validate the file using AdAgents.json Builder Support new asset discovery and integrate brand identity. The `FormatCategory` enum and format `type` field are removed in v3 — filter formats by `asset_types` or `format_ids` instead. * [ ] Support `assets` array with `required` boolean (replaces `assets_required`) * [ ] Replace `preview_image` with `format_card` rendering * [ ] Resolve brand identity via `brand.json` for on-brand creative generation * [ ] Support `catalog` field on creative manifests (replaces [`promoted_offerings`](/dist/docs/3.1.20/reference/migration/catalogs) asset) * [ ] Declare `catalog_requirements` on formats that render catalog items * [ ] Update schema references from v2 to v3 * [ ] Support `provenance` object on creative manifests and assets * [ ] Support `brief` and `catalog` as asset types in the `assets` map * [ ] Handle `compliance.required_disclosures` on creative briefs * [ ] Check format `supported_disclosure_positions` compatibility * [ ] Declare `supports_compliance` in capabilities (replaces `supports_brief`) * [ ] Handle `visual_guidelines` from `brand.json` for on-brand asset generation * [ ] Support `include_preview`, `target_format_ids`, `quality`, and `item_limit` on `build_creative` * [ ] Support library retrieval via `creative_id` and declare `supports_generation`, `supports_transformation`, `has_creative_library` * [ ] Implement `list_creatives` and `sync_creatives` as Creative Protocol operations * [ ] Support `quality` parameter on `preview_creative` * [ ] Declare disclosure persistence support via format `disclosure_capabilities` Establish machine-readable brand and operator identity. See [brand.json specification](/dist/docs/3.1.20/brand-protocol/brand-json). * [ ] Host `/.well-known/brand.json` on your domain * [ ] Declare brand portfolio, properties, and authorized operators * [ ] Optionally provide brand data (logos, colors, fonts, tone) inline in `brand.json` or via brand agent * [ ] Add `visual_guidelines` to `brand.json` for generative creative systems * [ ] Implement `get_rights` / `acquire_rights` / `update_rights` if licensing content * [ ] Register in the [community brand registry](/dist/docs/3.1.20/registry) if not hosting `brand.json` Implement brand suitability capabilities. See [Governance Protocol](/dist/docs/3.1.20/governance). * [ ] Implement property list tasks (`create_property_list`, `get_property_list`, etc.) * [ ] Implement content standards tasks (`create_content_standards`, `calibrate_content`, etc.) * [ ] Implement `get_adcp_capabilities` with `governance` in `supported_protocols` * [ ] Implement `provenance_required` enforcement on creative policy * [ ] Support `verification` results from AI detection services * [ ] Implement `get_creative_features` for creative evaluation * [ ] Declare `creative_features` in `get_adcp_capabilities` * [ ] Implement campaign governance tasks (`sync_plans`, `check_governance`, `report_plan_outcome`, `get_plan_audit_logs`) when offering plan-level governance Implement conversational brand experiences. SI ships in 3.0 as an [experimental surface](/dist/docs/3.1.20/reference/experimental-status) (`sponsored_intelligence.core`) — session lifecycle, UI components, and capability negotiation may change between 3.x releases with 6 weeks' notice. Sellers MUST declare `sponsored_intelligence.core` in `experimental_features`. See [SI Chat Protocol](/dist/docs/3.1.20/sponsored-intelligence/si-chat-protocol). * [ ] Implement SI session tasks (`si_initiate_session`, `si_send_message`, `si_terminate_session`) * [ ] Implement `get_adcp_capabilities` with `sponsored_intelligence` in `supported_protocols` and `sponsored_intelligence.core` in `experimental_features` *** ## Getting help * **Community**: [Slack](https://join.slack.com/t/agenticads/shared_invite/zt-3c5sxvdjk-x0rVmLB3OFHVUp~WutVWZg) * **Issues**: [GitHub Issues](https://github.com/adcontextprotocol/adcp/issues) * **Support**: [support@adcontextprotocol.org](mailto:support@adcontextprotocol.org) # Specification Guidelines Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/spec-guidelines AdCP specification guidelines: type naming rules, discriminated union patterns, field naming conventions, and style standards for writing protocol spec pages. # AdCP Specification Guidelines This document outlines design principles and rules for maintaining the AdCP specification. These guidelines help ensure consistency, clarity, and ease of implementation across different programming languages. ## Type Naming Principles ### No Reused Type Names **RULE**: Never use the same enum name or field name to represent different concepts, even in different contexts. **Why**: Type generators (TypeScript, Python, Go, etc.) create collisions when the same name appears with different values or semantics. This forces downstream users to use awkward workarounds like aliasing or deep imports. **Example of the problem**: ```json theme={null} // ❌ BAD: Multiple "Type" enums with different meanings // asset-type.json { "type": "string", "enum": ["image", "video", "html"] } // format.json { "type": "string", "enum": ["audio", "video", "display"] } // Result: Python generates Type, Type1, Type2 or uses alphabetical first-wins ``` **Solution**: Use semantic, domain-specific names: ```json theme={null} // ✅ GOOD: Distinct enum names for different concepts // asset-content-type.json { "type": "string", "enum": ["image", "video", "html"] } // pricing-model.json { "type": "string", "enum": ["cpm", "cpc", "fixed"] } // Result: Python generates AssetContentType and PricingModel ``` ### Semantic Field Names Field names should describe **what** they represent, not generic categories. **Examples**: * ✅ `asset_content_type` - Clear: describes what content the asset contains * ❌ `type` - Ambiguous: type of what? * ❌ `asset_type` - Better, but could conflict with other type fields ### Enum Consolidation When the same concept appears in multiple places with different subsets: 1. **Create a single canonical enum** with all possible values 2. **Reference that enum** in all schemas using `$ref` 3. **Document subset expectations** in field descriptions when needed **Example**: ```json theme={null} // enums/asset-content-type.json - Single source of truth { "$id": "/schemas/v3/enums/asset-content-type.json", "type": "string", "enum": ["image", "video", "audio", "text", "html", "javascript", ...] } // brand.json - References full enum { "asset_type": { "$ref": "/schemas/v3/enums/asset-content-type.json", "description": "Type of asset. Note: Brand manifests typically contain basic media assets (image, video, audio, text)." } } // list-creative-formats-request.json - References full enum { "asset_types": { "type": "array", "items": { "$ref": "/schemas/v3/enums/asset-content-type.json" } } } ``` **Benefits**: * Type generators produce single, consistent types * API allows filtering/specifying any valid value * Adding new values is non-breaking * Documentation clarifies typical usage without restricting capability ## Specialist Module Naming ### Core Principle Specialist module names must reflect the **technical capability** being taught — what practitioners verify, resolve, or operate — not the business or marketing category. A module titled "Brand" is ambiguous: does it teach brand-safety policy, brand-identity schema validation, or brand-campaign strategy? A module titled "Brand Identity & Verification" tells a developer exactly what they will learn to do. ### Naming Consistency The module name must be consistent across all four surfaces where it appears: 1. **Page title** — the `title:` frontmatter in the module's `.mdx` file 2. **Badge** — the `adcp_specialist_*` credential suffix (e.g., `adcp_specialist_signals`) 3. **Sidebar navigation** — the `sidebarTitle:` frontmatter 4. **Specialist overview table** — the row in the certification overview page If any of these diverge, implementers looking at one surface form a different mental model than those reading another. Keep all four in sync. ### Good vs. Bad Names | Avoid | Prefer | Why | | ----- | ----------------------------- | ---------------------------------------------------------------------------------------------- | | Brand | Brand Identity & Verification | "Brand" reads as marketing; the module teaches schema validation and identity resolution | | Ads | Creative Asset Management | "Ads" is too broad; the module covers creative formats, asset pipelines, and approval flows | | Data | Signals & Audience Activation | "Data" is generic; the module teaches signal discovery, privacy controls, and activation loops | ### Naming Checklist Before proposing a new specialist module: * [ ] Does the name describe the technical workflow, not the business domain? * [ ] Would a developer unfamiliar with AdCP understand what the module teaches from the name alone? * [ ] Is the name consistent across page title, badge, sidebar, and overview table? * [ ] Does the badge suffix (`adcp_specialist_*`) read naturally in a credential context? ## Enum Design ### Enum File Structure All enums should live in `/schemas/3.1.20/enums/` with descriptive names: ``` /schemas/v3/enums/ asset-content-type.json # What IS this asset? pricing-model.json # How is this PRICED? media-buy-status.json # What STATE is the buy in? ``` ### Enum Naming Convention * Use **noun phrases** that describe what's being categorized * Use **kebab-case** for filenames * Generated type names use **PascalCase** (AssetContentType, PricingModel) * Avoid generic terms like "type", "kind", "status" without qualifiers ### When to Create a New Enum Create a dedicated enum file when: * Values are reused across multiple schemas * Values represent a closed set of options * The concept is fundamental to the protocol * Type safety would benefit implementers ### Enum membership — when to add a value Adding a value to an *existing* enum is a curation decision, not a default. An enum is a curated roster of real, shared semantics — not a registry of every vendor or integration. A value earns membership when **all** hold: * **Published** — it names an externally-documented concept with a stable definition, not a per-buyer or per-integration shape. * **Natively supported** — at least one real implementer handles it directly, without bespoke per-value mapping (for a `feed_format`, the seller parses it natively without `feed_field_mappings`). * **Shared demand** — it is relevant across more than one producer **and** more than one consumer (a shared dialect, not branding for a single bilateral integration). A material **dialect** of an existing value earns its own value only when its differences would make the parent value's consumer mis-handle it — a renamed primary key, composite-encoded fields, or a field the parent treats as optional but the dialect requires. Cosmetic or additive-optional differences do not; use the parent value. When a concept fails these tests, model it through the schema's existing extension path (`custom` + a mapping, or `ext`) rather than minting an enum value. This is distinct from [Platform Agnosticism](#platform-agnosticism): a `feed_format` value legitimately names a vendor's *published spec* (the value **is** the spec), whereas platform-agnosticism forbids a vendor-specific *version of a general concept*. **Worked example — `feed_format` ([#3456](https://github.com/adcontextprotocol/adcp/issues/3456)).** `tiktok_shop`, `pinterest_catalog`, and `openai_product_feed` qualify: published, Google-Merchant-Center-derived feed dialects that real sellers parse natively, each with deltas a strict GMC parser would mis-handle. A feed without a published, natively-parsed spec uses `custom` + `feed_field_mappings`. ## Field Design ### Discriminated Unions When objects can have multiple shapes, always use explicit discriminator fields: ```json theme={null} { "oneOf": [ { "type": "object", "properties": { "delivery_type": { "type": "string", "const": "url" }, "url": { "type": "string" } }, "required": ["delivery_type", "url"] }, { "type": "object", "properties": { "delivery_type": { "type": "string", "const": "inline" }, "content": { "type": "string" } }, "required": ["delivery_type", "content"] } ] } ``` This enables proper type narrowing in TypeScript and pattern matching in other languages. ### Avoiding Over-Specific Subsets Don't artificially restrict enum values in request schemas unless there's a technical reason: * ❌ Limit `asset_types` filter to 7 values "because most people only use these" * ✅ Allow all asset content types - let users filter by anything If certain values are uncommon, document that in the description but don't prevent their use. ## Schema References ### When to Use \$ref Use `$ref` for: * Enum values (always) * Core data models used in multiple places * Complex nested objects used repeatedly Don't use `$ref` for: * Simple inline objects used only once * Request-specific parameters * Highly contextual structures ### Reference Paths All `$ref` paths should be absolute from schema root: ```json theme={null} // ✅ GOOD: Absolute path "$ref": "/schemas/v3/enums/asset-content-type.json" // ❌ BAD: Relative path "$ref": "../../enums/asset-content-type.json" ``` ## Platform Agnosticism **RULE**: Normative schema **field names** MUST NOT represent a specific vendor's version of a general concept. Platform-specific fields belong under `ext.{vendor}`. **Why**: AdCP is a protocol, not a platform. A field named `google_campaign_id` or `ttd_line_id` at the top level of a schema bakes one vendor's data model into the spec and creates lock-in. The protocol is credible as an open standard only to the extent that its normative field surface is vendor-neutral. **How**: Vendor-specific fields belong in the `ext.{vendor}` namespace (schema: `/schemas/core/ext.json`, source: `static/schemas/source/core/ext.json`). `ext` is `additionalProperties: true` — the namespacing is a convention enforced by review, not by JSON Schema. ```json theme={null} // ❌ BAD: vendor name in a normative field (a general concept dressed up as a vendor) { "google_campaign_id": "abc123" } // ✅ GOOD: vendor-specific under ext { "ext": { "gam": { "campaign_id": "abc123" } } } ``` ### External system identifiers Names that reference **canonical external identifier spaces** are legitimate in both field names and enum values. The distinction is not "does it contain a vendor token" but "does it represent *that vendor's version of something the protocol already has a general concept for*": * `google_campaign_id` (bad) — a vendor-specific ID for a concept the protocol already models (`media_buy_id`). Move to `ext.gam`. * `apple_podcast_id` (legitimate) — a canonical identifier for a specific Apple Podcasts item. There is no general concept to map to; the Apple Podcasts namespace is *the* namespace. * `nielsen_dma` (legitimate) — the industry-standard geographic division, not "Nielsen's version of geography." Existing examples of legitimate patterns: * Distribution-platform identifier types: `amazon_music_id`, `roku_channel_id` in `distribution-identifier-type.json` (enum values) * Feed formats: `google_merchant_center`, `facebook_catalog` in `brand.json` (enum values) — widely-adopted open interchange formats implemented by many third parties * Measurement/data identifiers: `nielsen_dma` in `get-adcp-capabilities-response` (field name) * Platform IDs: `apple_podcast_id`, `apple_id` (field names) The rule to apply: if the name asks "which vendor-equivalent version of something AdCP models?" (bad — use `ext`), reject; if the name asks "which externally-defined system/format/identifier space?" (legitimate), allow. When allowing a field name, add it to `tests/check-platform-agnostic.cjs` `FIELD_ALLOWLIST` with a one-line justification. When allowing an enum value, add it to `ENUM_VALUE_ALLOWLIST` with a path-qualified entry and a one-line justification. ### Reviewer checklist * Reject a new top-level or request/response field whose name is `{vendor}_{general_concept}` (e.g., `google_campaign_id`, `ttd_line_id`). * Accept an enum value naming an externally-defined system, format, or identifier space. * Vendor names in **example blocks** (email addresses, sample IDs) are fine. * When uncertain, ask: "Is this field or value representing *one vendor's version of something the protocol already has a general concept for*?" If yes, it belongs under `ext.{vendor}`. ## Reserved SDK-Internal Keys **RULE**: The top-level key `ctx_metadata` is reserved on AdCP resource objects as an adapter-internal round-trip cache for state that an SDK or platform adapter needs to carry across calls but that buyers MUST NOT see or rely on. Adapters MUST strip `ctx_metadata` from any payload before wire egress. When the key was present and non-empty at strip time, adapters MUST emit a warning-level log entry so operators can detect accidental key collisions with custom adapter code. (An empty or absent `ctx_metadata` is silent — only a non-empty value triggers the warning.) **Why**: Platform adapters (e.g. Google Ad Manager, Kevel, custom seller infrastructure) often need to associate adapter-internal identifiers — GAM ad-unit IDs, key-value pairs, placement IDs — with AdCP resources that the buyer-facing SDK returns. The reference Prebid `salesagent` Python implementation uses an `implementation_config` JSON column on its Product model for exactly this purpose. Without a reserved name, every SDK invents its own (`implementation_config`, `_internal`, `sdk_state`, etc.); a fourth SDK then collides with one of them, or two SDKs converging on the same name produce ambiguous semantics. One reserved name removes the coordination problem. **Scope**: The reservation applies to AdCP resource objects whose schemas declare `additionalProperties: true` — including `Product`, `MediaBuy`, `Package`, `Creative`, `AudienceSegment`, `Signal`, and `RightsGrant`. The reservation travels with the resource wherever it appears: top-level in a response envelope, nested inside another resource (e.g. `Package` inside `MediaBuy`), or inside an array of resources (e.g. each element of `products: Product[]`). Adapters MUST strip the key from every occurrence before egress, not just the outermost one. `PropertyList` and `CollectionList` declare `additionalProperties: false` and are out of scope until a follow-up PR widens those schemas; until then, adapters needing round-trip state for those resources should track it out-of-band. **Distinction from neighboring conventions**: * `ext.{vendor}` — vendor-namespaced, **buyer-visible**, travels on the wire. Use for vendor-specific data the buyer should see (e.g. `ext.gam.line_item_id`). * `context` / `context_id` — caller-echoed correlation data, also wire-visible. Despite the prefix-match, `ctx_metadata` is not a sub-namespace of these — they are unrelated concepts and travel on different layers. * `ctx_metadata` — **adapter-internal only**, MUST be stripped before egress, never reaches the buyer. **Adapter conformance**: ``` 1. Read ctx_metadata from inbound resource (publisher → SDK direction). 2. Carry it in adapter-local state. 3. Before serializing the resource for wire egress (SDK → buyer direction): a. Remove the ctx_metadata key. b. If the key was present and non-empty, emit a warning-level log: "stripping reserved ctx_metadata before egress on " 4. Buyer-facing surfaces MUST NOT expose ctx_metadata in any documentation, typed shape, or example. ``` **Reviewer checklist**: * Reject any spec, schema, or example that promotes `ctx_metadata` as a buyer-readable field. * Reject any SDK contribution that surfaces `ctx_metadata` in a buyer-facing typed return. * Accept SDK code that reads/writes `ctx_metadata` as adapter-internal state, provided the egress-strip + warning-log path is in place. ## Breaking Changes ### What Constitutes a Breaking Change **Major version bump required**: * Removing enum values * Renaming fields * Changing field types * Making optional fields required * Removing fields entirely **Minor version bump allowed**: * Adding new enum values (append-only) * Adding new optional fields * Clarifying descriptions * Adding new tasks/endpoints ### Migration Strategy When making breaking changes: 1. **Create v2 directory**: `/schemas/3.1.20/` 2. **Maintain v1**: Keep old schemas functional 3. **Document migration**: Provide before/after examples 4. **Deprecation period**: Support both versions for defined period ## JSON Schema Conventions ### Nullable Scalars For AdCP 3.x draft-07 schemas, encode nullable scalar fields as a JSON Schema type union: ```json theme={null} { "type": ["string", "null"] } ``` Use the same pattern for nullable numbers, integers, booleans, and mixed scalar value buckets. Do not introduce OpenAPI-style `nullable: true` in source schemas; it is not part of JSON Schema Draft 07 and creates inconsistent SDK projection rules. Nullable enums must include `null` in both the `type` union and the `enum` value set: ```json theme={null} { "type": ["string", "null"], "enum": ["active", "paused", null] } ``` Nullability and presence are separate in JSON Schema Draft 07: * `type: ["string", "null"]` means the field may be `null` when it is present. * The enclosing object's `required` array controls whether the field must be present. * Optional nullable fields therefore have three states: omitted, present with `null`, and present with a scalar value. * Required nullable fields have two states: present with `null` or present with a scalar value. When omission and explicit `null` carry different semantics, state that distinction in the field description so SDK generators do not collapse the cases. ## Testing Schemas All schema changes must: 1. ✅ Validate with JSON Schema Draft 07 2. ✅ Pass example data through validation 3. ✅ Generate types successfully (Python, TypeScript) 4. ✅ Update documentation to match 5. ✅ Include an `adcontextprotocol` changeset describing the schema change ## Review Checklist Before merging schema changes, verify: * [ ] No duplicate enum names across different files * [ ] No ambiguous field names (like bare "type") * [ ] All enums referenced via `$ref`, not inline * [ ] Breaking changes use proper versioning * [ ] Documentation updated to match schemas * [ ] Examples validate against new schemas * [ ] Type generation tested * [ ] `adcontextprotocol` changeset created with proper version bump ## Philosophy **"The schema is the spec"** Documentation should reflect what's in schemas, but schemas are the source of truth. When documentation and schemas diverge, schemas win. This means: * Write clear, detailed descriptions in schemas * Use semantic names that are self-documenting * Design for type generation, not just validation * Think about developer ergonomics across languages **"Make the right thing easy"** Good schema design guides implementers toward correct usage: * Use discriminators so type checkers catch mistakes * Use semantic names so code reads clearly * Consolidate enums so generators produce clean types * Restrict where necessary, but don't over-restrict ## Questions? When in doubt about schema design decisions: 1. Check existing patterns in `/schemas/3.1.20/` 2. Consider impact on type generation 3. Ask: "Will this name collision cause issues?" 4. Prefer specificity over brevity 5. Document rationale in this file for future reference # Accessibility Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/accessibility AdCP accessibility support lets formats declare WCAG conformance levels and requires accessible assets like alt text and captions. AdCP supports accessibility at two levels: formats declare the conformance level of their rendered output, and assets carry the metadata needed to achieve it. ## How It Works Accessibility in advertising creatives depends on who controls the rendering: * **Format-rendered creatives** (image + headline + CTA): The format controls the output. It can guarantee contrast ratios, keyboard navigation, and ARIA landmarks — it just needs the right inputs from the creative (alt text for images, captions for video, etc.). * **Opaque creatives** (HTML bundles, JavaScript tags): The format can't inspect or modify the content. The asset must self-declare its accessibility properties. AdCP handles both cases through the format's `accessibility` object and per-asset-type accessibility metadata. ## Format Accessibility Formats declare their accessibility posture through the `accessibility` object: ### `accessibility.wcag_level` The WCAG conformance level that creatives produced by this format will meet. Values: `A`, `AA`, `AAA`. For format-rendered creatives, this is a guarantee from the format. For opaque creatives, this reflects the level the format requires assets to self-certify to. ### `accessibility.requires_accessible_assets` When `true`, all assets with accessibility-relevant fields must include those fields. This is the enforcement mechanism — it tells validation to treat optional accessibility fields as required. ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "name": "Display Banner 300x250", "accessibility": { "wcag_level": "AA", "requires_accessible_assets": true }, "assets": [ { "item_type": "individual", "asset_id": "hero_image", "asset_type": "image", "required": true, "requirements": { "min_width": 300, "max_width": 300, "min_height": 250, "max_height": 250, "formats": ["jpg", "png", "webp"] } }, { "item_type": "individual", "asset_id": "headline", "asset_type": "text", "required": true, "requirements": { "max_length": 90 } } ] } ``` This format guarantees WCAG AA output and requires `alt_text` on the image asset (because `alt_text` is marked as an accessibility field on the image asset type). ## Asset Accessibility Fields Each asset type defines which of its fields are accessibility-relevant using the `x-accessibility` schema marker. These fields are always optional by default, but become required when the format sets `accessibility.requires_accessible_assets: true`. ### Inspectable Assets These assets provide structured data that the format uses to render accessibly. | Asset Type | Accessibility Fields | Purpose | | ---------- | ----------------------- | ----------------------------------- | | **Image** | `alt_text` | Alternative text for screen readers | | **Video** | `captions_url` | URL to captions file (WebVTT, SRT) | | | `transcript_url` | URL to text transcript | | | `audio_description_url` | URL to audio description track | | **Audio** | `transcript_url` | URL to text transcript | **Example** — video asset in a manifest for an accessible format: ```json theme={null} { "creative_id": "brand_video_001", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_hosted" }, "assets": { "video_file": { "url": "https://cdn.example.com/video.mp4", "width": 1920, "height": 1080, "duration_ms": 30000, "captions_url": "https://cdn.example.com/video.vtt", "transcript_url": "https://cdn.example.com/video-transcript.txt", "audio_description_url": "https://cdn.example.com/video-ad.mp3" } } } ``` ### Opaque Assets HTML and JavaScript assets are black boxes — the format can't inspect their rendering. These assets carry an `accessibility` object with self-declared properties. | Field | Type | Description | | ---------------------- | ------- | ----------------------------------------------------------------- | | `alt_text` | string | Text alternative describing the creative content | | `keyboard_navigable` | boolean | Creative can be fully operated via keyboard | | `motion_control` | boolean | Respects `prefers-reduced-motion` or provides pause/stop controls | | `screen_reader_tested` | boolean | Creative has been tested with screen readers | **Example** — HTML creative with accessibility declarations: ```json theme={null} { "creative_id": "rich_media_001", "format_id": { "agent_url": "https://publisher.com", "id": "rich_media_expandable" }, "assets": { "creative_html": { "content": "
...
", "version": "HTML5", "accessibility": { "alt_text": "Interactive product carousel showing summer collection", "keyboard_navigable": true, "motion_control": true, "screen_reader_tested": true } } } } ``` Self-declared accessibility is a trust claim. Platforms may independently validate these properties — that is outside the scope of the protocol. ### Third-Party Tags VAST and DAAST assets wrap video and audio delivered by third parties. They carry accessibility fields alongside their existing tag properties. | Asset Type | Accessibility Fields | | ---------- | --------------------------------------- | | **VAST** | `captions_url`, `audio_description_url` | | **DAAST** | `transcript_url` | ### Assets Without Accessibility Fields Some asset types don't produce standalone rendered content and have no accessibility fields. When a format sets `accessibility.requires_accessible_assets: true`, these are effectively no-ops: * **Text** — rendered by the format * **Markdown** — rendered by the format * **CSS** — styles, not content * **URL** — links, not rendered content * **Webhook** — server-side ## Discovering Accessible Formats Buyers can filter for accessible formats using the `wcag_level` parameter in `list_creative_formats`: ```json theme={null} { "wcag_level": "AA", "asset_types": ["image", "text"] } ``` This returns formats that meet at least WCAG AA conformance and accept image and text assets. The filter uses "at least" logic: requesting `AA` returns formats with `AA` or `AAA`. ## Implementation Notes **Enforcement is application-level.** The `x-accessibility` marker is a JSON Schema extension keyword. Standard JSON Schema validators ignore it — enforcement of `accessibility.requires_accessible_assets` must be implemented in application code that scans asset schemas for `x-accessibility: true` fields and validates their presence. **For format implementers:** * Set `accessibility.wcag_level` only when you can substantiate the claim — through your own rendering guarantees or by requiring accessible assets * If your format renders from structured inputs, ensure your rendering pipeline meets the declared WCAG level (contrast, keyboard nav, ARIA) * If your format wraps opaque assets, `accessibility.requires_accessible_assets: true` ensures the inputs carry the right declarations **For creative producers:** * When submitting to a format with `accessibility.requires_accessible_assets: true`, include all accessibility fields for your asset types * For opaque assets, test accessibility properties before declaring them * Provide captions and transcripts as separate hosted files, not embedded in the asset ## Related Documentation * [Creative Formats](/dist/docs/3.1.20/creative/formats) — Format structure and requirements * [Asset Types](/dist/docs/3.1.20/creative/asset-types) — Asset specifications and payload schemas * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) — Pairing assets with formats * [list\_creative\_formats](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) — Format discovery with filtering # AI creative for campaign teams Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/ai-creative-overview AI creative in AdCP lets campaign teams write a brief and get production-ready ads generated across formats without learning the protocol. AI creative in AdCP means you write a brief and an AI agent handles production. It generates ads from your direction, adapts them across formats, and can personalize them at serve time. You stay in control of the brand. The AI does the heavy lifting. This page explains how it works in terms you already use. ## How it works The workflow mirrors what you do with a production team, except the team is an AI agent. ### 1. Write the brief You describe what you want in plain language, the same way you'd brief a creative team. *"Create a holiday promotion for our outdoor gear line. Earth tones, active lifestyle imagery. Headline should emphasize the limited-time offer."* The brief can include a message (what to say), brand identity assets (logos, colors, fonts), and constraints (tone of voice, topics to avoid). The more specific you are, the better the output. ### 2. Review concepts The AI generates options. You see previews — rough concepts for direction, or polished versions for stakeholder review. Browse them the way you'd review comps from a production studio. You control the quality level: * **Draft mode** gives you fast, rough concepts for exploration. Think tissue session. * **Production mode** gives you polished, client-ready output. Think final comps. ### 3. Iterate Give feedback in the language you'd use with any creative team. *"Make the headline more urgent."* *"Try a warmer color palette."* *"Keep the layout but swap in the lifestyle photo instead of the product shot."* The AI revises and returns updated previews. ### 4. Approve and traffic Lock in the final creative. It gets synced to your campaigns — assigned to media buys, matched to placements, ready to serve. ### 5. Monitor what actually ran After launch, review what the AI served. Every variant, every context. Full audit trail. If the AI personalized headlines per audience, you can see each version and how it performed. ## Quality control AI creative has two separate quality dimensions. Understanding both prevents surprises. **Concept quality** is about the creative itself. Draft mode produces fast, rough ideas — useful when you're exploring directions and don't need pixel-perfect output. Production mode produces finished work suitable for client review and launch. You choose which mode based on where you are in the process. **Preview quality** is about how you view the output. Quick thumbnails let you scan many options fast. Full-fidelity renders show exactly how the ad will look at final size and resolution. These are independent of concept quality — you can get a high-fidelity render of a draft concept, or a quick thumbnail of a production-ready piece. Think of it as the difference between a tissue session (rough concepts, fast iteration) and a final presentation (polished work, high-res mockups). ## Brand safety Four layers keep your brand protected throughout the process. **The brief itself.** Your creative direction sets the boundaries. Specify tone, topics to include, topics to avoid, and any do's and don'ts. The AI works within these constraints. **Brand identity.** Logos, color palettes, typography, and brand guidelines are provided as structured assets. The AI references them during generation, not just as suggestions but as requirements. **Pre-launch review.** Before anything goes live, preview what the AI will generate. Review concepts, test edge cases, and approve the system before it serves a single impression. **Post-launch audit.** After launch, see every variant that was actually served. Not a summary — the actual creative outputs, with context about when and where each ran. ## What to expect AI-generated ads work differently from traditional production in a few important ways. **Previews are representative, not exact.** Because the AI can generate per-impression (adapting to context, audience, or placement), a pre-launch preview shows you what the AI *will* generate, not the one fixed ad it will serve. The preview is accurate to the brief and brand identity, but the live campaign may produce variations. **Conversational formats need guardrail testing.** If your ad includes an interactive chatbot or conversational element, test the boundaries. What happens when someone asks about a competitor? What if they ask an off-topic question? Pre-launch review should include these scenarios. **You approve the system, not every individual ad.** With traditional creative, you approve each finished ad. With AI creative, you approve the combination of brief, brand identity, and guardrails that the AI uses to generate ads. This is what makes personalization at scale possible — and why getting the brief and guardrails right matters more than reviewing every output. ## Protocol terms in agency language | What you call it | What AdCP calls it | | ------------------------------- | ----------------------------------------------------------- | | Creative brief | `message` or `assets.brief` in `build_creative` | | Comp / mockup | Preview from `preview_creative` | | Ad unit spec sheet | Creative manifest | | Production studio | Creative agent | | Placement size | Format (e.g., `display_300x250`) | | Trafficking | `sync_creatives` or inline attachment on `create_media_buy` | | Campaign report (variant-level) | `get_creative_delivery` | ## Next steps When you're ready to go deeper: * **See it in action** -- The [Creative protocol overview](/dist/docs/3.1.20/creative) follows a strategist from brief to delivery across CTV, display, and social * **Technical workflow** -- [Generative creative](/dist/docs/3.1.20/creative/generative-creative) walks through the API step by step * **Library management** -- [Creative libraries and concepts](/dist/docs/3.1.20/creative/creative-libraries) covers organizing and syncing assets * **CTV and video** -- [CTV and connected TV](/dist/docs/3.1.20/creative/channels/ctv) covers SSAI/CSAI delivery, companion ads, and VAST tags * **Multi-agent orchestration** -- [Multi-agent creative orchestration](/dist/docs/3.1.20/creative/multi-agent-orchestration) covers distributing creatives across sellers * **Brand safety details** -- [Sales agent creative capabilities](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities) explains guardrails and inline creative management * **Learning** -- The [certification program](/dist/docs/3.1.20/learning/overview) teaches AdCP through interactive modules with Addie # Asset Types Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/asset-types AdCP asset types define standardized properties for images, video, text, audio, tags, and tracking URLs used in creative formats. > **Canonical-formats readers**: this page describes asset types and their payload shapes — the same in v1 and the canonical-formats path. For how assets map to canonical-format slots via `asset_group_id`, see [canonical-formats](/dist/docs/3.1.20/creative/canonical-formats) and the [migration guide](/dist/docs/3.1.20/creative/canonical-formats-migration). v1 uses `asset_id` + `asset_role`; canonical formats use `asset_group_id` referencing the canonical vocabulary registry. Both paths use the same asset payload schemas — only the slot-key vocabulary differs. Creative formats in AdCP use standardized asset types with well-defined properties. Assets are the discrete, typed building blocks used by formats to define requirements and by manifests to supply concrete values. Standardizing asset types ensures consistency across formats and makes requirements easier for buyers and systems to understand. ## Asset types in v2 The full set of asset types valid in a v2 format `slots` declaration's `asset_type` field: | asset\_type | What it carries | Where defined | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | | `image` | Image file (jpg/png/gif/webp/svg) | `/schemas/core/assets/image-asset.json` | | `video` | Video file (mp4/webm/mov) | `/schemas/core/assets/video-asset.json` | | `audio` | Audio file (mp3/aac/wav) | `/schemas/core/assets/audio-asset.json` | | `text` | Plain text (headline, body, script) | `/schemas/core/assets/text-asset.json` | | `markdown` | Markdown text | `/schemas/core/assets/markdown-asset.json` | | `url` | URL with `url_type` discriminator (clickthrough, tracker\_pixel, third-party tag) | `/schemas/core/assets/url-asset.json` | | `html` | Inline HTML | `/schemas/core/assets/html-asset.json` | | `css` | CSS rules | `/schemas/core/assets/css-asset.json` | | `javascript` | JavaScript | `/schemas/core/assets/javascript-asset.json` | | `vast` | VAST tag (URL or inline XML), VAST 2.x-4.x | `/schemas/core/assets/vast-asset.json` | | `daast` | DAAST tag (URL or inline XML), 1.0-1.1 | `/schemas/core/assets/daast-asset.json` | | `webhook` | Webhook URL for async creative production | `/schemas/core/assets/webhook-asset.json` | | `brief` | Free-text creative brief (input to generative production) | `/schemas/core/assets/brief-asset.json` | | `catalog` | Reference to a synced catalog (sponsored\_placement) | `/schemas/core/assets/catalog-asset.json` | | `published_post` | Reference to an already-published post that the seller resolves after authorization/review | `/schemas/core/assets/published-post-asset.json` | | `zip` | Zip archive (HTML5 banner bundle) | `/schemas/core/assets/zip-asset.json` | | `vast_tracker` | Single VAST `Tracking` event URL (decomposed) | `/schemas/core/assets/vast-tracker-asset.json` | | `daast_tracker` | Single DAAST `Tracking` event URL (decomposed) | `/schemas/core/assets/daast-tracker-asset.json` | | `pixel_tracker` | Renderer-fired HTTP tracker (image pixel or JS include) for any web-rendered canonical. `event` (impression / viewable\_mrc\_\* / viewable\_video\_50 / audible\_video\_complete / click / custom) × `method` (img / js). Maps to IAB OpenRTB Native 1.2 `imptrackers[]` / `jstracker` / `eventtrackers[]` / `link.clicktrackers[]` event-type registry (types 1, 2, 3, 4, 500) | `/schemas/core/assets/pixel-tracker-asset.json` | | `object` | Structured object (image\_carousel `cards`, `video_brief` for generative video) — sub-shape declared by the canonical | (no standalone schema; per-canonical sub-shape) | In a v2 manifest's `assets` map, the **slot key** is the canonical's `asset_group_id` (e.g., `image_main`, `video_main`, `script`, `cards`, `landing_page_url`) and the **value** carries the matching asset payload with its `asset_type` discriminator. The format declaration's `slots[].asset_type` tells the validator which payload schema applies. Published-post references use the same rule: a product that accepts existing posts declares a `published_post` slot, and the manifest value carries `asset_type: "published_post"` with a `post_url` or `platform_post_id`. ## Important: Payload vs Requirements For payload schemas (the structure of the actual asset data supplied in creative manifests), see: * [Asset Type Registry](https://adcontextprotocol.org/schemas/3.1.20/creative/asset-types/index.json) - Links to all payload schemas * Core Asset Schemas at `/schemas/3.1.20/core/assets/` - Individual asset payload definitions **Key distinction:** **Format requirements** (this document) define constraints such as: * Whether an asset is required or optional * Acceptable file or container formats * Duration, dimension, or aspect ratio limits * File size and bitrate limits * Allowed or restricted features (for tag-based assets) **Payload schemas** (core schemas) define the supplied values, such as: * `url` * `content` (for inline text or inline tag markup) * `width` / `height` (when declared) * `duration_ms` (when applicable) * `format` (declared container type) ## Asset Type Schema The official JSON schema for asset types is available at: * **Production**: [https://adcontextprotocol.org/schemas/asset-types-v1.json](https://adcontextprotocol.org/schemas/asset-types-v1.json) * **GitHub**: [https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/asset-types-v1.json](https://github.com/adcontextprotocol/adcp/blob/main/static/schemas/asset-types-v1.json) ## Core Asset Types ### Video Asset Video assets represent video files with specific technical requirements. ```json theme={null} { "asset_type": "video", "required": true, "duration_seconds": 15, "acceptable_formats": ["mp4"], "acceptable_codecs": ["h264"], "acceptable_resolutions": ["1920x1080", "1280x720"], "aspect_ratio": "16:9", "max_file_size_mb": 30, "min_bitrate_mbps": 8, "max_bitrate_mbps": 10 } ``` **Properties:** * `duration_seconds`: Expected video duration * `min_duration_seconds` / `max_duration_seconds`: Duration range (if flexible) * `acceptable_formats`: Container formats (mp4, webm, mov) * `acceptable_codecs`: Video codecs (h264, h265, vp8, vp9, av1) * `acceptable_resolutions`: List of width x height strings * `aspect_ratio`: Required aspect ratio (16:9, 9:16, 1:1, etc.) * `max_file_size_mb`: Maximum file size in megabytes * `min_bitrate_mbps` / `max_bitrate_mbps`: Bitrate range in Mbps * `features`: Additional requirements (e.g., \["non-skippable", "sound on"]) ### Image Asset Static image assets for banners, logos, and visual content. ```json theme={null} { "asset_type": "image", "required": true, "width": 300, "height": 250, "acceptable_formats": ["jpg", "png", "gif"], "max_file_size_kb": 200, "animation_allowed": true } ``` **Properties:** * `width` / `height`: Dimensions in pixels * `min_width` / `min_height`: Minimum dimensions (px; typically used by responsive/sizeless formats) * `aspect_ratio`: Required aspect ratio * `acceptable_formats`: Image formats (jpg, png, gif, webp, svg) * `max_file_size_kb`: Maximum file size in kilobytes * `transparency`: Whether transparency is required/supported * `animation_allowed`: Whether animated GIFs are accepted * `notes`: Additional requirements (e.g., "Must be free of text") **Use Cases:** * Fixed layout: provide `width` and `height`. Do not include `min_width`, `min_height`, or `aspect_ratio`. * Responsive (fixed image aspect ratio): provide `min_width`, `min_height` and `aspect_ratio`. Do not include `width` or `height`. * Responsive (any image aspect ratio): provide `min_width` and `min_height` only. Do not include `width`, `height`, or `aspect_ratio`. **Note**: In fixed layouts, the image slot is an exact pixel box, so specify `width` and `height`. In responsive layouts, the renderer will resize the image; use `min_width`/`min_height` to ensure there are enough pixels for a sharp result after scaling. Use `aspect_ratio` only when the image asset itself must be a specific shape (e.g., 16:9); omit it if any image aspect ratio is acceptable ### Text Asset Text content for headlines, descriptions, CTAs, etc. ```json theme={null} { "asset_type": "text", "required": true, "text_type": "headline", "max_length": 90, "min_length": 10 } ``` **Properties:** * `text_type`: Specific type (title, headline, description, body, cta, advertiser\_name, disclaimer) * `max_length`: Maximum character count * `min_length`: Minimum character count * `default`: Default value if not provided * `allowed_characters`: Regex pattern for validation * `format`: Expected format (plain, currency, percentage) ### URL Asset Links for clickthroughs, tracking, and landing pages. Two related but distinct fields describe a URL asset: * **`url_type`** (on the manifest asset) — the **mechanism** the receiver uses to invoke this URL. * **`url-asset-requirements.role`** (on the format) — the **purpose** this URL slot serves in the creative. A slot can be `click_tracker` (purpose) and accept a `tracker_pixel` (mechanism) URL — those describe different things. #### Manifest-side: `url_type` (mechanism) Senders **SHOULD** include `url_type` on every URL asset. The valid values are: | Value | Mechanism | | ---------------- | --------------------------------------------------------------------------------------------------------- | | `clickthrough` | User-click destination (landing page or ad-tech redirector) | | `tracker_pixel` | Fires HTTP GET, expects 1×1 pixel or 204 response (impression / event / 3P trackers) | | `tracker_script` | Loads as a `" } } } ``` ### HTML Tag Format ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90_html" }, "type": "display", "renders": [ { "role": "primary", "dimensions": { "width": 728, "height": 90, "responsive": { "width": false, "height": false } } } ], "assets": [ { "item_type": "individual", "asset_id": "tag", "asset_type": "html", "asset_role": "third_party_tag", "required": true, "requirements": { "max_file_size_kb": 200, "sandbox": "iframe", "external_resources_allowed": true } } ] } ``` HTML tag manifest: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90_html" }, "assets": { "tag": { "asset_type": "html", "content": "" } } } ``` ## HTML5 Multi-Asset Formats HTML5 formats specify multiple assets that the publisher's ad server assembles into an interactive creative. ### HTML5 Banner Format ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_html5" }, "type": "display", "assets": [ { "asset_id": "background_image", "asset_type": "image", "asset_role": "background", "required": true, "requirements": { "width": 300, "height": 250, "file_types": ["jpg", "png"] } }, { "asset_id": "logo", "asset_type": "image", "asset_role": "logo", "required": true, "requirements": { "max_width": 100, "max_height": 50, "file_types": ["png", "svg"] } }, { "asset_id": "headline", "asset_type": "text", "asset_role": "headline", "required": true, "requirements": { "max_length": 25 } }, { "asset_id": "cta_text", "asset_type": "text", "asset_role": "call_to_action", "required": true, "requirements": { "max_length": 15 } } ] } ``` HTML5 manifest: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_html5" }, "assets": { "background_image": { "asset_type": "image", "url": "https://cdn.brand.com/bg.jpg", "width": 300, "height": 250 }, "logo": { "asset_type": "image", "url": "https://cdn.brand.com/logo.png", "width": 80, "height": 40 }, "headline": { "asset_type": "text", "content": "Spring Sale - 50% Off" }, "cta_text": { "asset_type": "text", "content": "Shop Now" }, "landing_url": { "asset_type": "url", "url_type": "clickthrough", "url": "https://brand.com/spring" } } } ``` ## Responsive Display Formats Responsive formats adapt to multiple sizes based on placement context. ### Responsive Banner Format ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_responsive" }, "type": "display", "responsive": true, "supported_sizes": ["300x250", "728x90", "320x50"], "assets": [ { "asset_id": "background_image", "asset_type": "image", "asset_role": "background", "required": true, "requirements": { "min_width": 728, "min_height": 250, "responsive": true, "file_types": ["jpg", "png", "webp"] } }, { "asset_id": "logo", "asset_type": "image", "asset_role": "logo", "required": true }, { "asset_id": "headline", "asset_type": "text", "asset_role": "headline", "required": true, "requirements": { "max_length": 30 } } ] } ``` ## Rich Media Formats ### Expandable Banner Format ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_970x250_expandable" }, "type": "display", "expandable": true, "collapsed_size": "970x250", "expanded_size": "970x600", "assets": [ { "asset_id": "collapsed_creative", "asset_type": "html", "asset_role": "collapsed_state", "required": true, "requirements": { "width": 970, "height": 250, "max_file_size_kb": 200 } }, { "asset_id": "expanded_creative", "asset_type": "html", "asset_role": "expanded_state", "required": true, "requirements": { "width": 970, "height": 600, "max_file_size_kb": 500 } } ] } ``` ## Display-Specific Macros In addition to [universal macros](/dist/docs/3.1.20/creative/universal-macros), display formats support: ### Placement Context * `{PLACEMENT_ID}` - IAB Global Placement ID * `{FOLD_POSITION}` - above\_fold, below\_fold * `{AD_WIDTH}` / `{AD_HEIGHT}` - Ad slot dimensions in pixels ### Web Context * `{DOMAIN}` - Publisher domain (e.g., "nytimes.com") * `{PAGE_URL}` - Full page URL (URL-encoded) * `{REFERRER}` - HTTP referrer URL * `{KEYWORDS}` - Page keywords (comma-separated) ### Device Context * `{DEVICE_TYPE}` - mobile, tablet, desktop * `{OS}` - iOS, Android, Windows, macOS * `{USER_AGENT}` - Full user agent string **Example with display macros:** ``` https://track.brand.com/imp? buy={MEDIA_BUY_ID}& placement={PLACEMENT_ID}& domain={DOMAIN}& fold={FOLD_POSITION}& device={DEVICE_TYPE}& cb={CACHEBUSTER} ``` ## Common File Specifications ### Image Requirements * **File types**: JPG, PNG, WebP, GIF * **Max file sizes**: * Standard banners: 150-200KB * Large formats (970x250): 300KB * Animated GIFs: 500KB * HTML5 initial load: 200KB ### Third-Party Tag Requirements * **HTTPS required**: All tag URLs must use secure protocol * **Max file size**: 200KB for tag content * **Async loading**: Tags should not block page rendering ### Animation Specifications For animated GIF formats: * `animated`: true * `animation_duration_ms`: Duration in milliseconds * Common durations: 15000ms (15 seconds) ## Related Documentation * [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) - Complete macro reference * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - Manifest structure and validation * [Asset Types](/dist/docs/3.1.20/creative/asset-types) - Image, HTML, and JavaScript asset specifications # DOOH (Digital Out-of-Home) Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/channels/dooh DOOH ad formats in AdCP define digital billboard, transit screen, and venue display specifications with venue-based impression tracking. This guide covers how AdCP represents Digital Out-of-Home advertising formats for digital billboards, transit screens, and venue displays. ## DOOH Format Characteristics DOOH formats differ from other digital formats: * Display on physical screens in public spaces * Include venue context (airport, mall, highway, etc.) * Use venue-based impression tracking instead of device identifiers * No clickthrough URLs (use QR codes instead) * Often display without audio ## Standard DOOH Formats ### Digital Billboard (Landscape) ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "dooh_billboard_1920x1080" }, "type": "dooh", "assets": [ { "asset_id": "billboard_image", "asset_type": "image", "asset_role": "hero_image", "required": true, "requirements": { "width": 1920, "height": 1080, "file_types": ["jpg", "png"], "max_file_size_kb": 1000 } }, { "asset_id": "impression_tracker", "asset_type": "url", "url_type": "tracker_pixel", "required": true } ] } ``` ### Transit Screen (Portrait) ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "dooh_transit_1080x1920" }, "type": "dooh", "assets": [ { "asset_id": "screen_image", "asset_type": "image", "asset_role": "hero_image", "required": true, "requirements": { "width": 1080, "height": 1920, "aspect_ratio": "9:16", "file_types": ["jpg", "png"] } }, { "asset_id": "impression_tracker", "asset_type": "url", "url_type": "tracker_pixel", "required": true } ] } ``` ### Video Billboard ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "dooh_video_15s" }, "type": "dooh", "assets": [ { "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "required": true, "requirements": { "duration": "15s", "width": 1920, "height": 1080, "format": ["MP4"], "audio_required": false, "max_file_size_mb": 50 } }, { "asset_id": "impression_tracker", "asset_type": "url", "url_type": "tracker_pixel", "required": true } ] } ``` ## Impression Tracking for DOOH DOOH formats use impression trackers (often called "proof-of-play") to verify when creatives display on physical screens. These are standard URL assets with DOOH-specific macros: ```json theme={null} { "asset_id": "impression_tracker", "asset_type": "url", "url_type": "tracker_pixel", "required": true, "requirements": { "required_macros": [ "SCREEN_ID", "PLAY_TIMESTAMP", "VENUE_LAT", "VENUE_LONG" ] } } ``` The mechanics are identical to digital impression tracking - it's just a URL that fires when the ad displays. The difference is the macros capture physical venue context instead of device identifiers. ## Creative Manifests ### Static Billboard Manifest ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "dooh_billboard_1920x1080" }, "assets": { "billboard_image": { "asset_type": "image", "url": "https://cdn.brand.com/dooh_billboard.jpg", "width": 1920, "height": 1080 }, "impression_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.com/pop?buy={MEDIA_BUY_ID}&screen={SCREEN_ID}&venue={VENUE_TYPE}&ts={PLAY_TIMESTAMP}&lat={VENUE_LAT}&long={VENUE_LONG}" } } } ``` ### Video Billboard Manifest ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "dooh_video_15s" }, "assets": { "video_file": { "asset_type": "video", "url": "https://cdn.brand.com/dooh_15s.mp4", "duration": 15, "width": 1920, "height": 1080, "audio": false }, "impression_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.com/pop?buy={MEDIA_BUY_ID}&screen={SCREEN_ID}&ts={PLAY_TIMESTAMP}" } } } ``` ## DOOH-Specific Macros In addition to [universal macros](/dist/docs/3.1.20/creative/universal-macros), DOOH formats support: ### Venue Information * `{SCREEN_ID}` - Unique screen identifier * `{VENUE_TYPE}` - airport, mall, transit, highway, retail * `{VENUE_NAME}` - Specific venue name * `{VENUE_LAT}` / `{VENUE_LONG}` - GPS coordinates ### Play Information * `{PLAY_TIMESTAMP}` - When creative displayed (Unix timestamp) * `{DWELL_TIME}` - Average dwell time at this location (seconds) * `{LOOP_LENGTH}` - Total ad rotation duration (seconds) **Example impression tracking URL:** ``` https://track.brand.com/imp? buy={MEDIA_BUY_ID}& screen={SCREEN_ID}& venue={VENUE_TYPE}& venue_name={VENUE_NAME}& ts={PLAY_TIMESTAMP}& lat={VENUE_LAT}& long={VENUE_LONG}& dwell={DWELL_TIME} ``` ## Common Aspect Ratios * **16:9** (1920x1080) - Landscape billboards and highway screens * **9:16** (1080x1920) - Portrait transit and retail displays * **1:1** (1080x1080) - Square formats ## Impression Verification DOOH impression trackers confirm: * Creative actually displayed on physical screen * Exact timestamp of display * Specific screen location and venue context **Example impression data captured:** ```json theme={null} { "media_buy_id": "mb_dooh_q1", "screen_id": "LAX_T1_GATE24", "venue_type": "airport", "venue_lat": "33.9416", "venue_long": "-118.4085", "play_timestamp": "1704067200", "dwell_time_seconds": "45" } ``` This is functionally identical to impression tracking in other channels - the URL fires when the ad displays, providing verification for billing and reporting. ## Related Documentation * [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) - Complete macro reference including DOOH macros * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - Manifest structure and examples * [Asset Types](/dist/docs/3.1.20/creative/asset-types) - URL asset specifications # Social and feed-native Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/channels/social-native Social and feed-native ad formats in AdCP define buyer-provided assets that platforms wrap in their own UI as promoted posts and sponsored content. This guide covers how AdCP represents advertising that renders as native content within a platform's feed — promoted posts, sponsored listings, boosted content, and other formats where the platform wraps buyer assets in its own chrome. Feed-native ads differ from standard display in a fundamental way: the buyer provides content assets (text, images, links), but the platform controls the visual presentation. The ad inherits the platform's UI — avatar, engagement buttons, community badges, dark mode — and appears alongside organic content. ## How feed-native formats work in AdCP A platform that hosts feed-native ads implements a creative agent with formats that define buyer-provided assets only. The platform handles rendering at preview and serve time, wrapping the buyer's content in its UI. ### Format definition The format specifies what the buyer provides. Everything else — layout, typography, engagement UI — is the platform's responsibility: ```json theme={null} { "format_id": { "agent_url": "https://ads.socialplatform.example", "id": "promoted_post" }, "name": "Promoted post", "type": "native", "description": "Sponsored content that appears in the feed alongside organic posts. Renders with platform chrome (user avatar, engagement buttons, community badge).", "assets": [ { "item_type": "individual", "asset_id": "headline", "asset_type": "text", "required": true, "requirements": { "max_length": 300 } }, { "item_type": "individual", "asset_id": "body", "asset_type": "text", "required": false, "requirements": { "max_length": 1000 } }, { "item_type": "individual", "asset_id": "image", "asset_type": "image", "required": false, "requirements": { "max_width": 1200, "max_height": 628, "accepted_types": ["image/jpeg", "image/png"] } }, { "item_type": "individual", "asset_id": "click_url", "asset_type": "url", "required": true, "requirements": {} } ] } ``` The `renders` array is optional for feed-native formats because the platform determines the visual dimensions at render time based on device, feed context, and layout rules. ### Preview with platform chrome When a buyer calls `preview_creative`, the platform renders a preview that includes the full feed experience — not just the buyer's assets in isolation: ```json theme={null} { "request_type": "single", "creative_manifest": { "format_id": { "agent_url": "https://ads.socialplatform.example", "id": "promoted_post" }, "assets": { "headline": { "content": "Introducing our new trail running collection" }, "body": { "content": "Built for the mountains. Tested on every terrain." }, "image": { "url": "https://cdn.acme-example.com/trail-hero.jpg", "width": 1200, "height": 628 }, "click_url": { "url": "https://acme-example.com/trail-running" } } }, "inputs": [ { "name": "Running community", "context_description": "Appears in r/trailrunning feed between user posts" }, { "name": "General feed", "context_description": "Appears in home feed between mixed content" } ] } ``` The `inputs` let the buyer see how the ad looks in different community contexts — the platform's rendering may change based on community theme, content density, or feed position. ## Community guidelines and creative review Social platforms enforce content policies beyond standard ad policy — community standards, category restrictions, and promoted content guidelines. These are surfaced through the standard creative review flow: * `rejection_reason` in `list_creatives` or `get_media_buys` explains which policy was violated * Community-specific rejections reference the community's rules, not just the platform's global policy * Re-submission after fixing the issue follows the same `sync_creatives` upsert pattern See [Creative review](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities#creative-review) for the full review flow. ## Engagement and interaction models Feed-native formats often support platform-specific interactions that go beyond click-through: | Interaction | How it maps to AdCP | | ------------------------ | -------------------------------------------------------------------- | | Like / upvote / reaction | Platform-tracked engagement, not an AdCP creative asset | | Comment / reply | Platform-managed, may appear in `get_creative_delivery` variant data | | Share / repost | Platform-tracked, included in delivery metrics | | Save / bookmark | Platform-tracked | | Click-through | Standard `click_url` asset | | Poll / quiz | Additional format assets (e.g., `poll_options` text array) | Platforms that expose engagement metrics include them in `get_creative_delivery` via the `ext` field on each variant, since engagement types vary by platform. ## Carousel and multi-card formats Many social platforms support carousel or multi-card promoted posts. These use the `asset_group` pattern: ```json theme={null} { "format_id": { "agent_url": "https://ads.socialplatform.example", "id": "promoted_carousel" }, "name": "Promoted carousel", "type": "native", "description": "Multi-card swipeable promoted content with 2-10 cards.", "assets": [ { "item_type": "group", "asset_id": "cards", "min_items": 2, "max_items": 10, "assets": [ { "item_type": "individual", "asset_id": "card_image", "asset_type": "image", "required": true, "requirements": { "min_width": 600, "min_height": 600, "aspect_ratio": "1:1" } }, { "item_type": "individual", "asset_id": "card_headline", "asset_type": "text", "required": true, "requirements": { "max_length": 100 } }, { "item_type": "individual", "asset_id": "card_click_url", "asset_type": "url", "required": true } ] }, { "item_type": "individual", "asset_id": "headline", "asset_type": "text", "required": true, "requirements": { "max_length": 300 } } ] } ``` See [Carousels](/dist/docs/3.1.20/creative/channels/carousels) for carousel-specific guidance on card ordering, aspect ratios, and swipe behavior. ## Generative feed-native Platforms with AI-powered ad generation can offer generative feed-native formats. The buyer provides a brief, and the platform generates feed-native content that matches the community's voice and visual style. This follows the [brief-in-media-buy](/dist/docs/3.1.20/creative/generative-creative#seller-side-generation-brief-in-media-buy) pattern. The key difference from standard generative creative: the platform has deep context about its community (trending topics, content style, audience behavior) that informs generation. A generative feed-native ad on a cooking community looks and sounds different from the same brief on a tech community. Preview with `context_description` inputs to see how the platform adapts the brief to different community contexts before launch. ## Related documentation * [Implementing creative agents — Pattern 4: feed-native](/dist/docs/3.1.20/creative/implementing-creative-agents#pattern-4-feed-nativesocial-format-agent) — Implementation guide for feed-native format agents * [Carousels](/dist/docs/3.1.20/creative/channels/carousels) — Multi-card format specifications * [Creative review](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities#creative-review) — Approval flow including community guidelines * [Generative creative](/dist/docs/3.1.20/creative/generative-creative) — AI-powered creative generation workflows # Video Ads Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/channels/video Video ad formats in AdCP cover hosted files, VAST tags, inline XML, and multi-resolution encoding for pre-roll, mid-roll, and post-roll ads. This guide covers how AdCP represents video advertising formats for online video, CTV, and streaming platforms. ## Video Format Characteristics Video formats include: * **Hosted Video** - Direct video file URLs served by publisher ad servers * **VAST Tags** - Third-party ad server URLs returning VAST/VPAID XML * **Inline VAST XML** - Complete VAST XML provided in creative manifest * **Multiple Resolutions** - Same creative in different encoding profiles Video ads play before (pre-roll), during (mid-roll), or after (post-roll) video content, or in-feed as out-stream video. ## Standard Video Formats ### Horizontal Video by Duration #### 15-Second Video ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" }, "name": "Standard Video - 15 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 15000, "max_duration_ms": 15000, "containers": ["mp4"], "codecs": ["h264"], "min_width": 1280, "max_width": 1920, "min_height": 720, "max_height": 1080, "max_file_size_kb": 30720, "min_bitrate_kbps": 4000, "max_bitrate_kbps": 10000, "audio_codecs": ["aac"] } } ] } ``` #### 30-Second Video ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, "name": "Standard Video - 30 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 30000, "max_duration_ms": 30000, "containers": ["mp4"], "codecs": ["h264"], "min_width": 1280, "max_width": 1920, "min_height": 720, "max_height": 1080, "max_file_size_kb": 51200, "min_bitrate_kbps": 4000, "max_bitrate_kbps": 10000, "audio_codecs": ["aac"] } } ] } ``` #### 6-Second Bumper ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_6s" }, "name": "6-Second Bumper", "assets": [ { "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 6000, "max_duration_ms": 6000, "containers": ["mp4"], "codecs": ["h264"], "min_width": 1280, "max_width": 1920, "min_height": 720, "max_height": 1080, "max_file_size_kb": 15360, "min_bitrate_kbps": 4000, "max_bitrate_kbps": 10000 } } ] } ``` ### Vertical/Mobile Video ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_vertical_15s" }, "name": "Vertical Video - 15 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 15000, "max_duration_ms": 15000, "aspect_ratio": "9:16", "min_width": 1080, "max_width": 1080, "min_height": 1920, "max_height": 1920, "containers": ["mp4"], "codecs": ["h264"], "max_file_size_kb": 30720 } } ] } ``` ### CTV/OTT Video CTV platforms have strict technical requirements that differ significantly from web video. Creative agents must produce assets that precisely match these specifications to avoid rejection. #### Standard CTV Video (30s) This format represents common CTV requirements across most platforms: ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_ctv" }, "name": "CTV Video - 30 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 30000, "max_duration_ms": 30000, "containers": ["mp4", "mov"], "codecs": ["h264"], "min_width": 1920, "max_width": 1920, "min_height": 1080, "max_height": 1080, "aspect_ratio": "16:9", "min_bitrate_kbps": 6000, "max_bitrate_kbps": 15000, "max_file_size_kb": 512000, "frame_rates": [23.976, 24, 25, 29.97, 30, 59.94, 60], "frame_rate_type": "constant", "scan_type": "progressive", "min_gop_interval_seconds": 1, "max_gop_interval_seconds": 2, "gop_type": "closed", "moov_atom_position": "start", "audio_required": true, "audio_codecs": ["aac", "pcm"], "audio_sample_rates": [48000], "audio_channels": ["stereo"], "loudness_lufs": -24, "loudness_tolerance_db": 2, "true_peak_dbfs": -2, "ext": { "ctv_profile": { "color_space": "rec709", "hdr_format": "sdr", "chroma_subsampling": ["4:2:0"], "video_bit_depth": [8], "audio_bit_depth": [16, 24], "audio_bitrate_kbps_min": 192 } } } } ] } ``` #### Platform-Specific CTV Examples Different CTV platforms have varying requirements. Sales agents should define formats matching their specific platform needs. **Roku-Compliant Format:** ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://sales.example.com", "id": "video_30s_roku" }, "name": "Roku CTV - 30 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 30000, "max_duration_ms": 30000, "containers": ["mp4", "mov"], "codecs": ["h264", "prores"], "min_width": 1920, "max_width": 1920, "min_height": 1080, "max_height": 1080, "min_bitrate_kbps": 6000, "frame_rates": [23.976, 25, 29.97], "frame_rate_type": "constant", "scan_type": "progressive", "audio_codecs": ["pcm", "aac"], "audio_sample_rates": [48000], "audio_channels": ["stereo"], "loudness_lufs": -23, "loudness_tolerance_db": 2, "ext": { "roku_profile": { "audio_bit_depth": [16, 24], "audio_bitrate_kbps_min": 192 } } } } ] } ``` **Hulu-Compliant Format:** ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://sales.example.com", "id": "video_30s_hulu" }, "name": "Hulu CTV - 30 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 30000, "max_duration_ms": 30000, "containers": ["mp4", "mov"], "codecs": ["h264", "prores"], "min_width": 1280, "max_width": 1920, "min_height": 720, "max_height": 1080, "min_bitrate_kbps": 10000, "max_bitrate_kbps": 40000, "max_file_size_kb": 10485760, "frame_rates": [23.976, 24, 25, 29.97, 30], "frame_rate_type": "constant", "scan_type": "progressive", "audio_codecs": ["pcm", "aac"], "audio_sample_rates": [48000], "audio_channels": ["stereo"], "loudness_lufs": -24, "loudness_tolerance_db": 2, "ext": { "hulu_profile": { "chroma_subsampling": ["4:2:0", "4:2:2"], "audio_bit_depth": [16, 24], "audio_bitrate_kbps_min": 192, "audio_bitrate_kbps_max": 256 } } } } ] } ``` **YouTube CTV Format:** ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://sales.example.com", "id": "video_30s_youtube_ctv" }, "name": "YouTube CTV - 30 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 30000, "max_duration_ms": 30000, "containers": ["mp4"], "codecs": ["h264"], "min_width": 1280, "max_width": 1920, "min_height": 720, "max_height": 1080, "frame_rates": [24, 25, 30, 48, 50, 60], "scan_type": "progressive", "moov_atom_position": "start", "audio_codecs": ["aac"], "audio_sample_rates": [48000], "audio_channels": ["stereo"], "loudness_lufs": -14, "ext": { "youtube_profile": { "audio_bitrate_kbps_min": 128 } } } } ] } ``` #### SSAI-Ready CTV Format For server-side ad insertion (SSAI) compatibility, GOP structure is critical: ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://sales.example.com", "id": "video_30s_ssai" }, "name": "SSAI Video - 30 seconds", "assets": [ { "asset_id": "video_file", "asset_type": "video", "item_type": "individual", "required": true, "requirements": { "min_duration_ms": 30000, "max_duration_ms": 30000, "containers": ["mp4"], "codecs": ["h264"], "min_width": 1920, "max_width": 1920, "min_height": 1080, "max_height": 1080, "min_bitrate_kbps": 15000, "frame_rates": [29.97, 30], "frame_rate_type": "constant", "scan_type": "progressive", "min_gop_interval_seconds": 1, "max_gop_interval_seconds": 2, "gop_type": "closed", "moov_atom_position": "start", "audio_required": true, "audio_codecs": ["aac"], "audio_sample_rates": [48000], "audio_channels": ["stereo"] } } ] } ``` ### VAST Tag Formats For third-party ad servers: ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_vast" }, "name": "VAST Tag - 30 seconds", "assets": [ { "asset_id": "vast_tag", "asset_type": "vast", "item_type": "individual", "required": true, "requirements": { "vast_version": "4.2" } } ] } ``` ### VPAID Interactive Video ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_vpaid" }, "name": "VPAID Interactive - 30 seconds", "assets": [ { "asset_id": "vpaid_tag", "asset_type": "vast", "item_type": "individual", "required": true, "requirements": { "vast_version": "4.2", "ext": { "vpaid": { "vpaid_version": ["2.0"], "api_framework": "VPAID", "vpaid_enabled": true } } } } ] } ``` ## Creative Manifests ### Hosted Video Manifest ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, "assets": { "video_file": { "asset_type": "video", "url": "https://cdn.brand.com/spring_30s.mp4", "duration_ms": 30000, "width": 1920, "height": 1080, "container_format": "mp4", "video_codec": "h264", "video_bitrate_kbps": 8000 }, "impression_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/imp" }, "landing_url": { "asset_type": "url", "url_type": "clickthrough", "url": "https://brand.example/spring-sale" } } } ``` ### VAST Tag Manifest (URL Delivery) ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_vast" }, "assets": { "vast_tag": { "asset_type": "vast", "delivery_type": "url", "url": "https://adserver.brand.example/vast", "vast_version": "4.2" } } } ``` ### Inline VAST XML Manifest ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_vast" }, "assets": { "vast_xml": { "asset_type": "vast", "delivery_type": "inline", "content": "\n\n \n \n \n \n \n \n 00:00:30\n \n \n \n \n \n \n \n \n \n \n \n \n \n", "vast_version": "4.2" } } } ``` ### Multi-Resolution Manifest ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, "assets": { "video_1080p": { "asset_type": "video", "url": "https://cdn.brand.com/spring_30s_1080p.mp4", "duration_ms": 30000, "width": 1920, "height": 1080, "video_bitrate_kbps": 8000 }, "video_720p": { "asset_type": "video", "url": "https://cdn.brand.com/spring_30s_720p.mp4", "duration_ms": 30000, "width": 1280, "height": 720, "video_bitrate_kbps": 5000 }, "video_480p": { "asset_type": "video", "url": "https://cdn.brand.com/spring_30s_480p.mp4", "duration_ms": 30000, "width": 854, "height": 480, "video_bitrate_kbps": 2500 } } } ``` ## Video-Specific Macros In addition to [universal macros](/dist/docs/3.1.20/creative/universal-macros), video formats support: ### Video Content Context * `{VIDEO_ID}` - Content video identifier * `{VIDEO_TITLE}` - Content video title * `{VIDEO_DURATION}` - Content duration in seconds * `{VIDEO_CATEGORY}` - IAB content category * `{CONTENT_GENRE}` - Content genre (news, sports, comedy) * `{CONTENT_RATING}` - Content rating (G, PG, TV-14, etc.) * `{PLAYER_WIDTH}` / `{PLAYER_HEIGHT}` - Video player dimensions in pixels ### Ad Pod Position * `{POD_POSITION}` - Position within ad break (1, 2, 3, etc.) * `{POD_SIZE}` - Total ads in this break * `{AD_BREAK_ID}` - Unique ad break identifier ### Playback Context * `{PLAYBACK_METHOD}` - auto-play-sound-on, auto-play-sound-off, click-to-play * `{PLAYER_SIZE}` - small, medium, large, fullscreen * `{VIDEO_PLACEMENT}` - in-stream, in-banner, in-article, in-feed, interstitial ### VAST Macros AdCP macros (`{CURLY_BRACES}`) work alongside [IAB VAST 4.x macros](http://interactiveadvertisingbureau.github.io/vast/vast4macros/vast4-macros-latest.html) (`[SQUARE_BRACKETS]`): * `[CACHEBUSTING]` - Random number for cache prevention * `[TIMESTAMP]` - Unix timestamp * `[DOMAIN]` - Publisher domain * `[IFA]` - Device advertising ID (IDFA/AAID) * `[REGULATIONS]` - Privacy regulation signals (GDPR, CCPA) * `[DEVICEUA]` - Device user agent string **Example mixing both macro formats:** ``` https://track.brand.com/imp? buy={MEDIA_BUY_ID}& video={VIDEO_ID}& device=[IFA]& domain=[DOMAIN]& cb=[CACHEBUSTING] ``` ## Video Tracking Assets ### Standard Tracking Events ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" }, "assets": { "video_file": { "asset_type": "video", "url": "https://cdn.brand.com/video_30s.mp4", "width": 1920, "height": 1080 }, "impression_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/imp" }, "start_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/start" }, "quartile_25_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/q25" }, "quartile_50_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/q50" }, "quartile_75_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/q75" }, "complete_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/complete" }, "click_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/click" } } } ``` ### Interactive Tracking Events For formats supporting user interaction: ```json theme={null} { "pause_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/pause" }, "resume_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/resume" }, "skip_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/skip" }, "mute_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/mute" }, "unmute_tracker": { "asset_type": "url", "url_type": "tracker_pixel", "url": "https://track.brand.example/unmute" } } ``` ## Common Aspect Ratios * **16:9** (1920x1080, 1280x720) - Standard horizontal video * **9:16** (1080x1920) - Vertical mobile video * **4:3** (640x480) - Legacy format, rare * **1:1** (1080x1080) - Square social video ## Video Placement Types ### Pre-Roll Video ad plays before content starts. Most common placement. **Common durations:** 6s, 15s, 30s ### Mid-Roll Video ad plays during content breaks. Uses ad pod macros for position tracking. **Common durations:** 15s, 30s ### Post-Roll Video ad plays after content ends. **Common durations:** 15s, 30s ### Out-Stream Video ad plays in-feed or in-article, not in a video player. **Common formats:** Vertical mobile video, in-feed video ## VAST/VPAID Integration ### VAST Versions AdCP supports all VAST versions: * **VAST 2.0** - Legacy support * **VAST 3.0** - Adds verification and error handling * **VAST 4.0** - Improved tracking, viewability * **VAST 4.1** - Enhanced ad pod support * **VAST 4.2** - Latest specification (recommended) ### VPAID Support VPAID (Video Player Ad-Serving Interface Definition) enables interactive video ads: ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_vpaid" }, "name": "VPAID Interactive - 30 seconds", "assets": [ { "asset_id": "vpaid_tag", "asset_type": "vast", "item_type": "individual", "required": true, "requirements": { "vast_version": "4.2", "ext": { "vpaid": { "vpaid_version": ["2.0"], "api_framework": "VPAID", "vpaid_enabled": true } } } } ] } ``` ## File Specifications ### Video Codecs * **H.264** - Most widely supported, required for CTV * **H.265/HEVC** - Better compression, growing CTV support * **ProRes** - High quality mezzanine, accepted by premium CTV * **VP8/VP9** - Open codec, web-focused * **AV1** - Next-gen open codec, emerging support ### Audio Codecs * **AAC/AAC-LC** - Standard for MP4, widely supported * **HE-AAC** - High-efficiency AAC for lower bitrates * **PCM** - Uncompressed, preferred by some CTV platforms * **AC-3/E-AC-3** - Dolby Digital, used in broadcast ### Container Formats * **MP4** - Industry standard, required for most platforms * **MOV** - QuickTime format, accepted by premium CTV * **WebM** - Open format, web-focused ### Video Bitrate Ranges * **Premium CTV (mezzanine):** 15-50 Mbps * **Standard CTV:** 6-15 Mbps * **High Quality Web (1080p):** 8-10 Mbps * **Standard Quality (720p):** 4-6 Mbps * **Mobile Optimized (480p):** 2-3 Mbps ### Frame Rates * **Film:** 23.976 fps, 24 fps * **PAL:** 25 fps * **NTSC:** 29.97 fps, 30 fps * **High frame rate:** 48 fps, 50 fps, 60 fps CTV platforms require constant frame rate (CFR). Variable frame rate (VFR) will be rejected. ### Common Resolutions **16:9 Landscape:** * 1920x1080 (1080p Full HD) - Standard CTV * 1280x720 (720p HD) * 854x480 (480p SD) * 3840x2160 (4K UHD) - Premium CTV **9:16 Portrait:** * 1080x1920 (Mobile vertical) **1:1 Square:** * 1080x1080 (Social video) ### Scan Type CTV universally requires **progressive scan**. Interlaced content will be rejected. ### Color Space * **Rec.709** - Standard for HD/SDR content (required by most CTV) * **Rec.2020** - UHD/4K content * **Rec.2100** - HDR content (HDR10, HLG) * **sRGB** - Web content ### Chroma Subsampling * **4:2:0** - Standard for delivery * **4:2:2** - Broadcast/mezzanine quality ### Video Bit Depth * **8-bit** - Standard SDR * **10-bit** - HDR and premium SDR * **12-bit** - Professional HDR ### GOP Structure (SSAI Critical) For server-side ad insertion compatibility: * **Keyframe interval:** 1-2 seconds * **GOP type:** Closed GOP required * **moov atom:** Must be at file start for progressive download ## Audio Specifications ### Sampling Rate * **48 kHz** - Required for CTV (Roku, Hulu, Snapchat mandate this) * **44.1 kHz** - CD quality, accepted by some platforms * **96 kHz** - High resolution, accepted but not required ### Channel Configuration * **Stereo (2 channels)** - Required for CTV ads * **Mono** - Acceptable for some web/mobile * **5.1/7.1** - Not supported for CTV ads ### Audio Bit Depth * **16-bit** - Standard * **24-bit** - High quality, accepted by premium CTV ### Audio Bitrate * **CTV minimum:** 192 kbps * **Standard web:** 128 kbps * **High quality:** 256 kbps ### Loudness Standards Different platforms normalize to different targets: | Platform | Target LUFS | Tolerance | Standard | | ------------- | ----------- | --------- | --------- | | Broadcast/CTV | -24 LUFS | ±2 dB | ATSC A/85 | | Spotify | -16 LUFS | ±1.5 dB | - | | YouTube | -14 LUFS | - | - | **True Peak:** Should not exceed -1 to -2 dBFS to prevent clipping ## Related Documentation * [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) - Complete macro reference including video macros * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - Manifest structure and asset specifications * [Asset Types](/dist/docs/3.1.20/creative/asset-types) - Video asset type definitions # Creative libraries and concepts Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/creative-libraries Creative libraries in AdCP let buyers browse, organize, and track approval status for creatives across ad servers and management platforms. Creative libraries let buyers manage creatives through AdCP — browsing existing assets, uploading new ones, assigning them to campaigns, and tracking approval status. A creative library is hosted by any agent that declares `has_creative_library: true` in its capabilities: ad servers (CM360, Flashtalking), creative management platforms (Celtra), or sales agents that also expose the Creative Protocol. ## The model A creative library is an account-scoped collection of creative resources with stable `creative_id`s, observable lifecycle state, and assignment relationships that are separate from any one media buy or package. The implementation can be a full ad-server library or a thin view over seller storage, but the protocol commitment is the same: buyers can read the creative resource through `list_creatives`, update it through `sync_creatives`, and reference it by `creative_id` across assignments while the seller retains it. Inline package creatives from a seller that does not advertise a creative library are not library creatives. They are package-scoped creative attachments: the seller accepts and serves them as part of the media buy, but does not advertise independent library management or cross-buy reuse. A creative library organizes assets at three levels: | Level | AdCP equivalent | Examples | | -------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------ | | Account | Account (via [accounts protocol](/dist/docs/3.1.20/accounts/overview)) | Advertiser in CM360, brand workspace in Celtra | | Concept | `concept_id` / `concept_name` | Flashtalking concept, CM360 creative group, Celtra campaign folder | | Creative | Creative item with `creative_id`, `format_id`, `assets` | A 300x250 display ad, a 30s video spot | **Concepts** group related creatives across sizes and formats. A "Holiday 2026" concept might contain a 300x250 banner, a 728x90 leaderboard, and a 30s video — all expressing the same campaign idea. Use `concept_id` to filter and manage them as a group. ### Creative state and assignment state are separate Two things the library tracks independently: * **Creative state** — the review status of the creative itself: `processing`, `pending_review`, `approved`, `rejected`, or `archived`. Set by the creative agent's review workflow. Applies to the creative as a library asset, regardless of where it is used. * **Assignment state** — the relationship between a creative and a package on a specific media buy. Created when the buyer assigns the creative (via `sync_creatives`, `creative_assignments`, or inline creatives on `create_media_buy`). Released when the media buy or package is rejected, canceled, or completed, or when the buyer removes the assignment. These lifecycles are tracked independently: * A creative in the library has **zero or more** active assignments at any time. * Rejecting, canceling, or completing a media buy releases its assignments. It does not change the creative's review state, remove the creative from the library, or affect the creative's use in other media buys. * When a creative's review state changes after assignments exist (e.g., a seller revokes an approval, or approves a previously rejected creative), sellers MAY continue or stop in-flight serving based on the new state. Buyers SHOULD re-fetch `approval_status` per package via [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) after a creative-state change to detect assignment-level impact. See [creative review](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities#creative-review). Buyer agents reusing a library creative on a new media buy check **creative state** to know whether the asset is usable, and **assignment state** to know where it is currently in flight. ### Creatives outlast campaigns A creative MUST persist in the library independently of the buys that reference it. Buy rejection, cancellation, or completion releases assignments only — the creative remains in the library at its current `status` and may be reused on subsequent buys. This holds regardless of how the creative entered the library: explicit [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives), library-backed inline creative on [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy), or platform-native upload exposed through AdCP. Sellers whose underlying ad server has no library object distinct from per-buy attachment satisfy this rule by exposing the buyer-synced creative through `list_creatives` for the buy's lifetime and continuing to expose its terminal state (including `archived`) after teardown — the library can be a thin view over per-buy storage; it does not need to be a separate store. Retention beyond active assignments is seller-defined. An agent MAY archive an unassigned creative due to inactivity, post-flight expiry, or storage policy (see [creative status lifecycle](/dist/docs/3.1.20/creative/specification#creative-status-lifecycle)), MAY transition `approved` → `suspended` for recoverable dependency loss such as a published-post authorization expiring, and MAY transition `approved` → `rejected` for policy revocation, takedown, or content drift. Whenever a creative's state changes after it has been synced by a buyer, the seller MUST make the new state observable so the buyer can resync, replace, or stop relying on the asset before reuse: * For state changes affecting an active media buy (e.g., `approved` → `suspended` or `approved` → `rejected` on a creative with live assignments), sellers MUST surface a corresponding `impairment` on the buy. See [media buy health](/dist/docs/3.1.20/media-buy/media-buys/lifecycle#health-and-dependency-impairment). * For state changes on creatives with no active assignments (e.g., seller archives an unassigned creative for inactivity), sellers MUST reflect the new `status` on the next [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) read — that snapshot is the conformant signal today, per the [snapshot-and-log contract](/dist/docs/3.1.20/protocol/snapshot-and-log). A push channel for account-scoped creative state changes is being defined under the [creative lifecycle webhooks RFC](https://github.com/adcontextprotocol/adcp/issues/2261); when that channel ships, sellers SHOULD additionally fire on it. Buyers SHOULD confirm availability via [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) before reuse on a new buy. A library creative is the bundle of inputs the buyer supplied — uploaded assets, a brief, brand and catalog pointers, or any combination. Retention applies to the bundle; whether the format's rendered outputs are individually addressable is a format-level concern and is independent of library retention. ## Connecting to a library Establish account access before querying: ```json theme={null} { "accounts": [{ "account_id": "acct_acme_2026", "account_name": "Acme Corp", "credentials": { "api_key": "..." } }] } ``` The account setup is the same whether the library is on a standalone creative agent or a sales agent. See [accounts protocol](/dist/docs/3.1.20/accounts/overview) for details. ## Browsing creatives Use [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) to browse the library. Filter by concept, format, status, tags, or date range: ```json theme={null} { "filters": { "concept_ids": ["concept_holiday_2026"], "statuses": ["approved"], "format_ids": [{ "agent_url": "https://ads.flashtalking-example.com", "id": "display_300x250" }] }, "include": { "variables": true, "assignments": true } } ``` Each creative in the response includes: ```json theme={null} { "creative_id": "ft_88201", "name": "Holiday 2026 - Medium Rectangle", "format_id": { "agent_url": "https://ads.flashtalking-example.com", "id": "display_300x250" }, "status": "approved", "concept_id": "concept_holiday_2026", "concept_name": "Holiday 2026 Campaign", "created_date": "2026-10-15T14:00:00Z", "updated_date": "2026-11-20T09:30:00Z", "tags": ["holiday_2026", "display"], "variables": [ { "variable_id": "headline", "name": "Headline text", "type": "text", "default_value": "Holiday Sale — Up to 40% Off" } ], "assignments": [ { "package_id": "pkg_premium_display", "weight": 100 } ] } ``` The `status` field reflects the creative's current state in the library: `processing`, `pending_review`, `approved`, `rejected`, or `archived`. See [creative review](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities#creative-review) for how status transitions work. ## Uploading creatives Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) to upload new creatives or update existing ones. The operation uses upsert semantics — if a `creative_id` already exists, it updates; otherwise it creates. ```json theme={null} { "creatives": [ { "creative_id": "acme_video_001", "name": "Holiday Sale 30s", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_30s" }, "assets": { "video": { "url": "https://cdn.acme-example.com/holiday-sale-30s.mp4", "width": 1920, "height": 1080, "duration_ms": 30000 }, "click_url": { "url": "https://acme-example.com/holiday-sale" } } } ] } ``` The response tells you what happened to each creative: ```json theme={null} { "creatives": [ { "creative_id": "acme_video_001", "action": "created" } ] } ``` After upload, the creative enters the library's review process. Check `list_creatives` to see when it transitions from `pending_review` to `approved`. ### Uploading with assignments Assign creatives to packages in the same call: ```json theme={null} { "creatives": [ { "creative_id": "acme_video_001", "name": "Holiday Sale 30s", "format_id": { "agent_url": "...", "id": "video_standard_30s" }, "assets": { "...": "..." } } ], "assignments": [ { "creative_id": "acme_video_001", "package_id": "pkg_premium_video" } ] } ``` ## Generating tags from library creatives When you need a serving tag for a creative in the library, use [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) with `creative_id` instead of a manifest: ```json theme={null} { "creative_id": "ft_88201", "concept_id": "concept_holiday_2026", "target_format_id": { "agent_url": "https://ads.flashtalking-example.com", "id": "display_300x250" } } ``` The creative agent resolves the `creative_id` from its library and returns a manifest with the serving tag. The tag format depends on the platform: * **Flashtalking, Celtra**: Universal tags that adapt to any environment. No placement context needed. * **CM360**: Placement-level tags that require trafficking context. Pass `media_buy_id` and `package_id`: ```json theme={null} { "creative_id": "cm360_creative_12345", "target_format_id": { "agent_url": "https://ads.cm360-example.com", "id": "display_300x250" }, "media_buy_id": "buy_holiday_q4", "package_id": "pkg_premium_display" } ``` See [tag generation models](/dist/docs/3.1.20/creative/implementing-creative-agents#tag-generation-models) for the full breakdown. ## Assigning creatives to campaigns There are two paths for attaching library creatives to a media buy: ### Path 1: Creative assignments on the package Reference library creatives by ID when creating the media buy: ```json theme={null} { "packages": [{ "product_id": "premium_display", "creative_assignments": [ { "creative_id": "ft_88201", "weight": 60 }, { "creative_id": "ft_88202", "weight": 40 } ] }] } ``` This works when the creative is already in the agent's library (via `sync_creatives` or the platform's own upload flow). ### Path 2: Inline creatives on the package Upload the creative directly with the media buy — no separate sync step: ```json theme={null} { "packages": [{ "product_id": "premium_display", "creatives": [{ "creative_id": "acme_banner_001", "name": "Holiday banner", "format_id": { "agent_url": "...", "id": "display_300x250" }, "assets": { "...": "..." } }] }] } ``` For sellers that also advertise `creative.has_creative_library: true`, the agent adds the creative to its library and assigns it to the package in one operation. For inline-only sellers that do not advertise a creative library, the same `creatives` payload assigns package-scoped assets without creating a reusable library entry. See [inline creative management](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities) for details. **Library-backed inline creatives follow the same library lifecycle as `sync_creatives` uploads.** When the seller advertises `creative.has_creative_library: true`, the inline form is a convenience — "sync and assign in one call" — not a separate lifecycle. Once submitted, the creative enters the library with the same review flow, retention, and identifiers it would have under `sync_creatives`. If the `create_media_buy` task resolves as `pending_manual` and the buy never activates, or if the buy is rejected or canceled, only the package assignments are released; the creatives remain in the library and may be referenced by `creative_id` in a subsequent `create_media_buy` call. This assignment-release behavior is normative on the media-buy side — see the [Media Buy State Transitions](/dist/docs/3.1.20/media-buy/specification#media-buy-state-transitions) rule. For inline-only sellers, buyers should treat the creative body as attached to the media-buy package. The seller may retain enough package-scoped creative state to review, serve, replace, and audit the buy, but it has not advertised reusable library operations such as `list_creatives`, `sync_creatives`, or later `creative_assignments` reuse. Creative review proceeds independently of the media buy outcome. Sellers MUST NOT skip review solely because the buy did not activate, and a buy rejection does not by itself imply rejection of the submitted creatives — a creative rejection MUST be a deliberate review decision with its own `rejection_reason`, not implicit from the containing buy's status. Sellers MAY deprioritize review of creatives that currently have no active assignments, provided review completes before any future assignment activates. **Capability-flag scope.** `inline_creative_management: true` advertises that the sales agent accepts inline creatives on `create_media_buy` and `update_media_buy`; it does not by itself advertise a creative library. The decoupled library lifecycle applies when the seller also advertises `creative.has_creative_library: true`. ### Multi-seller distribution When you work with multiple sellers, build creatives once and distribute them: 1. **Build** on your creative agent: ```json theme={null} { "message": "Create a holiday promotion banner", "target_format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90" } ] } ``` 2. **Sync** to each seller's library: ```json theme={null} { "creatives": [ { "creative_id": "holiday_2026_300x250", "name": "Holiday 2026 - Medium Rectangle", "concept_id": "concept_holiday_2026", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { } } ] } ``` Call `sync_creatives` on each sales agent separately. Use the same `creative_id` and `concept_id` across sellers so you can correlate the same creative and campaign concept across your media buys. See [Multi-agent creative orchestration](/dist/docs/3.1.20/creative/multi-agent-orchestration) for the full pattern. 3. **Track approval** per seller — each seller reviews independently. Poll `list_creatives` on each agent to check status. A creative may be `approved` on one seller and `rejected` on another based on their policies. ## Tracking approval status Creative approval operates at two levels: **Library level**: The `status` field on each creative in [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) — `processing`, `pending_review`, `approved`, `rejected`, or `archived`. **Package level**: The `approval_status` on each creative in [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) — `pending_review`, `approved`, or `rejected` with `rejection_reason`. A creative can be `approved` in the library but `rejected` at the package level if it violates placement-specific policies. Poll both after syncing or submitting a media buy with new creatives. ## Dynamic creative optimization (DCO) Creatives with dynamic content variables appear in the library with a `variables` array when you request `include: { variables: true }`. Each variable defines a slot that the ad server fills at serve time: ```json theme={null} { "variables": [ { "variable_id": "headline", "name": "Headline text", "type": "text", "default_value": "Holiday Sale — Up to 40% Off" }, { "variable_id": "product_image", "name": "Product image", "type": "image", "default_value": "https://cdn.acme-example.com/hero.jpg" }, { "variable_id": "cta_color", "name": "CTA button color", "type": "color", "default_value": "#FF6600" } ] } ``` Use `has_variables: true` in `list_creatives` filters to find DCO creatives. Variable types match common platform patterns: `text`, `color`, `image`, `video`, `number`, `boolean`. How the ad server uses these variables at serve time (data feeds, targeting rules, optimization algorithms) is outside AdCP's scope. AdCP models the variable *slots*, not the optimization logic. ## Next steps * [Generative creative](/dist/docs/3.1.20/creative/generative-creative) — AI-powered creative generation and brief-in-media-buy workflows * [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities) — When the seller manages both media and creative * [Implementing creative agents](/dist/docs/3.1.20/creative/implementing-creative-agents) — Building an AdCP creative agent around your platform * [sync\_creatives reference](/dist/docs/3.1.20/creative/task-reference/sync_creatives) — Upload API details * [list\_creatives reference](/dist/docs/3.1.20/creative/task-reference/list_creatives) — Query API details with full filtering options # Creative Manifests Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/creative-manifests Creative manifests in AdCP are reusable presets that supply concrete asset values to satisfy format requirements for programmatic ad delivery. Creative manifests define a creative preset: a reusable configuration of brand content that can be instantiated in a specific creative format. Three columns showing how a format defines required slots, a manifest fills them with concrete values, and the result is a rendered creative A manifest does not define structure, layout, or rendering behavior. Instead, it supplies concrete asset values that satisfy the requirements defined by a creative format. When combined with a format definition, a manifest produces a renderable creative instance. For an overview of how formats, manifests, and creative agents work together, see the [Creative Protocol Overview](/dist/docs/3.1.20/creative). ## Manifest Structure ### Basic Structure ```typescript theme={null} { format_id: { // Format this manifest is for agent_url: string; // Creative agent URL id: string; // Format identifier }; assets: { [asset_id: string]: { // Keyed by asset_id from format's assets // Asset type is determined by format specification, not declared here // Type-specific fields depend on asset_type defined in format's assets // Image: url, width, height, format, alt_text // Video: url, width, height, duration_ms, format, bitrate_kbps // Audio: url, duration_ms, format, bitrate_kbps // Text: content, language // Markdown: content, flavor // URL: url, description // HTML: content or url, version // CSS: content, media // JavaScript: content, module_type // VAST: url or content, vast_version, vpaid_enabled, duration_ms, tracking_events // DAAST: url or content, daast_version, duration_ms, tracking_events, companion_ads // Webhook: url, method, timeout_ms, response_type, security, supported_macros, required_macros // Brief: name, objective, tone, audience, messaging, compliance // Catalog: type, catalog_id, items, selectors } }; } ``` ### Asset IDs Each asset in a manifest is keyed by its `asset_id`, which must match an `asset_id` defined in the format's `assets` array. The asset ID serves as the technical identifier for referencing the asset requirement in the format specification. **How Asset IDs Work**: When a format defines required assets: ```json theme={null} { "assets": [ { "asset_id": "banner_image", "asset_type": "image", "required": true }, { "asset_id": "clickthrough_url", "asset_type": "url", "required": true } ] } ``` Your manifest **must use those exact asset IDs** as keys: ```json theme={null} { "assets": { "banner_image": { // ← Matches asset_id from format "url": "https://cdn.example.com/banner.jpg", "width": 300, "height": 250 }, "clickthrough_url": { // ← Matches asset_id from format "url": "https://example.com/landing" } } } ``` **Common Asset IDs** (vary by format): * `banner_image`, `hero_image`: Primary visual assets * `logo`: Brand logo * `headline`, `description`: Text content * `cta_text`: Call-to-action button text * `video_file`: Video content * `vast_tag`: VAST XML for video delivery * `clickthrough_url`: Landing page URL Always check the specific format's `assets` to see which asset IDs are required. ### Complete Example Here's a complete creative manifest showing the current structure without redundant fields: ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.example.com/banner.jpg", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "Nutrition Dogs Love" }, "description": { "asset_type": "text", "content": "Made with real chicken and wholesome grains" }, "clickthrough_url": { "asset_type": "url", "url": "https://acmecorp.example.com/products/premium-dog-food?campaign={MEDIA_BUY_ID}", "description": "Product landing page" } } } ``` **Note**: Each asset carries an `asset_type` discriminator (`image`, `text`, `url`, etc. — see [asset types](/dist/docs/3.1.20/creative/asset-types)). This lets validators select the matching asset schema and report errors against only the chosen branch instead of all 14. The format specification also declares the expected `asset_type` for each `asset_id`; manifest and format should agree. ## Types of Creative Manifests Creative manifests can be static, dynamic, or hybrid - reflecting the three creative agent modalities above. ### Static Manifests Static manifests contain all assets ready for immediate rendering. These are produced by creative agents in **Static Asset Delivery** or **Prompt to Static Rendering** modes. ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "native_responsive" }, "assets": { "hero_image": { "asset_type": "image", "url": "https://cdn.example.com/hero.jpg", "width": 1200, "height": 627, "alt_text": "Product image" }, "logo": { "asset_type": "image", "url": "https://cdn.example.com/logo.png", "width": 100, "height": 100 }, "headline": { "asset_type": "text", "content": "Premium Quality You Can Trust" }, "description": { "asset_type": "text", "content": "Discover why veterinarians recommend our formula" }, "cta_text": { "asset_type": "text", "content": "Learn More" } } } ``` **Use Cases**: * Traditional display advertising * Pre-rendered video ads * Static native ads * Fixed creative campaigns ### Dynamic Manifests Dynamic manifests include endpoints or code for real-time generation. These are produced by creative agents in **Prompt to Dynamic Rendering** mode (DCO/Generative). ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_dynamic_300x250" }, "assets": { "dynamic_content": { "asset_type": "webhook", "url": "https://creative-agent.example.com/render/campaign-123", "method": "POST", "timeout_ms": 500, "supported_macros": ["WEATHER", "TIME", "DEVICE_TYPE", "COUNTRY"], "response_type": "html", "security": { "method": "hmac_sha256", "hmac_header": "X-Signature" } }, "fallback_image": { "asset_type": "image", "url": "https://cdn.example.com/fallback-300x250.jpg", "width": 300, "height": 250 } } } ``` **Use Cases**: * Weather-based creative * Time-of-day personalization * Product availability messaging * Real-time inventory updates **Note**: For client-side dynamic rendering, use `html` or `javascript` asset types with embedded tags instead of webhooks. **Dynamic manifests can mix asset types** - some assets may be static (images, videos) while others are dynamic (webhooks, tags with macros). For example, a video VAST tag with a static hero video but a personalized end card webhook. ### DOOH Manifests with Impression Tracking Digital Out-of-Home (DOOH) creatives use impression tracking just like other formats, but with venue-specific macros instead of device identifiers. ```json test=false theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "dooh_billboard_1920x1080" }, "assets": { "billboard_image": { "url": "https://cdn.example.com/billboard-1920x1080.jpg", "width": 1920, "height": 1080 }, "impression_tracker": { "url": "https://tracking.example.com/imp?screen={SCREEN_ID}&venue={VENUE_TYPE}&ts={PLAY_TIMESTAMP}&lat={VENUE_LAT}&lon={VENUE_LONG}", "url_type": "tracker_pixel" } } } ``` **DOOH-Specific Macros** (see [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) for complete list): * `{SCREEN_ID}` - Unique identifier for the physical screen * `{VENUE_TYPE}` - Venue category (airport, mall, transit, highway, retail) * `{VENUE_LAT}` / `{VENUE_LONG}` - Physical location coordinates * `{PLAY_TIMESTAMP}` - When creative displayed (Unix timestamp) * `{DWELL_TIME}` - Average viewer dwell time at this location The mechanics are identical to digital impression tracking - a URL fires when the ad displays. The difference is DOOH uses fixed venue context instead of dynamic device identifiers. ## Working with Manifests ### Creating Manifests Manifests are JSON structures that can be created in two ways: **1. Manual Assembly** Construct manifests directly by pairing format requirements with your assets: ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "native_responsive" }, "assets": { "hero_image": { "asset_type": "image", "url": "https://cdn.example.com/salmon-hero.jpg", "width": 1200, "height": 627 }, "headline": { "asset_type": "text", "content": "New Premium Salmon Formula" } } } ``` **2. AI-Generated (Optional)** Use `build_creative` on any agent implementing the Creative Protocol to generate manifests from natural language briefs. See [Generative Creative](/dist/docs/3.1.20/creative/generative-creative) for details. ### Validating Manifests Before using a manifest, validate it against format requirements: 1. **Format Compatibility**: Ensure `format_id` matches intended format 2. **Required Assets**: All required `asset_id` values from the format are present as keys in the manifest's `assets` object 3. **Asset Key Matching**: Each key in the manifest's `assets` object MUST match an `asset_id` from the format's `assets` array 4. **Asset Specifications**: Each asset meets format requirements (dimensions, file size, duration, etc.) 5. **Macro Support**: Dynamic manifests properly handle required macros **What happens with invalid asset keys?** * **Missing required asset\_id**: Creative agents MUST reject the manifest with an error listing which required assets are missing * **Unknown asset\_id**: Creative agents MUST reject manifests containing asset keys that don't match any `asset_id` in the format. This catches typos and incompatible formats immediately. * **Wrong asset\_type**: If an asset doesn't match the type requirement for that `asset_id` in the format specification, reject with a clear type mismatch error Creative agents that implement `build_creative` handle validation automatically. For manually constructed manifests, validate against the format specification returned by `list_creative_formats`. **Format-Aware Validation**: Creative manifest validation MUST be performed in the context of the format specification. The format defines what type each `asset_id` should be, eliminating any validation ambiguity. Asset type information is NOT included in the manifest itself - it's determined by looking up the `asset_id` in the format's `assets` array. #### Validation Flow When a creative agent receives a manifest for validation: 1. **Extract format\_id** from the manifest 2. **Fetch format specification** from the format registry (local or remote based on `agent_url`) 3. **For each asset key in `manifest.assets`:** * Look up the `asset_id` in `format.assets` * If not found → **reject** with error "Unknown asset\_id 'banner\_imag' not defined in format" * If found → determine the expected `asset_type` from the format requirement * Fetch the asset type schema (e.g., `/schemas/3.1.20/core/assets/image-asset.json`) * Validate the asset payload against that schema * Validate the asset meets any additional constraints in the format's `requirements` field 4. **Check all required assets are present** (where `required: true` in format spec) 5. **Validate type-specific constraints** (dimensions, file size, duration, etc.) The format specification is the single source of truth for what type each `asset_id` should be and what constraints apply. **Validation is runtime, not schema-time**: The JSON schema for creative manifests uses a flexible pattern (`^[a-z0-9_]+$`) for asset keys because the valid keys depend on which format you're using. Validation against the specific format's `assets` happens when you submit the manifest to a creative agent. ### Previewing Manifests Use the `preview_creative` task to see how a manifest will render: ```json theme={null} { "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "native_responsive" }, "assets": { "hero_image": { "url": "https://cdn.example.com/hero.jpg", "width": 1200, "height": 627 }, // ... other assets } }, "macro_values": { "CLICK_URL": "https://example.com/landing", "CACHE_BUSTER": "12345" } } ``` The creative agent returns preview URLs and renderings. ### Submitting Manifests Manifests are submitted to the creative library using `sync_creatives`, then referenced by ID in media buys: ```json theme={null} { "task": "sync_creatives", "parameters": { "creatives": [ { "creative_id": "native-salmon-v1", "name": "Salmon Special Native Ad", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "native_responsive" }, "manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "native_responsive" }, "assets": { "product_catalog": { "catalog_id": "salmon-products", "type": "product" }, "headline": { "content": "Fresh Pacific Salmon - 20% Off Today" }, "main_image": { "url": "https://cdn.example.com/salmon.jpg", "width": 1200, "height": 628 } } } } ] } } ``` Then reference in media buys by `creative_id`. Each manifest is for a single format. ## Macro Substitution in Manifests Manifests support macro placeholders for dynamic values. AdCP uses universal macros that work consistently across all publishers. See [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) for complete documentation on available macros, macro substitution process, and format-specific macro support. ## Best Practices ### For Creative Agents 1. **Complete Manifests**: Include all required assets for the format 2. **Validate Assets**: Ensure assets meet format specifications 3. **Provide Fallbacks**: Include fallback assets for dynamic creatives 4. **Document Macros**: Clearly specify which macros are used 5. **Version Assets**: Use versioned URLs for asset management ### For Publishers 1. **Validate on Receipt**: Check manifests against format requirements 2. **Cache Assets**: Pre-fetch and cache hosted assets 3. **Handle Failures**: Implement fallback rendering for dynamic manifests 4. **Support Macros**: Implement full Universal Macro support 5. **Provide Templates**: Offer rendering templates for custom formats ### For Buyers 1. **Validate Manifests**: Ensure manifests match format requirements (manually or via `build_creative`) 2. **Preview First**: Always preview manifests before submission 3. **Test Macros**: Verify macro substitution works as expected 4. **Optimize Assets**: Ensure assets are properly sized and compressed 5. **Organize Libraries**: Use creative libraries for asset management ## Advanced Topics ### Catalogs Manifests for catalog-driven formats (product carousels, dynamic product ads) include their catalogs as assets within the `assets` map. See [Catalogs in Creatives](/dist/docs/3.1.20/creative/catalogs#catalogs-in-creatives) for the workflow. ### Repeatable Asset Groups For carousel, slideshow, and multi-asset formats, see the [Carousel & Multi-Asset Formats](/dist/docs/3.1.20/creative/channels/carousels) guide for complete documentation on repeatable asset groups. ## Schema Reference * [Creative Manifest Schema](https://adcontextprotocol.org/schemas/3.1.20/core/creative-manifest.json) * [Preview Creative Request](https://adcontextprotocol.org/schemas/3.1.20/creative/preview-creative-request.json) * [Preview Creative Response](https://adcontextprotocol.org/schemas/3.1.20/creative/preview-creative-response.json) ## Related Documentation * [Creative Formats](/dist/docs/3.1.20/creative/formats) - Understanding format specifications and discovery * [Channel Guides](/dist/docs/3.1.20/creative/channels/video) - Format examples across video, display, audio, DOOH, and carousels * [build\_creative Task](/dist/docs/3.1.20/creative/task-reference/build_creative) * [preview\_creative Task](/dist/docs/3.1.20/creative/task-reference/preview_creative) # Creative Formats Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/formats Creative formats in AdCP define asset requirements, technical constraints, and rendering behavior that shape how ads are built and delivered. > **Canonical formats in 3.1**: starting in 3.1, formats can be declared inline on products via `format_options` (an array of `ProductFormatDeclaration`s narrowing one of 12 canonical formats). This page describes the v1 format-registry model, which remains a first-class path through 4.x. For the canonical-formats model, see [canonical-formats](/dist/docs/3.1.20/creative/canonical-formats) and the [migration guide](/dist/docs/3.1.20/creative/canonical-formats-migration). Creative formats define the structural and technical requirements used to instantiate advertising creatives. A format specifies: * The asset types required (video, image, text, audio, etc.) via the `assets` array * Technical constraints for each asset (duration, dimensions, file types, limits) * How the resulting creative is delivered and validated Formats define requirements and constraints, not brand content or layout logic. For an overview of how formats, manifests, and creative agents work together, see the [Creative Protocol Overview](/dist/docs/3.1.20/creative). ## Standard vs Custom Formats AdCP supports two categories of formats based on authority and reuse, not capability. ### Standard Formats Standard formats are predefined specifications provided by the **AdCP Reference Creative Agent** (`https://creative.adcontextprotocol.org`). Standard formats are: * **Industry-aligned**: Based on IAB format families and widely adopted conventions * **Portable**: Designed to work consistently across platforms * **Validated**: Pre-tested against known technical constraints * **Discoverable**: Returned via `list_creative_formats` * **Maintained**: Centrally documented and updated Standard formats typically cover common use cases such as: * Display formats with fixed or responsive dimensions * Video formats with standard durations and aspect ratios * Audio formats with defined spot lengths * Common DOOH display and video executions **Guidance for sales agents:** Before defining custom formats, check whether an equivalent standard format already exists. Most publishers can reference standard formats for common inventory and reserve custom formats for genuinely differentiated offerings. See [Implementing Standard Format Support](/dist/docs/3.1.20/media-buy/capability-discovery/implementing-standard-formats) for detailed guidance. ### Custom Formats Custom formats are defined by publishers or platforms for inventory that cannot be accurately represented by standard formats. Custom formats may be used when there are: * **Unique constraints**: Non-standard dimensions, physical displays, or asset requirements * **Specialized capabilities**: Platform-specific rendering or interaction support * **Premium inventory**: Differentiated or bespoke ad products * **Custom validation logic**: Publisher-specific review or assembly rules Custom formats should be introduced only when necessary. Where possible, standard formats should be reused to maximize portability and buyer understanding. ## Discovering Formats Buyers discover supported formats using the `list_creative_formats` task exposed by sales agents. Formats may be sourced from: * **The sales agent itself** - for custom formats * **Referenced creative agents** - such as the AdCP reference agent, for standard formats **Example discovery response:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/creative/list-creative-formats-response.json", "status": "completed", "formats": [ { "format_id": { "agent_url": "https://youragent.com", "id": "homepage_takeover_2024" }, "name": "Homepage Takeover", "type": "rich_media" } ], "creative_agents": [ { "agent_url": "https://creative.adcontextprotocol.org" } ] } ``` This indicates that the sales agent supports its own custom format and all formats defined by the referenced creative agent. **For sales agents implementing format support:** See [Implementing Standard Format Support](/dist/docs/3.1.20/media-buy/capability-discovery/implementing-standard-formats). ## Format Authority Each format includes an `agent_url` that identifies the authoritative creative agent responsible for the format: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_hosted" }, "name": "Standard 30-Second Video" } ``` The authoritative creative agent provides: * Complete format specifications * Creative element requirements and constraints * Validation rules * Preview generation * Canonical documentation Buyers and sales agents rely on the `agent_url` to retrieve definitive format information. ## Format Visual Presentation Formats may optionally include metadata to support visual browsing and selection in user interfaces. ### Example Showcase **Field**: `example_url` **Purpose**: Deep link to a publisher-controlled showcase May include: * Interactive demos * Videos * Multiple creative examples * Best practices and specifications This approach allows publishers to present complex formats without constraining presentation in the protocol. **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://publisher.com", "id": "homepage_takeover_premium" }, "name": "Premium Homepage Takeover", "type": "rich_media", "description": "Full-screen immersive experience with video, carousel, and companion units", "example_url": "https://publisher.com/formats/homepage-takeover-demo" } ``` ## Referencing Formats For a normative contrast between `format_id` (structured reference object) and `format` (full definition object) — including the two most common validation errors — see [Format References](/dist/docs/3.1.20/protocol/format-references). AdCP uses structured format identifiers everywhere to avoid ambiguity and namespace collisions. ### Structured Format IDs (Required Everywhere) **ALL format references** use structured format ID objects: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/format-id.json", "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } ``` This approach ensures: * Explicit namespacing * Collision-safe identifiers * Schema validation * Extensibility without breaking changes String-based format identifiers MUST NOT be used in API contracts. ### Where Structured Format IDs Are Used **Requests:** * `sync_creatives` - Uploading creative assets * `build_creative` - Generating creatives via agents implementing the Creative Protocol * `preview_creative` - Preview generation * `create_media_buy` - When specifying format requirements **Responses:** * `list_creatives` - Returning creative details * `get_products` - Product format capabilities * `list_creative_formats` - Format definitions * Any response containing creative or format details **Filter parameters:** * `format_ids` (plural) in request filters - Array of structured format\_id objects ### Validation Rules **All AdCP agents MUST:** 1. ✅ Accept structured `format_id` objects in ALL contexts 2. ✅ Return structured `format_id` objects in ALL responses 3. ❌ Reject string format\_ids with clear error messages 4. ❌ Never use string format\_ids in any API contract **Error handling for invalid format\_id:** ```json theme={null} { "error": "invalid_format_id", "message": "format_id must be a structured object with 'agent_url' and 'id' fields", "received": "display_300x250", "required_structure": { "agent_url": "https://creative-agent-url.com", "id": "display_300x250" } } ``` ### Legacy Considerations Some legacy systems may send string format\_ids. Implementers have two options: 1. **Strict validation** (recommended): Reject strings immediately with clear error 2. **Auto-upgrade with deprecation**: Accept strings temporarily, log warnings, set removal timeline If auto-upgrading, you MUST: * Only accept strings for well-known formats you can map to agent URLs * Fail loudly for unknown format strings * Log deprecation warnings on every request * Set and communicate a removal date (recommend 6 months maximum) ## Format Structure Formats are JSON objects with the following key fields: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_hosted" }, "name": "30-Second Hosted Video", "type": "video", "assets": [ { "item_type": "individual", "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "required": true, "requirements": { "duration": "30s", "format": ["MP4"], "resolution": ["1920x1080", "1280x720"] } }, { "item_type": "individual", "asset_id": "end_card_image", "asset_type": "image", "asset_role": "end_card", "required": false, "requirements": { "dimensions": "1920x1080", "format": ["PNG", "JPG"] } }, { "item_type": "individual", "asset_id": "companion_banner", "asset_type": "image", "asset_role": "companion", "required": false, "requirements": { "dimensions": "300x250", "format": ["PNG", "JPG", "GIF"] } }, { "item_type": "individual", "asset_id": "impression_tracker", "asset_type": "url", "asset_role": "third_party_tracking", "required": false, "requirements": { "description": "Third-party impression tracking pixel URL" } } ], // DEPRECATED: Use "assets" above instead. Kept for backward compatibility. "assets": [ { "item_type": "individual", "asset_id": "video_file", "asset_type": "video", "asset_role": "hero_video", "requirements": { "duration": "30s", "format": ["MP4"], "resolution": ["1920x1080", "1280x720"] } } ] } ``` **Key fields:** * **format\_id**: Unique identifier (may be namespaced with `domain:id`) * **agent\_url**: The creative agent authoritative for this format * **type**: *(deprecated, optional)* High-level category hint. See note below. * **assets**: Array of all asset specifications with `required` boolean indicating mandatory vs optional. This is the authoritative source for understanding creative requirements. * **asset\_role**: Identifies asset purpose (hero\_image, logo, cta\_button, etc.) * **renders**: Array of rendered outputs with dimensions - see below * **accessibility**: Optional WCAG conformance declaration — see [Accessibility](/dist/docs/3.1.20/creative/accessibility) **Deprecation Notice**: The `type` field is deprecated and optional. The `assets` array provides precise information about creative requirements (video, image, text, etc.) and should be used instead. Categories like "video", "display", and "native" are lossy abstractions that don't scale well to emerging formats like Performance Max (spans multiple channels), search ads (text-only with high intent), or conversational AI placements. ### Asset Discovery The `assets` array enables comprehensive asset discovery. Each asset has a `required` boolean: * **`required: true`** - Asset MUST be provided for a valid creative * **`required: false`** - Asset is optional, enhances the creative when provided (e.g., companion banners, third-party tracking pixels) This unified approach helps creative tools and AI agents understand the full capabilities of a format, enabling richer creative experiences when optional assets are available while clearly indicating minimum requirements. ### Third-Party Tracker Support Whether a format supports third-party measurement is determined by whether its `assets` array includes a tracker slot. A tracker slot is any asset with `asset_type: "url"` AND either: * `url_type: "tracker_pixel"` or `url_type: "tracker_script"` (mechanism-side declaration), OR * `requirements.role` of `impression_tracker`, `click_tracker`, `viewability_tracker`, or `third_party_tracker` (purpose-side declaration; the slot accepts a tracker URL even if the format author declares the role rather than the mechanism) Buyer agents should check this before assigning creatives that require third-party verification. Most digital formats (display, video, CTV, audio, DOOH) include an optional `impression_tracker` asset. Formats without a tracker slot — such as broadcast TV spots — do not support creative-level pixel tracking. Measurement for these formats comes from external sources (panel data, set-top box telemetry) declared in the product's `billing_measurement` terms, not from creative-embedded pixels. A buyer agent that requires DoubleVerify viewability or IAS brand safety measurement should filter formats by tracker support. If no tracker slot exists, those vendors cannot instrument the creative — the buyer must rely on the seller's declared measurement vendor instead. ### Typed Asset Requirements Each asset type has its own requirements schema that defines what constraints apply to that asset. The `requirements` object is typed based on the `asset_type`: **Image assets** (`asset_type: "image"`): * `min_width`, `max_width`, `min_height`, `max_height` - Dimension constraints (set min=max for exact) * `aspect_ratio` - Required aspect ratio (e.g., '1:1') * `formats` - Accepted file formats (jpg, jpeg, png, webp, gif, svg, avif) * `max_file_size_kb` - Maximum file size * `animation_allowed` - Whether animated images are accepted * `max_animation_duration_ms` - Maximum animation duration **Video assets** (`asset_type: "video"`): * `min_width`, `max_width`, `min_height`, `max_height` - Dimension constraints * `aspect_ratio` - Required aspect ratio (e.g., '16:9') * `min_duration_ms`, `max_duration_ms` - Duration constraints * `containers` - Accepted container formats (mp4, webm, mov) * `codecs` - Accepted codecs (h264, h265, vp9, av1) * `frame_rates` - Accepted frame rates (e.g., \[24, 30, 60]) * `max_file_size_kb`, `max_bitrate_kbps` - Size constraints **HTML assets** (`asset_type: "html"`): * `max_file_size_kb` - Maximum file size * `sandbox` - Execution environment (`none`, `iframe`, `safeframe`, `fencedframe`) * `external_resources_allowed` - Whether external scripts/images can be loaded * `allowed_external_domains` - Permitted domains for external resources **JavaScript assets** (`asset_type: "javascript"`): * `max_file_size_kb` - Maximum file size * `module_type` - Module format (`script`, `module`, `iife`) * `external_resources_allowed` - Whether dynamic resource loading is allowed * `allowed_external_domains` - Permitted domains for dynamic loading **Audio assets** (`asset_type: "audio"`): * `min_duration_ms`, `max_duration_ms` - Duration constraints * `formats` - Accepted audio formats (mp3, aac, wav, ogg, flac) * `sample_rates` - Accepted sample rates in Hz (e.g., \[44100, 48000]) * `channels` - Accepted channel configurations (mono, stereo) * `min_bitrate_kbps`, `max_bitrate_kbps` - Bitrate constraints * `max_file_size_kb` - Maximum file size **Text assets** (`asset_type: "text"`): * `min_length`, `max_length` - Character limits * `min_lines`, `max_lines` - Line count limits * `character_pattern` - Regex for allowed characters * `prohibited_terms` - Words/phrases not allowed * `allowed_values` - Closed list of permitted strings; submissions outside the list are rejected with `CREATIVE_VALUE_NOT_ALLOWED` **Closed-set text example (legal/brand-approved CTA list):** ```json theme={null} { "asset_id": "cta_label", "asset_type": "text", "required": true, "requirements": { "max_length": 14, "allowed_values": ["Shop Now", "Learn More", "Get Quote", "Find a Provider"] } } ``` Sellers in regulated verticals (pharma, financial services) and brand-controlled programs use `allowed_values` to expose the exact strings legal or brand has pre-approved. All declared constraints are conjunctive (every allowed value must also satisfy `max_length`, `character_pattern`, etc.) and matching is case-sensitive — buyers MUST submit one of the listed strings verbatim. Buyer agents using LLMs to fill text slots should pass `allowed_values` as a constrained-sampling option rather than generating freely and retrying after rejection. When a submitted value is not in the list, sellers MUST return `CREATIVE_VALUE_NOT_ALLOWED` with `error.field` pointing at the offending asset path and `error.details.allowed_values` echoing the format's list so the buyer can re-prompt deterministically. **URL assets** (`asset_type: "url"`): * `role` - Standard purpose (clickthrough, impression\_tracker, click\_tracker, etc.) * `protocols` - Allowed protocols (https, http) * `allowed_domains` - Permitted destination domains * `macro_support` - Whether macro substitution is supported **Example with HTML execution context:** ```json theme={null} { "asset_id": "banner_html", "asset_type": "html", "required": true, "requirements": { "max_file_size_kb": 150, "sandbox": "safeframe", "external_resources_allowed": false } } ``` This tells creative agents: "Build HTML that works in a SafeFrame container with no external resource loading." See [Display Ads - HTML Display Formats](/dist/docs/3.1.20/creative/channels/display#html-display-formats-with-execution-context) for complete examples. ### Asset Overlays Some formats include publisher-controlled elements that render on top of buyer creative content — video player controls, publisher logos, and similar chrome. Formats declare these as `overlays` on the relevant asset so creative agents and buyers know which areas will be covered and can compose accordingly. Overlay bounds are expressed relative to the asset's own top-left corner. The `unit` field is either `"px"` (absolute pixels) or `"fraction"` (proportional to asset dimensions: for `x`/`y`, 0.0 is the asset's own edge; for `width`/`height`, 0.12 means 12% of the asset dimension). Different overlays on the same asset may use different units — creative agents must handle each independently. ```json theme={null} { "item_type": "individual", "asset_id": "video", "asset_type": "video", "required": true, "requirements": { "aspect_ratio": "16:9", "max_duration_ms": 15000 }, "overlays": [ { "id": "play_pause", "description": "Play/pause control — avoid placing CTA, copy, or logos here", "bounds": { "x": 0, "y": 285, "width": 120, "height": 120, "unit": "px" }, "visual": { "url": "https://publisher.example.com/controls/play.svg" } }, { "id": "volume", "description": "Volume control", "bounds": { "x": 0.88, "y": 0.85, "width": 0.12, "height": 0.15, "unit": "fraction" }, "visual": { "light": "https://publisher.example.com/controls/volume-light.svg", "dark": "https://publisher.example.com/controls/volume-dark.svg" } } ] } ``` The `visual` field is optional but strongly recommended — it allows creative agents to composite accurate previews showing buyer content with publisher chrome in place. Use `url` for a theme-neutral graphic (e.g., an SVG using `currentColor`), or provide `light`/`dark` variants for separate theme-specific assets. ### Rendered Outputs and Dimensions Formats describe one or more rendered outputs, each with defined dimensions and semantic roles. This supports: * Single-render formats * Companion creatives * Multi-placement outputs * Responsive behavior * Physical dimensions for non-personal environments Formats specify their rendered outputs via the `renders` array. Most formats produce a single render, but some (companion ads, adaptive formats, multi-placement) produce multiple renders: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "name": "Display Banner 300x250", "type": "display", "renders": [ { "role": "primary", "dimensions": { "width": 300, "height": 250, "responsive": { "width": false, "height": false } } } ] } ``` **Multi-render example (companion ad):** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_with_companion_300x250" }, "name": "Video with Companion Banner", "type": "video", "renders": [ { "role": "primary", "dimensions": { "width": 1920, "height": 1080, "responsive": { "width": false, "height": false } } }, { "role": "companion", "dimensions": { "width": 300, "height": 250, "responsive": { "width": false, "height": false } } } ] } ``` **Dimension types:** **Fixed dimensions** (standard display ads): ```json theme={null} { "role": "primary", "dimensions": { "width": 300, "height": 250, "responsive": {"width": false, "height": false}, "unit": "px" } } ``` **Responsive width** (fluid banners): ```json theme={null} { "role": "primary", "dimensions": { "min_width": 300, "max_width": 970, "height": 250, "responsive": {"width": true, "height": false}, "unit": "px" } } ``` **Aspect ratio constrained** (native formats): ```json theme={null} { "role": "primary", "dimensions": { "aspect_ratio": "16:9", "min_width": 300, "responsive": {"width": true, "height": true}, "unit": "px" } } ``` **Physical dimensions** (DOOH): ```json theme={null} { "role": "primary", "dimensions": { "width": 48, "height": 14, "responsive": {"width": false, "height": false}, "unit": "inches" } } ``` **Benefits of the renders structure:** * Supports single and multi-render formats uniformly * No string parsing required - structured dimensions * Schema-validated dimensions * Supports responsive and fixed formats equally * Enables proper preview rendering * Allows dimension-based filtering * Supports physical units for DOOH * Clear semantic roles for each rendered piece ## Understanding Format Requirements Traditional IAB format families (display, video, audio, native) are lossy abstractions that don't scale to emerging formats: * **Performance Max** spans video, display, search, and native simultaneously * **Search ads** (RSA) are text-only with high intent context * **Conversational AI** placements don't fit traditional categories The `assets` array tells you exactly what creative elements are needed: * Video required? Check for `asset_type: 'video'` * Images required? Check for `asset_type: 'image'` * Text only? All required assets have `asset_type: 'text'` AdCP supports formats across multiple media types: ### Video Formats * Standard video (15s, 30s, 60s) * Vertical video for mobile/stories * VAST/VPAID tags * Interactive video See [Video Channel Guide](/dist/docs/3.1.20/creative/channels/video) for complete specifications. ### Display Formats * Standard IAB sizes (300x250, 728x90, etc.) * Responsive units * Rich media and expandable * HTML5 creative See [Display Channel Guide](/dist/docs/3.1.20/creative/channels/display) for complete specifications. ### Audio Formats * Streaming audio (15s, 30s, 60s) * Podcast insertion * Companion banners * VAST audio tags See [Audio Channel Guide](/dist/docs/3.1.20/creative/channels/audio) for complete specifications. ### DOOH Formats * Digital billboards * Transit displays * Retail screens * Venue-based impression tracking See [DOOH Channel Guide](/dist/docs/3.1.20/creative/channels/dooh) for complete specifications. ### Carousel/Multi-Asset Formats * Product carousels (3-10 items) * Story sequences * Slideshow formats * Frame-based structures See [Carousel Channel Guide](/dist/docs/3.1.20/creative/channels/carousels) for complete specifications. ## Multi-Asset & Frame-Based Formats Some formats like carousels, slideshows, and stories use repeatable asset groups where each frame contains a collection of assets. See the [Carousel & Multi-Asset Formats guide](/dist/docs/3.1.20/creative/channels/carousels) for complete documentation on frame-based format patterns. ## Reported Metrics Formats can declare which metrics they produce in delivery reporting via the `reported_metrics` field. This tells buyers what data to expect when purchasing inventory using this format. ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_hosted" }, "name": "30-Second Hosted Video", "reported_metrics": [ "impressions", "spend", "views", "completed_views", "completion_rate", "quartile_data" ] } ``` ### Intersection with Product Metrics Products declare their available metrics via `reporting_capabilities.available_metrics`. The buyer receives the **intersection** of format `reported_metrics` and product `available_metrics`. `impressions` and `spend` are always reported regardless of the intersection — they are implicit in every delivery response. The intersection applies to all other metrics. For example, if a video format declares `quartile_data` but the product only reports `impressions`, `spend`, and `clicks`, the buyer will not receive quartile data for that product. ### When to Omit If `reported_metrics` is omitted, the format defers entirely to product-level metric declarations. This is appropriate for formats where the creative type does not inherently constrain which metrics are available (e.g., native formats where metrics depend on the platform). ### Common Patterns **Video formats**: `impressions`, `spend`, `views`, `completed_views`, `completion_rate`, `quartile_data` **Display formats**: `impressions`, `spend`, `clicks`, `ctr`, `viewability` **DOOH formats**: `impressions`, `spend`, `dooh_metrics` **Social/performance formats**: `impressions`, `spend`, `clicks`, `ctr`, `conversions`, `engagement_rate` ## Format Cards Format cards provide visual representations of creative formats for display in browsing and selection interfaces. Creative agents can optionally include card definitions that reference card formats and provide the assets needed to render attractive visual cards. ### Card Types Creative agents should provide at least the standard card, and optionally a detailed card: **Standard Card** (`format_card`): * Compact 300x400px card for format browsing * Supports 2x density images for retina displays * Quick visual understanding of format specs **Detailed Card** (`format_card_detailed`, optional): * Responsive layout with text description alongside hero carousel showing format in context * Markdown specifications section below * Full format documentation similar to [Yahoo's ad specs](https://adspecs.yahooinc.com/premium-ads/e2e-horizon-desktop) ### Structure ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_30s" }, "name": "Standard Video - 30 seconds", "type": "video", // ... other format fields ... "format_card": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "format_card_standard" }, "manifest": { "display_name": "30-Second Video", "preview_mockup_url": "https://cdn.example.com/format-mockups/video_30s.png", "format_type_label": "Video" } }, "format_card_detailed": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "format_card_detailed" }, "manifest": { "display_name": "Standard 30-Second Video", "description": "The Edge A-Logs Horizon (desktop) format is an encompassing...", "carousel_images": [ "https://cdn.example.com/formats/video_30s_context1.jpg", "https://cdn.example.com/formats/video_30s_context2.jpg" ], "specifications_markdown": "# Technical Specifications\n\n..." } } } ``` ### Rendering Cards Cards can be rendered in two ways: 1. **Via `preview_creative`**: Pass the card format and manifest to generate a rendered card 2. **Pre-rendered**: Creative agents can pre-generate cards and serve them directly This flexibility allows implementations to choose between dynamic generation or static hosting based on their infrastructure. ### Standard Card Formats The AdCP reference creative agent defines two standard card formats: * **`format_card_standard`** (300x400px) - Compact card for format browsing * **`format_card_detailed`** (responsive) - Rich card with carousel and full specs Creative agents can also define custom card formats to highlight unique format capabilities or match their branding. **Note**: Standard card format definitions are maintained in the [creative-agent repository](https://github.com/adcontextprotocol/creative-agent), not in this protocol specification. ### When to Include Format Cards Format cards are optional but recommended for: * Visual formats (display, video, DOOH) where mockups help explain the format * Complex formats with multiple asset requirements * Custom formats that differ from standard specifications * Formats where visual preview aids buyer understanding Use the detailed card variant when you want to provide comprehensive format documentation similar to ad spec pages. ### Client Rendering Guidelines When displaying formats in UIs, clients should follow this fallback order: 1. **If `format_card` exists** → Render card via `preview_creative` or display pre-rendered image 2. **If no `format_card` exists** → Render text-only representation (format name + description) 3. **If card rendering fails** → Gracefully fall back to text-only representation This ensures a consistent user experience regardless of what format metadata is available. ## Related Documentation * [Creative Protocol Overview](/dist/docs/3.1.20/creative) - How formats, manifests, and agents work together * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - Pairing assets with formats * [Asset Types](/dist/docs/3.1.20/creative/asset-types) - Understanding asset specifications * [Channel Guides](/dist/docs/3.1.20/creative/channels/video) - Detailed format documentation by media type * [Implementing Standard Format Support](/dist/docs/3.1.20/media-buy/capability-discovery/implementing-standard-formats) - For sales agents * [list\_creative\_formats Task](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) - API reference for format discovery # Generative Creative Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/generative-creative Generative creative in AdCP uses AI to produce ad assets from a brief with three tiers: static manifests, asset group optimization, and per-context generation. > **Canonical formats in 3.1**: in canonical formats, the "generative" category dissolves at the protocol level — production mechanism (generative AI, host recording, transcoding, asset rendering) is invisible to the buyer. Production source is declared per-canonical via `*_source` enums (`audio_source`, `image_source`, `video_source`, `item_production_model`); `synthesis_nondeterministic: true` flags Veo / Sora / Runway-class flows that need post-synthesis QA-loop semantics. See [canonical-formats](/dist/docs/3.1.20/creative/canonical-formats) §"Two axes" for the canonical-formats model. The Creative Protocol enables AI-powered creative generation and asset management for advertising campaigns. This guide will help you create your first creative in 5 minutes. Three tiers of generative creative: Tier 1 static (one creative, one variant), Tier 2 optimized (asset group combinations), Tier 3 generated (AI creates for each context) > **Technical Reference**: This guide shows how to use the [`build_creative` task](/dist/docs/3.1.20/creative/task-reference/build_creative). For complete API specifications, see the task reference documentation. ## Overview The Creative Protocol provides AI-powered creative generation: * **`build_creative`**: Generate creative content using AI with either static manifests or dynamic code * **`preview_creative`**: Generate previews of creative manifests * **`list_creative_formats`**: Discover supported creative formats Assets are provided via [brand identity](/dist/docs/3.1.20/brand-protocol/brand-json) - no separate asset library management needed. ## Quick Start: Generate Your First Creative ### Step 1: Basic Creative Generation Here's the simplest possible request to generate a native display ad: ```json theme={null} { "message": "Create a simple ad for a coffee shop promotion - 20% off all drinks this week", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" } } ``` ### Step 2: Understanding the Response You'll receive a structured creative manifest: ```json theme={null} { "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" }, "assets": { "headline": { "content": "20% Off All Drinks This Week!" }, "description": { "content": "Visit our cozy coffee shop and enjoy premium coffee at an unbeatable price." }, "call_to_action": { "content": "Visit Today" } } } } ``` ### Step 3: Preview Your Creative Preview the manifest to see what it looks like before iterating: ```json theme={null} { "request_type": "single", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" }, "assets": { "headline": { "content": "20% Off All Drinks This Week!" }, "description": { "content": "Visit our cozy coffee shop and enjoy premium coffee at an unbeatable price." }, "call_to_action": { "content": "Visit Today" } } } } ``` The response includes a preview URL you can embed in an iframe. See [`preview_creative`](/dist/docs/3.1.20/creative/task-reference/preview_creative) for device variants, batch preview, and output format options. ### Step 4: Refine Your Creative Iterate by passing the previous output's `creative_manifest` back with a new `message`. Alternatively, update the brief asset (`assets.brief`) to change the creative direction — the brief is the buyer-owned source of truth for what the creative should be. ```json theme={null} { "message": "Make the headline more exciting and add urgency", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" }, "assets": { "headline": { "content": "20% Off All Drinks This Week!" } } } } ``` ## Common Patterns ### Using brand identity Provide brand context for better creative generation: ```json theme={null} { "message": "Create a display ad for our coffee shop promotion", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "brand": { "domain": "mycoffeeshop.com" } } ``` **Minimal brand reference**: Start with just a domain for low-friction creative generation: ```json theme={null} { "message": "Create a coffee shop ad", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" }, "brand": { "domain": "mycoffeeshop.com" } } ``` See [brand.json reference](/dist/docs/3.1.20/brand-protocol/brand-json) for comprehensive examples. ### Using Your Own Assets Provide existing assets to incorporate into the creative: ```json theme={null} { "message": "Create a display ad featuring our signature latte", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "brand_logo": { "url": "https://mycoffeeshop.com/assets/logo.png", "width": 200, "height": 50 } } } } ``` ### Generating Dynamic Code The creative agent decides whether to return a static manifest or dynamic code based on the brief's requirements. When the creative requires runtime logic — like weather-responsive behavior, time-of-day adaptation, or location-based content — the agent returns executable code in the manifest's assets. Use a descriptive brief that implies dynamic behavior: ```json theme={null} { "message": "Create a weather-responsive coffee ad that shows hot drinks when cold, iced drinks when warm", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" } } ``` Code-based creatives are indicated by the asset structure in the response — the manifest includes code assets alongside or instead of static content: ```json theme={null} { "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" }, "assets": { "code": { "content": "" }, "headline_warm": { "content": "Cool Down with Our Iced Collection" }, "headline_cold": { "content": "Warm Up with Our Signature Roasts" } } } } ``` ### Attaching creatives to a media buy Once you've built and previewed your creative, attach it to a media buy. Pass the manifest from `build_creative` as a creative in `create_media_buy` (see [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) for the full request structure): ```json theme={null} { "packages": [{ "product_id": "premium_display", "pricing_option_id": "cpm_standard", "budget": 10000, "creatives": [{ "creative_id": "coffee_promo_v3", "name": "Coffee shop 20% off - final", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_native" }, "assets": { "headline": { "content": "This Week Only: 20% Off Every Drink!" }, "description": { "content": "Visit our cozy coffee shop and enjoy premium coffee at an unbeatable price." }, "call_to_action": { "content": "Visit Today" } } }] }] } ``` For sellers with [inline creative management](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities), creatives travel with the media buy. For other workflows, use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) to upload creatives to the sales agent's library before referencing them in a media buy. ## Format Discovery ### Standard Formats Common format IDs you can use immediately: * `display_native` - Native advertising format * `display_300x250` - Medium rectangle banner * `video_standard_30s` - 30-second video ad ### Publisher-Specific Formats For custom publisher formats, specify the source: ```json theme={null} { "message": "Create a premium video ad", "target_format_id": { "agent_url": "https://premium-publisher.com", "id": "premium_video_15s" } } ``` ## Seller-side generation (brief in media buy) The examples above use `build_creative` to generate creatives interactively. A different pattern applies when the seller generates creatives at serve time — contextual ads, page-matched display, or AI-generated native. In this flow, the sales agent implements both the Media Buy Protocol and the Creative Protocol. The buyer provides a brief as part of `create_media_buy`, and the seller generates creatives at serve time without any separate `build_creative` call. ### How it works 1. **Discover generative formats** — Call `list_creative_formats` on the sales agent. Look for formats where `format_id.agent_url` points to the sales agent itself. 2. **Submit brief in media buy** — Include a creative with the brief as an asset: ```json theme={null} { "packages": [{ "product_id": "premium_display", "pricing_option_id": "cpm_standard", "budget": 50000, "creatives": [{ "creative_id": "brand_contextual_brief", "name": "Contextual campaign brief", "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "assets": { "brief": { "content": "Highlight our sustainability story. Match tone to editorial context. Avoid competitor comparisons." } } }] }] } ``` 3. **Preview before launch** — Call `preview_creative` on the sales agent with the brief manifest and `context_description` inputs to see representative samples of what the agent will generate. Use `quality: "draft"` for fast iteration, `quality: "production"` for stakeholder review. These are illustrative — real output depends on live signals at serve time. ```json theme={null} { "request_type": "single", "quality": "draft", "creative_manifest": { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "assets": { "brief": { "content": "Highlight our sustainability story. Match tone to editorial context. Avoid competitor comparisons." } } }, "inputs": [ { "name": "Tech article", "context_description": "Article about semiconductor manufacturing" }, { "name": "Travel blog", "context_description": "Blog post about eco-friendly travel" } ] } ``` Preview doesn't require the media buy to be active — you can preview with just the brief manifest before calling `create_media_buy`. See [Previewing generative creative](/dist/docs/3.1.20/creative/task-reference/preview_creative#previewing-generative-creative) for the full mental model. 4. **Seller generates at serve time** — The seller uses the brief plus the buyer's [brand identity](/dist/docs/3.1.20/brand-protocol/brand-json) to generate page-tailored creatives per impression or per page context. The brief and brand identity act as guardrails — the agent generates within these constraints. No buyer action is required for ongoing generation. 5. **Review generated variants** — Call `get_creative_delivery` on the sales agent (it implements the Creative Protocol) to see what was generated, including variant manifests and generation context: ```json theme={null} { "media_buy_ids": ["mb_12345"], "max_variants": 20 } ``` The response includes variant-level manifests showing exactly what was served, along with the generation context (page topic, device class, etc.). **Variant retention**: Agents are not required to retain variant data indefinitely. When calling `get_creative_delivery`, use `max_variants` to control how many variants are returned per creative. Agents select which variants to return based on their own criteria — typically by impression volume (most-served first), but some may use recency or representative sampling. For high-volume generative campaigns, expect that only a subset of all generated variants is retained. Request `max_variants` early and often during the campaign rather than relying on a single post-flight pull. ### Key differences from `build_creative` | Aspect | `build_creative` | Brief in media buy | | ----------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- | | When generation happens | On demand, before campaign starts | At serve time, throughout campaign | | Who generates | Standalone creative agent or sales agent | Sales agent (integrated) | | Pre-launch preview | Immediate (manifest is the creative) | Representative samples (brief + simulated context) | | Buyer interaction | Interactive — review, iterate, approve | Preview samples, then set-and-forget — brief is the ongoing constraint | | Variant visibility | Immediate (returned in response) | After serving (via `get_creative_delivery`) | | Format authority | `format_id.agent_url` points to the agent that owns the format | `format_id.agent_url` is the sales agent itself | See [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities) for the full pattern. ### Guardrails for serve-time generation When the seller generates creatives at serve time, the buyer's controls are: * **The brief** — explicit instructions on tone, topics, exclusions ("Avoid competitor comparisons") * **Brand identity** — colors, logos, voice guidelines, approved assets via [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json) * **Pre-flight preview** — sample what the agent generates across contexts before launch * **Post-flight audit** — review variant manifests and generation context via `get_creative_delivery`, replay specific variants via `preview_creative` variant mode If post-flight review reveals off-brand variants, update the brief with more specific constraints and re-submit via `create_media_buy`. For regulated categories (financial services, pharma), include compliance requirements in the brief — agents that declare `supports_compliance: true` in their creative capabilities validate disclosures and required elements during generation. ## Conversational and interactive formats Some generative formats are stateful — AI chat, interactive experiences, conversational native ads. These follow the same brief-in-media-buy pattern but have unique characteristics worth understanding. ### Format discovery A conversational format in `list_creative_formats` might look like: ```json theme={null} { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "conversational_native" }, "name": "Conversational native ad", "type": "native", "description": "AI-powered conversational ad that responds to user messages within the content feed. Adapts tone and recommendations based on conversation context." } ``` ### Brief structure The brief for a conversational format includes persona, topic boundaries, and guardrails: ```json theme={null} { "packages": [{ "product_id": "premium_native", "pricing_option_id": "cpm_engaged", "budget": 25000, "creatives": [{ "creative_id": "brand_chat_brief", "name": "Product advisor chat", "format_id": { "agent_url": "https://ads.seller-example.com", "id": "conversational_native" }, "assets": { "brief": { "content": "Act as a helpful product advisor for our outdoor gear line. Recommend products based on the user's activity interests. Keep responses concise (2-3 sentences). Never discuss competitor products. Always include a product link when recommending." } } }] }] } ``` ### Previewing conversations Pre-flight previews for conversational formats produce a representative first interaction. Use `context_description` to simulate different entry points: ```json theme={null} { "request_type": "batch", "quality": "production", "requests": [ { "creative_manifest": { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "conversational_native" }, "assets": { "brief": { "content": "Act as a helpful product advisor for our outdoor gear line. Recommend products based on the user's activity interests." } } }, "inputs": [ { "name": "Hiking enthusiast", "context_description": "User reading an article about day hikes near Portland" }, { "name": "Runner", "context_description": "User browsing a running shoe review page" } ] } ] } ``` When the preview response includes an `interactive_url`, reviewers can interact with the experience directly in a sandbox — testing different conversation paths, verifying guardrails, and checking tone. **Testing guardrails**: Include adversarial contexts in your preview inputs to verify the agent respects brief constraints: ```json theme={null} { "inputs": [ { "name": "Competitor question", "context_description": "User asks which brand makes better hiking boots than yours" }, { "name": "Off-topic request", "context_description": "User asks for medical advice about a knee injury" }, { "name": "Price haggling", "context_description": "User asks for a discount code or tries to negotiate pricing" } ] } ``` Review these previews carefully — they reveal how the agent handles edge cases that the happy-path previews won't surface. For conversational formats, the preview response may include an `interactive_url` where reviewers can test the conversation live: ```json theme={null} { "response_type": "single", "previews": [ { "preview_id": "prev_hiker", "renders": [ { "render_id": "render_1", "output_format": "url", "preview_url": "https://ads.seller-example.com/preview/conv_hiker_001", "role": "primary" } ], "input": { "name": "Hiking enthusiast", "context_description": "User reading an article about day hikes near Portland" } } ], "interactive_url": "https://ads.seller-example.com/sandbox/conv_hiker_001", "expires_at": "2026-04-01T00:00:00Z" } ``` The `interactive_url` provides a sandbox where reviewers can have a real conversation with the agent, testing different paths and verifying guardrails interactively. Static previews show a representative first interaction; the sandbox shows how the agent actually behaves. ### Variant manifests for conversations After the campaign runs, `get_creative_delivery` returns variant manifests that capture what the agent produced. For conversational formats, each variant represents a conversation session: ```json theme={null} { "variant_id": "conv_hiker_session_042", "generation_context": { "context_type": "conversational", "topic": "outdoor recreation, hiking gear", "device_class": "mobile", "ext": { "turn_count": 4, "engagement_duration_seconds": 45 } }, "manifest": { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "conversational_native" }, "assets": { "greeting": { "asset_type": "text", "content": "Planning a hike? I can help you find the right gear for the trail." }, "transcript": { "asset_type": "text", "content": "Agent: Planning a hike? I can help you find the right gear for the trail.\nUser: Looking for a lightweight daypack\nAgent: For day hikes near Portland, I'd recommend our TrailLite 22L — it's 380g with a built-in rain cover. [link]\nUser: What about trekking poles?\nAgent: The CompactTrek poles fold to 36cm and weigh just 240g per pair. Great for the elevation changes on trails like Dog Mountain. [link]" }, "products_shown": { "asset_type": "text", "content": "TrailLite 22L Daypack, CompactTrek Folding Poles" } } }, "impressions": 1, "clicks": 2 } ``` The level of detail in variant manifests depends on the agent. Some provide full transcripts (as shown above), others provide summarized exchanges with anonymized user signals. The key is that the buyer can audit what the brand's AI said to users. ### Session management and data handling Conversational formats raise considerations that static ads do not: * **Turn limits**: Include turn or duration limits in the brief ("End the conversation gracefully after 5 exchanges"). Agents may also enforce their own limits — check the format description in `list_creative_formats`. * **Escalation**: Define what the agent should do when it cannot answer ("If the user asks about returns, provide a link to our help center"). Without explicit guidance, agent behavior on out-of-scope questions is undefined. * **User data in transcripts**: Variant manifests from `get_creative_delivery` may include user messages. For regulated industries, confirm the agent's data handling practices (anonymization, retention limits) before launch. * **Cost implications**: Conversational ads with per-engagement or per-turn pricing can have variable costs. Review the product's pricing model in `get_products` to understand how conversation depth affects spend. These constraints are expressed in natural language in the brief. The protocol does not enforce them at runtime — verify compliance through post-flight variant review via [`get_creative_delivery`](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery). See [Previewing generative creative](/dist/docs/3.1.20/creative/task-reference/preview_creative#conversational-and-interactive-formats) for the full mental model on previewing stateful formats. ## Next steps * **Multi-format generation**: Generate creatives for multiple sizes in one call with `target_format_ids` — see [Multi-format generation](/dist/docs/3.1.20/creative/task-reference/build_creative#multi-format-generation) * **Browse examples**: See [Task reference](/dist/docs/3.1.20/creative/task-reference/build_creative) for `build_creative` examples * **Sales agent creative flow**: See [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities) for seller-side generation * **Delivery reporting**: See [get\_creative\_delivery](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery) for variant-level analytics ## Common Issues ### Format Not Found If you get a format error, the publisher may not support that format. Try: 1. Use a standard AdCP format first (`display_native`, `video_standard_30s`) 2. Check the publisher's `list_creative_formats` endpoint 3. Verify the `format_id.agent_url` is correct ### Understanding the quality model Creative quality has two independent axes: * **Build quality** (`quality` on `build_creative`): Controls generation fidelity — how much compute goes into producing the creative. `draft` produces rough concepts fast (lower-resolution images, simplified layouts). `production` produces polished, final-quality output. * **Preview quality** (`quality` on `preview_creative`, `preview_quality` on `build_creative`): Controls render fidelity — how the creative is visualized for review. `draft` produces fast, lower-fidelity renderings for rapid iteration. `production` produces full-quality renderings for stakeholder review. These axes are independent. Common combinations: | Build | Preview | Use case | | ---------- | ---------- | ------------------------------------------------------------------------------- | | draft | draft | Rapid exploration — fast concepts, fast previews | | draft | production | Stakeholder review of rough concepts — full-quality rendering of draft creative | | production | production | Final review — polished creative, polished preview | | production | draft | Thumbnail grid — final creative shown as quick thumbnails for selection | Agents that support only one quality level silently ignore the parameter they don't support. There is no echo-back field — verify quality by inspection. ### Creative Quality Issues To improve creative output: 1. **Build quality** and **preview quality** are independent axes — see [Understanding the quality model](#understanding-the-quality-model) above. Use `draft` for iteration, `production` for final review. 2. Be more specific in your message: "Create a minimalist coffee ad with earth tones" 3. Provide comprehensive brand identity with assets and guidelines 4. Iterate using [refinement](/dist/docs/3.1.20/creative/task-reference/build_creative#iterative-refinement) — pass back the manifest with a new message, or update the brief asset ### Asset Management Assets are provided via [brand identity](/dist/docs/3.1.20/brand-protocol/brand-json): 1. Include assets in brand identity with descriptive tags 2. Use `asset_filters` in requests to select specific assets 3. Reference product catalogs for large inventories Ready to create your first creative? Start with the basic example above and explore from there! # Implementing Creative Agents Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/implementing-creative-agents How to build an AdCP creative agent that defines formats, validates manifests, generates previews, and hosts a creative library. > **Canonical formats in 3.1**: in the canonical-formats path, creative agents declare what they can produce via `creative.supported_formats` on `get_adcp_capabilities` (replacing the v1 `list_creative_formats` overload for creative agents). Each entry uses the same `ProductFormatDeclaration` shape as a product's inline `format_options[i]`. See [canonical-formats](/dist/docs/3.1.20/creative/canonical-formats). This guide explains how to implement a creative agent that defines and manages creative formats. ## What is a creative agent? A creative agent is a service that: * **Defines formats** - Specifies what assets are required and how they should be structured * **Validates manifests** - Ensures creative manifests meet format requirements * **Generates previews** - Shows how creatives will render * **Builds creatives** (optional) - Generates manifests from natural language briefs or retrieves them from a library * **Hosts a creative library** (optional) - Lets buyers browse and filter existing creatives An ad server (CM360, Flashtalking), creative management platform, creative agency, publisher, or sales agent can implement a creative agent. Sales agents that implement the Creative Protocol alongside the Media Buy Protocol serve both roles from a single endpoint — see [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities). ## Three interaction models Creative agents fall into three distinct categories based on how assets arrive and what the output is. Identifying which model your agent follows determines which tasks to implement and how buyers will interact with you. ### Stateless: template and transformation agents **Examples:** Celtra, format conversion services, rich media template platforms The buyer passes all assets inline with each call. Your agent applies a template or transformation and returns the result. There is no persistent creative library — every call is self-contained. | Task | Role | | ----------------------- | --------------------------------------------------------- | | `list_creative_formats` | Discover available templates and their asset requirements | | `preview_creative` | Render a template with provided assets | | `build_creative` | Transform input assets into a serving tag | **Capabilities:** `supports_transformation: true` The buyer's workflow: discover your formats → preview with real assets → request built creatives for trafficking. ### Stateful (pre-loaded): ad servers **Examples:** Innovid, Flashtalking, CM360 Creatives already exist in your system, loaded through your platform's UI or API. Buyers connect to browse your library and request serving tags for their media buys. The buyer never pushes assets to you — they reference creatives that are already there. | Task | Role | | ----------------------- | ----------------------------------------------------------------- | | `list_creatives` | Browse existing creatives in the library | | `build_creative` | Generate a serving tag for a specific `creative_id` and media buy | | `preview_creative` | Preview an existing creative | | `get_creative_delivery` | Report variant-level delivery metrics | **Capabilities:** `has_creative_library: true` The buyer's workflow: browse your creative library → request tags per media buy → track delivery. ### Stateful (push): sales agents with creative **Examples:** Publisher platforms, retail media networks, native ad platforms Buyers push creative assets or catalog items to your platform. You validate, store, and render them in your environment. This is where catalog-driven creative gets interesting — buyers might push product feeds, flight listings, or hotel inventory that you render as native ads. | Task | Role | | ----------------------- | ------------------------------------------------------- | | `list_creative_formats` | Discover what formats you accept | | `sync_creatives` | Accept pushed assets or catalog items | | `preview_creative` | Preview pushed creatives in your platform's environment | | `get_creative_delivery` | Report delivery metrics | **Capabilities:** `has_creative_library: true` The buyer's workflow: discover your accepted formats → push assets → preview in your environment. ### Choosing your model The key question is: **where do the assets come from?** * If the buyer sends assets with every call → **stateless (template/transformer)** * If creatives already exist in your system → **stateful (ad server)** * If the buyer pushes assets to you for hosting → **stateful (sales agent)** Some agents combine models. A creative management platform might be both a template engine (stateless transformation) and a library host (stateful). Declare the appropriate capability flags in `get_adcp_capabilities` so buyers can determine the right interaction model. ### Pricing and statefulness A creative agent that charges for its services needs an account relationship. Adding pricing requires: 1. **Implement the [Accounts Protocol](/dist/docs/3.1.20/accounts/overview)** — buyers establish accounts with rate cards 2. **Expose `pricing_options` on discovery** — on `list_creative_formats` (transformation/generation agents) or `list_creatives` (ad servers/library agents) 3. **Return pricing in `build_creative` responses** — `pricing_option_id`, `vendor_cost`, `currency`, and `consumption` 4. **Accept `report_usage`** — orchestrators report what was served so you can track revenue Free transformation agents remain stateless and unchanged. No account, no pricing required. #### Pricing discovery by agent type | Agent type | Discovery surface | Why | | ------------------------------------------------ | ----------------------- | ------------------------------------------------------------------------------ | | **Transformation** (Celtra) | `list_creative_formats` | Buyers see pricing per format before any creative exists | | **Generation** (AI platforms) | `list_creative_formats` | Same — pricing is on the capability, not a pre-existing creative | | **Ad server** (Innovid, CM360) | `list_creatives` | Buyers see pricing on specific creatives in the library | | **Both** (Celtra, creative management platforms) | Both surfaces | Library pricing on `list_creatives`, format pricing on `list_creative_formats` | Transformation and generation agents do not need to implement `list_creatives` for pricing — `list_creative_formats` is the natural surface since the format is the product. #### Pricing walkthrough Here is the full round-trip for a transformation agent charging per format adapted. **Step 1: Buyer discovers pricing via `list_creative_formats`** The buyer calls `list_creative_formats` with `include_pricing: true` and their `account`. Your agent looks up the account's rate card and returns `pricing_options` on each format: ```json theme={null} { "formats": [ { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "name": "Display 300x250", "pricing_options": [ { "pricing_option_id": "po_standard_per_format", "model": "per_unit", "unit": "format", "unit_price": 2.00, "currency": "USD" }, { "pricing_option_id": "po_volume_per_format", "model": "per_unit", "unit": "format", "unit_price": 1.25, "currency": "USD" } ] } ] } ``` Multiple options are common — here the buyer sees a standard rate and a volume rate. For ad servers, the same `pricing_options` pattern appears on `list_creatives` instead. **Step 2: Buyer builds a creative** The buyer calls `build_creative` with their `account`. Your agent performs the work, selects the applicable pricing option server-side (based on the account's commitment level, the work performed, etc.), and returns the cost: ```json theme={null} { "creative_manifest": { "creative_id": "cr_hero_banner", "format_id": { "agent_url": "https://creative.example.com", "id": "display_728x90" }, "assets": { "..." : "..." } }, "pricing_option_id": "po_standard_per_format", "vendor_cost": 2.00, "currency": "USD", "consumption": { "renders": 1 } } ``` The `pricing_option_id` tells the buyer which rate was applied. The `consumption` object lets the buyer verify: 1 render × $2.00/format = $2.00 `vendor_cost`. For **CPM-priced creatives** (ad servers), `vendor_cost` is 0 at build time — cost accrues when impressions are served: ```json theme={null} { "creative_manifest": { "..." : "..." }, "pricing_option_id": "po_video_cpm", "vendor_cost": 0, "currency": "USD" } ``` **Step 3: Buyer reports usage** After the campaign delivers, the buyer reports usage via [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage). This example shows CPM reporting, where cost accrued during delivery rather than at build time. The `pricing_option_id` and `creative_id` flow through for reconciliation: ```json theme={null} { "reporting_period": { "start": "2026-03-01T00:00:00Z", "end": "2026-03-31T23:59:59Z" }, "usage": [ { "account": { "account_id": "acct_acme_creative" }, "creative_id": "cr_hero_banner", "pricing_option_id": "po_video_cpm", "impressions": 2400000, "vendor_cost": 1200.00, "currency": "USD" } ] } ``` Your agent validates that `pricing_option_id` matches the account's rate card and accepts the record. #### Who selects the pricing option? The **vendor agent** selects the pricing option, not the buyer. The buyer passes `account` on `build_creative` — the agent determines which pricing option applies based on the account's rate card, the work performed, and any commitment tiers. The response tells the buyer what was applied. The buyer does not pass `pricing_option_id` on the `build_creative` request. They see the options on `list_creatives`, and they receive the applied option on the `build_creative` response. #### Consumption fields by agent type | Agent type | Typical `consumption` fields | Notes | | --------------- | ---------------------------- | ------------------------------------------ | | Transformation | `renders` | Number of format adaptations performed | | AI generation | `tokens`, `images_generated` | LLM tokens consumed, images produced | | Ad server (CPM) | Omitted | Cost accrues at serve time, not build time | | Multi-variant | `renders` | Number of variants rendered | The `consumption` object is informational — it lets the buyer verify that `vendor_cost` is consistent with the rate card. `vendor_cost` is the billing source of truth. ## Core requirements ### 1. Format ID namespacing Every format you define must use structured format IDs with your agent URL to prevent conflicts: ```json theme={null} { "format_id": { "agent_url": "https://youragency.com", "id": "video_story_15s" } } ``` **Rules:** * `agent_url` must match your agent's URL * `id` should be descriptive and unique within your namespace * Use lowercase with underscores for consistency ### 2. Format validation When formats reference your agent\_url, you are the authority for: * Format specifications * Asset validation rules * Technical requirements * Preview generation **Format definition example:** ```json theme={null} { "format_id": { "agent_url": "https://youragency.com", "id": "story_sequence_5frame" }, "name": "5-Frame Story Sequence", "type": "display", "min_frames": 5, "max_frames": 5, "frame_schema": { "assets": [ { "asset_type": "image", "asset_role": "frame_image", "required": true, "requirements": { "width": 1080, "height": 1920, "file_types": ["jpg", "png", "webp"], "max_file_size_kb": 500 } }, { "asset_type": "text", "asset_role": "caption", "required": false, "requirements": { "max_length": 100 } } ] }, "global_assets": [ { "asset_type": "image", "asset_role": "brand_logo", "required": true, "requirements": { "width": 200, "height": 200, "transparency_required": true } } ] } ``` ## Required tasks Creative agents must implement these two tasks: ### list\_creative\_formats Return all formats your agent defines. This is how buyers discover what creative formats you support. **Key responsibilities:** * Return complete format definitions with all `assets` (both required and optional) * Include your `agent_url` in each format * Use proper namespacing for `format_id` values See [list\_creative\_formats task reference](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) for complete API specification. ### preview\_creative Generate a visual preview showing how a creative manifest will render in your format. **Key responsibilities:** * Validate manifest against format requirements * Return validation errors if manifest is invalid * Generate visual representation (URL, image, or HTML) * Preview should be accessible for at least 24 hours See [preview\_creative task reference](/dist/docs/3.1.20/creative/task-reference/preview_creative) for complete API specification. ## Optional tasks ### build\_creative Generate a creative manifest from a natural language brief, transform an existing manifest to a new format, or retrieve a library creative as a delivery-ready manifest. **Key responsibilities:** * Parse natural language brief or resolve a `creative_id` from your library * Generate or source appropriate assets * Return valid manifest for the target format * Substitute `macro_values` into serving tags when provided * Optionally return preview URL See [build\_creative task reference](/dist/docs/3.1.20/creative/task-reference/build_creative) for complete API specification. ### list\_creatives Browse and filter creatives in your library. Implement this if your platform hosts a creative library that buyers need to query. **Key responsibilities:** * Return creatives accessible to the authenticated account * Support filtering by format, status, tags, and date range * Support pagination for large libraries * Optionally include dynamic creative optimization (DCO) variable definitions per creative See [list\_creatives task reference](/dist/docs/3.1.20/creative/task-reference/list_creatives) for complete API specification. ### sync\_creatives Accept creative asset uploads into your library. Implement this if your platform allows buyers to push assets. **Key responsibilities:** * Validate creatives against format specifications * Return per-creative results with platform-assigned IDs * Support upsert semantics (create or update by `creative_id`) * Optionally support bulk package assignments (for agents that also manage media buys) * Optionally support async approval workflows for brand safety review See [sync\_creatives task reference](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for complete API specification. ## Validation best practices ### Manifest validation When validating manifests: 1. **Check format\_id** - Ensure it references your agent 2. **Validate required assets** - All required assets must be present 3. **Check asset types** - Assets must match specified types 4. **Validate requirements** - Dimensions, file types, sizes, etc. 5. **URL accessibility** - Verify asset URLs are accessible (optional but recommended) **Example validation errors:** ```json theme={null} { "status": "error", "error": "validation_failed", "validation_errors": [ { "asset_id": "frame_1_image", "error": "missing_required_asset", "message": "Required asset 'frame_1_image' is missing" }, { "asset_id": "brand_logo", "error": "invalid_dimensions", "message": "Logo must be 200x200px, got 150x150px" } ] } ``` ### Disclosure requirements When a creative brief includes `compliance.required_disclosures`, creative agents must ensure each disclosure appears in the generated creative. The workflow: 1. **Check format support** — Compare each `required_disclosures[].position` against the format's `supported_disclosure_positions` or `disclosure_capabilities`. If a required position is not supported by the format, return a validation error rather than silently dropping it. When `disclosure_capabilities` is present, use it for persistence-aware matching — verify that the format supports both the required position and the required persistence mode. 2. **Respect persistence** — When the brief specifies `persistence` on a required disclosure, the creative agent must satisfy it using a position that supports that persistence mode in the format's `disclosure_capabilities`. For example, if a brief requires `"continuous"` persistence for an EU AI Act disclosure, the format must declare that position with `"continuous"` in its `disclosure_capabilities`. When the brief omits `persistence`, use the most restrictive persistence mode the format supports for that position. 3. **Render disclosures** — For positions your format supports: * `footer`, `overlay`, `end_card`, `prominent`: Render the disclosure `text` into the creative at the specified position * `audio`, `pre_roll`: Include disclosure as spoken audio. Respect `min_duration_ms` if specified. * `subtitle`: Include as a text track within the video creative * `companion`: Deliver in the companion ad unit alongside the primary creative 4. **Respect jurisdiction scoping** — A disclosure with `jurisdictions: ["US"]` is legally required only in the US. Creative agents that produce a single creative per brief should include all jurisdictional disclosures. If your agent can produce jurisdiction-specific variants, filter disclosures by their `jurisdictions` field. 5. **Propagate into provenance** — When the brief specifies `persistence` and `position` on a required disclosure, propagate these into `provenance.disclosure.jurisdictions[].render_guidance` on the creative manifest. The brief is a creation-time document; at serve time, the publisher has the creative and its provenance, not the brief. If the creative agent does not propagate persistence into provenance render guidance, the publisher has no way to know what persistence the regulation requires. 6. **Preserve through regeneration** — When regenerating or resizing a creative, carry forward all disclosures from the `BriefAsset` attached to the manifest. A `BriefAsset` is a `brief`-typed asset in the format's `assets` array that carries the creative brief through the manifest, ensuring disclosures survive format adaptation. **Example:** A brief requires `"KI-generiert"` disclosure at `overlay` position with `persistence: "continuous"` for `eu_ai_act_article_50`. Your format declares `disclosure_capabilities: [{ "position": "overlay", "persistence": ["continuous", "initial"] }]`. The format supports continuous overlay, so the creative agent renders the disclosure as a persistent overlay visible throughout the content. The agent also propagates `render_guidance: { "persistence": "continuous", "positions": ["overlay"] }` into the EU jurisdiction entry in `provenance.disclosure.jurisdictions[]`. ### Format evolution When updating format definitions: * **Additive changes** (new optional assets with `required: false` in `assets`) are safe * **Breaking changes** (removing assets, changing requirements) require new format\_id * Consider versioning: `youragency.com:format_name_v2` * Maintain backward compatibility when possible ## Deployment checklist Before launching your creative agent: * [ ] MCP and/or A2A endpoints are accessible * [ ] `get_adcp_capabilities` returns `creative.supported_formats` with at least one entry * [ ] All format\_ids use proper namespacing (`domain:id`) * [ ] Domain in format\_id matches your `agent_url` domain * [ ] `creative.supported_formats` includes every format the agent offers for discovery * [ ] `preview_creative` validates manifests and generates previews * [ ] Format definitions include complete asset requirements * [ ] Documentation available for your custom formats To verify a remote creative agent is reachable and advertising its format surface, call `get_adcp_capabilities` on the agent endpoint and confirm that the response includes a non-empty `creative.supported_formats` array. Each entry follows the [canonical format declaration](/dist/docs/3.1.20/creative/canonical-formats) shape. ```bash theme={null} export AGENT_URL="https://your-creative-agent.example.com/mcp" # 1. Initialize MCP and capture Mcp-Session-Id when the server is stateful. SESSION_ID=$(curl -s -D - -o /dev/null -X POST "$AGENT_URL" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "adcp-probe", "version": "1.0" } } }' | grep -i '^mcp-session-id:' | awk '{print $2}' | tr -d '\r\n') HEADERS=(-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") [ -n "$SESSION_ID" ] && HEADERS+=(-H "Mcp-Session-Id: $SESSION_ID") # 2. Complete the MCP initialization lifecycle before calling a tool. curl -s -o /dev/null -X POST "$AGENT_URL" "${HEADERS[@]}" \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' # 3. Call get_adcp_capabilities with the same session header. curl -X POST "$AGENT_URL" "${HEADERS[@]}" \ -d '{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_adcp_capabilities", "arguments": { "adcp_version": "3.1", "adcp_major_version": 3, "protocols": ["creative"] } } }' ``` Read the AdCP payload from the MCP result and verify that it contains a creative capability block like this: ```json theme={null} { "creative": { "supported_formats": [ { "capability_id": "display_banner", "format": { "format_kind": "image", "params": { "width": 300, "height": 250 } } } ] } } ``` An unreachable endpoint, an authorization error, a missing `creative` block, or an empty `supported_formats` array means the agent is not ready for creative-format discovery. If the endpoint requires authentication, include the credential configured for that agent. ## Integration patterns ### Pattern 1: creative agency You're a creative agency building custom formats for brands: ```json theme={null} { "format_id": { "agent_url": "https://brandstudio.com", "id": "hero_video_package" }, "name": "Hero Video Package", "type": "video", "description": "Premium video creative with multiple aspect ratios", "assets": [ {"asset_role": "hero_video_16x9", ...}, {"asset_role": "hero_video_9x16", ...}, {"asset_role": "hero_video_1x1", ...} ] } ``` ### Pattern 2: platform-specific formats You're a platform defining specialized formats: ```json theme={null} { "format_id": { "agent_url": "https://platform.com", "id": "interactive_quiz" }, "name": "Interactive Quiz Ad", "type": "rich_media", "description": "Engagement-driven quiz format", "assets": [ {"asset_role": "question_1", "asset_type": "text", ...}, {"asset_role": "answer_1a", "asset_type": "text", ...}, // ... quiz structure ] } ``` ### Pattern 3: format extension service You provide enhanced versions of standard formats: ```json theme={null} { "format_id": { "agent_url": "https://enhanced.com", "id": "video_30s_optimized" }, "name": "Optimized 30s Video", "type": "video", "extends": "creative.adcontextprotocol.org:video_30s", "description": "Standard 30s video with automatic optimization", "assets": [ // Same requirements as standard format ], "enhancements": { "auto_transcode": true, "quality_optimization": true, "format_variants": ["mp4", "webm", "hls"] } } ``` ### Pattern 4: feed-native/social format agent You host ad formats that render as native content within your platform's feed: ```json theme={null} { "format_id": { "agent_url": "https://ads.socialplatform.com", "id": "promoted_post" }, "name": "Promoted post", "type": "native", "description": "Sponsored content that appears in the feed alongside organic posts. Renders with platform chrome (user avatar, engagement buttons, community badge).", "assets": [ { "item_type": "individual", "asset_id": "headline", "asset_type": "text", "required": true, "requirements": { "max_length": 300 } }, { "item_type": "individual", "asset_id": "body", "asset_type": "text", "required": false, "requirements": { "max_length": 1000 } }, { "item_type": "individual", "asset_id": "image", "asset_type": "image", "required": false, "requirements": { "max_width": 1200, "max_height": 628, "accepted_types": ["image/jpeg", "image/png"] } }, { "item_type": "individual", "asset_id": "click_url", "asset_type": "url", "required": true, "requirements": {} } ] } ``` Platform-specific rendering (dark mode, community context, engagement UI) is handled by the agent at preview and serve time — the format definition specifies only the buyer-provided assets. The agent wraps these assets in the platform's native chrome. When a buyer calls `preview_creative` for a feed-native format, the preview renders the buyer's assets inside the platform's UI — avatar, engagement buttons, community badge, and all: ```json theme={null} { "request_type": "single", "creative_manifest": { "format_id": { "agent_url": "https://ads.socialplatform.com", "id": "promoted_post" }, "assets": { "headline": { "content": "Introducing our new trail running collection" }, "body": { "content": "Built for the mountains. Tested on every terrain." }, "image": { "url": "https://cdn.acme-example.com/trail-hero.jpg", "width": 1200, "height": 628 }, "click_url": { "url": "https://acme-example.com/trail-running" } } }, "inputs": [ { "name": "Running community", "context_description": "Appears in r/trailrunning feed between user posts" }, { "name": "General feed", "context_description": "Appears in home feed between mixed content" } ] } ``` The preview response shows how the ad looks in each context — including community-specific chrome that the buyer cannot preview elsewhere. This is why platforms should implement `preview_creative` even for simple formats: the platform chrome is the differentiator. ## Platform mapping If you're wrapping an existing ad server or creative management platform, this section shows how common platform concepts map to the creative protocol. ### Concept mapping | Platform concept | AdCP equivalent | Notes | | ------------------------------------------ | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Advertiser / account | Account (via [accounts protocol](/dist/docs/3.1.20/accounts/overview)) | Buyer establishes access before querying the library | | Creative concept / group / template folder | `concept_id` in `list_creatives` | Groups related creatives across sizes/formats (Flashtalking concepts, Celtra campaign folders, CM360 creative groups) | | Creative | Creative item in `list_creatives` response | | | Creative type + size | `format_id` (structured object with `agent_url`, `id`, dimensions) | AdCP combines type and size into a single format reference | | Template (Celtra's term) | Format (via `list_creative_formats`) | Celtra templates define structure and available properties; AdCP formats define structure and available assets | | Template object properties (Celtra) | `variables` array | Named slots with types (text, color, image, video, number, boolean) — near-exact match | | Active / archived / pending | `status` field | | | Ad tag / serving tag | Asset in a creative manifest (`html`, `javascript`, or `vast` type) | Tags are just assets — no special concept | | Placement / ad unit | Package within a media buy | The buy context where a creative is assigned | | DCO variables / dynamic fields | `variables` array (via `include_variables=true`) | Named slots with types and defaults | | Data feed / targeting rules | Not modeled | AdCP models the variable *slots*, not the optimization rules | | CTV/OTT ad server (Innovid, Brightcove) | Same as ad server, plus VAST/SSAI delivery model | VAST tags in `vast`-type assets; companion ads via multi-render formats | ### Tag generation models Ad servers differ in how they produce serving tags. The creative protocol's `build_creative` accommodates all common models through the combination of `creative_id`, `target_format_id`, and optional `media_buy_id`/`package_id`: **Universal tags** (Celtra, Flashtalking) — A single tag that adapts to multiple environments (web, in-app). No placement context is needed — `build_creative(creative_id, target_format_id)` produces a tag that works wherever it's trafficked. Best for agency/programmatic use cases where the final destination is unpredictable, reducing trafficking errors. **Single-placement tags** (Celtra, Flashtalking, CM360) — A tag scoped to one specific size and placement. The `target_format_id` specifies the exact dimensions (e.g., 300x250 web). For publisher template use cases where the destination is known, this is the most common choice. **Multi-placement tags** (Celtra) — A tag covering multiple sizes within one environment. The `target_format_id` references a format whose `renders` array defines the supported sizes. Useful for publisher templates that need several sizes (e.g., 300x250 + 320x50 + 728x90 web) but want a single tag to traffic. **Placement-level tags** (CM360) — The platform generates tags per placement, not per creative. The caller passes `media_buy_id` and optionally `package_id` to provide the trafficking context. A CM360 adapter uses the media buy context to produce a tag scoped to the target format. The choice between these models is often a campaign context decision, not a platform constraint. The same creative agent may produce different tag types depending on the caller's needs: | Use case | Tag type | `build_creative` parameters | | ----------------------------------------- | ---------------- | ------------------------------------------------------------------ | | Agency/programmatic (unknown destination) | Universal | `creative_id` + `target_format_id` (universal format) | | Publisher template (known placement) | Single placement | `creative_id` + `target_format_id` (specific size) | | Publisher template (multiple sizes) | Multi-placement | `creative_id` + `target_format_id` (multi-render format) | | Ad server with trafficking context | Placement-level | `creative_id` + `target_format_id` + `media_buy_id` + `package_id` | In all cases the output is the same: a creative manifest with the serving code in an `html` or `javascript` asset. ### Variable models Platforms represent dynamic content differently. The creative protocol's `variables` array accommodates the common patterns: **Named variable slots** (Flashtalking) — Each creative has explicit variables with IDs, names, and types. Maps directly to `creative-variable.json`. **Template object properties** (Celtra) — Templates define `templateObjects` with typed `properties` (text, color, image, video, percentage, hidden) scoped to specific components and size variants. A Celtra adapter flattens these into the `variables` array, using the template object label and property label to construct `variable_id` and `name`. **Rule-based asset selection** (CM360) — Dynamic creatives use `dynamicAssetSelection` with targeting rules, fed by data feeds. This model is not variable-based — CM360 adapters would typically not populate the `variables` array, and `has_variables` filtering would not apply. ### Macro handling Platforms use their own macro syntax internally. The `macro_values` parameter in `build_creative` lets the caller pass universal macro values (e.g., `CLICK_URL`) that the creative agent substitutes into the output tag using whatever syntax the platform expects. | Universal macro | CM360 equivalent | Flashtalking equivalent | | --------------- | ---------------- | ----------------------- | | `CLICK_URL` | `%c` | `[clickTag]` | | `CACHEBUSTER` | `%n` | `[timestamp]` | | `TIMESTAMP` | `%t` | `[timestamp]` | The creative agent handles translation — callers always use universal macros. ### Re-submission after rejection When `sync_creatives` or creative review results in a rejection, the fix-and-resubmit flow uses upsert semantics: 1. Check rejection reason via `list_creatives` (library `status: "rejected"`) or `get_media_buys` (package `approval_status: "rejected"` with `rejection_reason`) 2. Fix the creative (update assets, adjust copy, replace media) 3. Re-submit via `sync_creatives` with the same `creative_id` — the agent updates the existing creative and re-triggers review 4. Poll `list_creatives` until `status` transitions from `pending_review` to `approved` or `rejected` Re-submission resets the review clock. The agent treats the updated creative as a new submission for review purposes. ### Which tasks to implement The table below maps each interaction model to the tasks it should implement. See [Three interaction models](#three-interaction-models) above for detailed descriptions. | Interaction model | Required tasks | Additional tasks | Capabilities | | ---------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------ | ------------------------------- | | Template/transformer (Celtra, format conversion) | `list_creative_formats`, `preview_creative`, `build_creative` | — | `supports_transformation: true` | | Ad server (Innovid, Flashtalking, CM360) | `list_creatives`, `build_creative`, `preview_creative` | `list_creative_formats`, `get_creative_delivery` | `has_creative_library: true` | | Sales agent with creative (publishers, retail media) | `list_creative_formats`, `sync_creatives`, `preview_creative` | `get_creative_delivery` | `has_creative_library: true` | | Generative creative tool | `list_creative_formats`, `preview_creative`, `build_creative` | — | `supports_generation: true` | Declare these capabilities in `get_adcp_capabilities` so buyer agents can determine the correct interaction model without trial and error. See [Interaction models](/dist/docs/3.1.20/creative/specification#interaction-models) in the spec. Platforms with a creative library should also implement the [accounts protocol](/dist/docs/3.1.20/accounts/overview) so buyers can establish access before querying. This is the same accounts protocol used by sales agents for media buys. ## Related * [Creative Formats](/dist/docs/3.1.20/creative/formats) - Understanding format structure * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - How manifests work * [Asset Types](/dist/docs/3.1.20/creative/asset-types) - Asset specifications * [list\_creative\_formats task](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) - Format discovery API reference * [list\_creatives task](/dist/docs/3.1.20/creative/task-reference/list_creatives) - Creative library API reference * [build\_creative task](/dist/docs/3.1.20/creative/task-reference/build_creative) - Manifest generation API reference * [preview\_creative task](/dist/docs/3.1.20/creative/task-reference/preview_creative) - Preview rendering API reference # Creative protocol Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/index The AdCP Creative Protocol manages ad creative from brief to delivery across CTV, display, and social using AI agents and standardized formats. A creative strategist reviews ad mockups across multiple formats on her monitor Maya is a creative strategist at a mid-size agency. She's launching a holiday campaign for Acme Outdoor across three channels: CTV, display, and social. She has one brief. She needs it running everywhere by Friday. This walkthrough follows her journey through AdCP — from brief to delivery reporting — showing how the protocol connects creative tools, publishers, and AI agents into a single workflow. ## Step 1: Write the brief Maya starts with what she knows best: the creative direction. A creative brief radiates outward to TV, phone, laptop, and billboard screens In AdCP, the brief is the `message` field on `build_creative`. Maya doesn't need to write JSON — her agency platform translates her direction into the protocol format: ```json theme={null} { "message": "Holiday campaign for Acme Outdoor. Warm, adventurous tone. Hero product: Trail Pro 3000 hiking boot. Key message: 'Gift the adventure.' Use brand colors and winter imagery.", "brand": { "domain": "acmeoutdoor.com" }, "target_format_ids": [ { "agent_url": "https://streamhaus.example", "id": "ssai_30s" }, { "agent_url": "https://outdoornet.example", "id": "display_300x250" }, { "agent_url": "https://outdoornet.example", "id": "display_728x90" } ], "quality": "draft", "include_preview": true } ``` The brand identity — colors, logos, typography, tone — lives at `acmeoutdoor.com/.well-known/brand.json`. Every agent in the chain reads it from there. Maya's team maintains it once. | What Maya says | What the protocol calls it | | ---------------- | ----------------------------------------- | | Creative brief | `message` on `build_creative` | | Brand guidelines | `brand.json` at `/.well-known/brand.json` | | Comp / mockup | Preview (from `preview_creative`) | | Final creative | Production-quality manifest | | Trafficking | `sync_creatives` to each seller | | Campaign report | `get_creative_delivery` across agents | ## Step 2: Discover what each seller supports Before generating anything, Maya's platform checks what formats each seller accepts. This happens automatically — but here's what's going on under the hood. Her platform calls `list_creative_formats` on each connected agent: Agency platform calls list_creative_formats on three sellers: StreamHaus returns CTV formats, Pinnacle Media returns display sizes, CommHub returns social formats Now Maya's platform knows: StreamHaus needs video files and VAST tags. Pinnacle needs display banners. CommHub needs feed-native content assets. One brief, three different output types. ## Step 3: Check governance constraints Before generating creatives, Maya's platform checks what governance constraints apply. A buyer's governance agent may require security scanning, content categorization, or policy compliance on all creatives. The platform calls `get_adcp_capabilities` on the buyer's governance agent to discover what creative features will be evaluated: ```javascript theme={null} const capabilities = await governanceAgent.getCapabilities(); const creativeFeatures = capabilities.governance.creative_features; // Returns features like: registry:eu_ai_act_article_50, auto_redirect, credential_harvest ``` These constraints feed into the generation request. If the buyer requires EU AI Act compliance (`registry:eu_ai_act_article_50`), the creative agent ensures generated content includes required provenance metadata. If security scanning is required, generated creatives avoid patterns that trigger `auto_redirect` or `credential_harvest` flags. Governance constraints are evaluated after generation via [`get_creative_features`](/dist/docs/3.1.20/governance/creative/get_creative_features), which scores a specific creative manifest — but knowing the feature set before generation avoids a reject-regenerate cycle. Creative agents that are aware of governance requirements produce compliant output on the first pass. See the [Creative Governance overview](/dist/docs/3.1.20/governance/creative/index) for the feature-based evaluation model. ## Step 4: Generate and preview Three AI agents collaborate at a workbench, passing creative assets between them Maya's platform routes the brief to the right creative agents. A CTV specialist handles video. A display agent handles banners. The social platform generates feed-native content that matches each community's voice. Agency platform routes the brief to three agents: Video Agent receives build_creative for SSAI 30s, Display Agent receives build_creative for banner sizes, Social Platform receives create_media_buy with brief embedded For CTV and display, Maya gets back draft-quality manifests she can preview immediately. For social, the platform will generate at serve time — but Maya can still preview what it would look like. Split screen showing a rough draft mockup transforming into a polished production creative Maya asks to see the CTV spot in a living room context and the social post in two different communities: ```json theme={null} { "request_type": "single", "creative_manifest": { "...": "video manifest from build_creative" }, "inputs": [ { "name": "Living room primetime", "context_description": "CTV app, evening, family household" }, { "name": "Mobile commute", "context_description": "Phone screen, morning, commuter" } ] } ``` She reviews the drafts. The CTV spot needs a warmer color grade. She sends feedback via another `build_creative` call with an updated message. The agent iterates. This is the tissue session — fast, low-fidelity, focused on getting the direction right. When Maya's happy, she promotes to production quality: ```json theme={null} { "quality": "production" } ``` ## Step 5: Distribute to sellers A strategist presses Launch while publisher connections light up in sequence Now the finished creatives need to reach each seller. Maya's platform calls `sync_creatives` on each one — same creative, adapted to each seller's required format: Creative agency platform distributes creatives via sync_creatives to three sellers: CTV seller returns pending review, Display seller returns approved, Social platform returns pending review for community guidelines Different sellers have different review processes. Pinnacle auto-approves based on brand safety rules. StreamHaus and CommHub do manual review. Maya's platform tracks approval state per seller — a creative approved on one and pending on another is normal, not an error. CommHub also checks community guidelines beyond standard ad policy. If a promoted post is rejected from a specific community, `list_creatives` shows the `rejection_reason` referencing that community's rules. ## Step 6: Campaign runs — AI generates variants The campaign goes live. Here's where it gets interesting. On StreamHaus, the CTV spot runs as-is — one creative, one variant (Tier 1). On Pinnacle, the display ads run with asset group optimization — the platform tests different headline and image combinations (Tier 2). On CommHub, the social platform generates promoted posts that match each community's voice and trending topics (Tier 3). Maya doesn't manage any of this. The protocol handles it. But she can see everything that happened. ## Step 7: Review delivery and variants A unified dashboard merging data from three sellers into combined performance charts One week in, Maya pulls delivery data. Her platform calls `get_creative_delivery` on each agent and merges the results: Three sellers report delivery data back to agency platform: CTV seller shows 1 variant with 150K impressions, Display seller shows 4 variants with 200K impressions, Social platform shows 12 AI-generated variants with 85K impressions plus engagement metrics The social platform's response includes engagement metrics in the `ext` field — upvotes, comments, shares — alongside standard delivery metrics. Display variants include the manifest showing which headline/image combination each variant used. CTV includes completion rates and quartile data. ## Step 8: Replay what ran A strategist views a grid of ad variants with performance ratings, one highlighted as top performer Maya wants to see exactly what CommHub's AI generated for the hiking community. She finds the top-performing variant in the delivery data and replays it: ```json theme={null} { "request_type": "variant", "variant_id": "gen_hiking_community_v3" } ``` The platform renders exactly what was served — the generated headline, the community-adapted imagery, the engagement UI. Maya can see that the AI leaned into trail photography and used language that resonated with the hiking community. She takes this insight back to her next brief. ## Step 9: Pricing and billing Creative agents can charge for their services. Pinnacle's account with each creative agent includes a rate card — the agreed pricing. For the ad server, Maya's platform calls `list_creatives` and each creative includes `pricing_options`. For the transformation agent, pricing appears on `list_creative_formats` instead — the format is the product. When `build_creative` runs, the response includes `vendor_cost` so the platform knows what each build cost. After the campaign, Maya's platform calls `report_usage` on each creative agent with the `creative_id` and `pricing_option_id` from the build response. This closes the billing loop — the creative agent can verify the reported cost matches its rate card. Different agents price differently. The CTV ad server charges CPM (cost per thousand impressions served). The transformation agent charges per format adapted. The AI generation platform charges per image generated. The protocol handles all of these through the same `pricing_options` pattern — see the [pricing specification](/dist/docs/3.1.20/creative/specification#pricing) for details. ## The full picture The full creative lifecycle: Write brief, Discover formats, Generate and preview, Iterate and approve, Distribute to sellers, Campaign runs, Review delivery and replay variants, then Inform next brief — completing the cycle Every step uses a standard AdCP task. Every agent — creative tools, publishers, social platforms — speaks the same protocol. Maya writes one brief, and AdCP handles the rest: format adaptation, multi-agent routing, cross-seller distribution, variant tracking, unified reporting, and billing reconciliation. ## Go deeper * **Key concepts**: [Assets, formats, manifests, and creative agents](/dist/docs/3.1.20/creative/key-concepts) — the building blocks behind this walkthrough * **AI creative**: [AI creative for campaign teams](/dist/docs/3.1.20/creative/ai-creative-overview) — the strategic view for non-engineers * **Generative creative**: [Generative creative](/dist/docs/3.1.20/creative/generative-creative) — Tier 1, 2, and 3 in depth * **Build an agent**: [Implementing creative agents](/dist/docs/3.1.20/creative/implementing-creative-agents) — make your platform speak AdCP * **Orchestrate**: [Multi-agent orchestration](/dist/docs/3.1.20/creative/multi-agent-orchestration) — the engineering patterns behind Maya's platform * **Get certified**: The [Buyer track](/dist/docs/3.1.20/learning/tracks/buyer) teaches the full creative workflow through interactive modules # Key concepts Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/key-concepts Assets, formats, manifests, and creative agents are the four building blocks of the AdCP Creative Protocol for programmatic ad creative. > **Canonical formats in 3.1**: this page describes the v1 model. For the canonical-formats architectural shift (inline `format_options` on products, production-source axis, `validate_input` primitive), see [canonical-formats](/dist/docs/3.1.20/creative/canonical-formats). One upload, every format. This guide explains how creatives work in AdCP, from defining format requirements to assembling and delivering ads. ## The Four Key Concepts ### 1. **Assets** Assets are the individual building blocks used to compose a creative. Each asset has a defined type that determines how it is used and validated. **Examples:** * An image file (PNG, JPG, WebP) * A video file (MP4, WebM, MOV) * A block of text (headline or CTA) * An audio file (MP3, M4A) * An HTML or JavaScript tag * A tracking or clickthrough URL ### 2. **Formats** Formats define how assets are assembled and rendered, based on IAB format taxonomy. A format specifies: * Media family (e.g., Display, Video, Audio, Native) * The required asset types * Technical constraints (duration, dimensions, codecs, file size) * Rendering and interaction expectations **Examples:** * A Video format may require one video asset (MP4, 30 seconds, defined resolution and codec) and one clickthrough URL * A Display format may require one or more image or HTML assets, optional text assets, and tracking URLs ### 3. **Manifests** Manifests define a creative preset: a reusable configuration that captures what a brand wants to say and show, independent of where or how it is ultimately delivered. A manifest does not describe an ad format or rendering logic. Instead, it declares a named set of creative choices—media, messaging, destinations, and tracking intent—that can be applied consistently wherever a compatible format is supported. **Example**: A manifest for "video\_30s" provides the URL to your actual 30-second video file, plus tracking pixels and landing page URL. ### 4. **Creative Agents** Creative agents are services that: * Define and document formats (authoritative source) * Explain how each format renders * Validate manifests against format requirements * Generate previews showing how creatives will appear * Optionally build manifests from natural-language briefs Each format identifies its authoritative creative agent. ## How They Fit Together ``` Format Definition (by Creative Agent) ↓ "video_30s format requires: - One video file asset (MP4, 30s, 1920x1080) - One clickthrough URL" Creative Manifest (by Buyer) ↓ "Here's my actual video file: https://cdn.brand.com/spring_30s.mp4 Landing page: https://brand.com/spring-sale" Sales Agent (validates & delivers) ↓ - Checks: Is this really 30 seconds? Is it MP4? - Adds: Impression tracking, click tracking - Delivers: Creative to ad server ``` ## The Workflow ### 1. **Discovery** - "What formats do you support?" Buyers call `list_creative_formats` on sales or creative agents to discover available formats and their full specifications. Each format includes an `agent_url` identifying the creative agent that is authoritative for that format. See [Creative Formats](/dist/docs/3.1.20/creative/formats) for format discovery details. ### 2. **Assembly** - "Here are my assets" Buyers create manifests that provide assets fulfilling the selected format's requirements. Manifests pair format specifications with actual asset URLs, text, and tracking data. See [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) for manifest structure details. ### 3. **Validation** - "Does this match the requirements?" Creative agents validate manifests by checking: * Are all required assets present? * Do assets meet technical constraints (duration, dimensions, file type, file size)? * Are tracking URLs and macros correctly formatted? ### 4. **Delivery** - "Traffic this to the ad server" Sales agents deliver validated creatives to their ad servers, translating AdCP universal concepts to platform-specific formats. ## Core Concepts ### Assets & Asset Types Assets are the raw materials of a creative. Each asset has a type that defines its role: * **image**: Static images (JPEG, PNG, WebP) * **video**: Video files (MP4, WebM, MOV) or VAST tags * **audio**: Audio files (MP3, M4A) or DAAST tags * **text**: Headlines, descriptions, CTAs * **html**: HTML5 creatives or third-party tags * **javascript**: JavaScript tags * **url**: Tracking pixels, clickthrough URLs, webhooks See [Asset Types](/dist/docs/3.1.20/creative/asset-types) for detailed specifications. ### Formats & Format Authority Each format has an authoritative source—the creative agent that defines it (indicated by `agent_url`). That agent: * Hosts the definitive documentation * Explains how to assemble assets * Describes how the format renders * Provides validation rules **Standard vs. Custom Formats:** * **Standard formats** are based on IAB specifications and hosted by the reference creative agent (`https://creative.adcontextprotocol.org`) * **Custom formats** are defined by individual publishers or creative platforms for specialized inventory * Technically, both work the same way—the `agent_url` field identifies which agent is authoritative for each format See the [Channel Guides](/dist/docs/3.1.20/creative/channels/video) for format examples and patterns across video, display, audio, DOOH, and carousels. ### Manifests Manifests are JSON structures that pair format-defined asset IDs with actual asset content. They supply the URLs, text values, and tracking endpoints required to assemble a complete creative. See [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) for detailed documentation. ### Universal Macros AdCP defines universal macros that work across platforms. Sales agents translate these macros into their ad server's native syntax. * **For impression tracking**: Sales agents translate AdCP macros into the ad server's native macro format before serving the impression tracking URL. * **For click tracking**: In addition to macro translation, sales agents replace `{REDIRECT_URL}` with the final destination click URL and serve the click tracking URL as the destination URL in the ad server, ensuring proper redirection after the click is recorded. See [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) for complete reference. ## Common Patterns ### Third-Party Tags Some formats support creatives that are served by third-party systems rather than assembled from first-party media elements. In this pattern, the creative is provided as an HTML tag or a JavaScript tag. The format definition specifies the required element type and any applicable constraints (such as sandboxing or tracking expectations). Rendering, asset loading, and interaction logic are handled externally by the third-party system. This pattern is most commonly used with Display formats. See the [Display Channel Guide](/dist/docs/3.1.20/creative/channels/display) for third-party tag examples. ### Repeatable Asset Groups Some formats allow or require repeated sets of creative elements under a common structure. This pattern is used for creatives such as carousels, slideshows, story-style sequences, and playlists or product lists. Each repetition contains the same logical element roles (for example: image, text, and URL), with the number of repetitions defined or constrained by the format. Repeatable element groups describe structural patterns, not formats or layout guarantees, and may be used across Display, Native, or other compatible formats. See the [Carousel & Multi-Asset Formats](/dist/docs/3.1.20/creative/channels/carousels) guide for detailed documentation. ### DOOH Impression Tracking Digital Out-of-Home (DOOH) environments require different measurement semantics than personal-device environments. In this pattern: * Impressions are tracked using venue- or screen-level identifiers * Device-based identifiers are not used * Tracking relies on environment-specific macros This affects measurement and reporting only and does not change creative format classification or asset requirements. See the [DOOH Channel Guide](/dist/docs/3.1.20/creative/channels/dooh) for DOOH-specific macro details. ## Channel-Specific Information For detailed information on creatives by channel and format family, see [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests), including: * **Video Ads** - In-stream and outstream video formats (including CTV environments) * **Display Ads** - Standard display formats (e.g., banners and interstitials) * **Audio Ads** - In-stream audio formats (with optional companion display) * **DOOH** - Digital out-of-home inventory and venue-based delivery environments ## Getting Started 1. **Discover formats**: Call `list_creative_formats` to see what's available 2. **Choose your channel guide**: Pick the guide that matches your campaign type 3. **Build your manifest**: Follow the format requirements 4. **Use universal macros**: Add tracking with standardized macros 5. **Preview**: Use `preview_creative` to see how it looks 6. **Submit**: Include manifests in your `create_media_buy` request ## Additional Resources * [Creative Task Reference](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) - API documentation for creative tasks * [Generative Creative](/dist/docs/3.1.20/creative/generative-creative) - AI-powered creative generation guide # Multi-agent creative orchestration Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/multi-agent-orchestration Multi-agent creative orchestration in AdCP routes requests across agents, distributes assets to sellers, and aggregates delivery data. Agency platforms and holding company systems often sit between a brand team and dozens of agents: creative tools, ad servers, and sales agents across publishers. The orchestrator's job is to route creative requests to the right agent, distribute finished creatives to sellers, and aggregate delivery data back into a unified view. Sequence diagram showing an orchestrator calling get_adcp_capabilities, list_creative_formats, build_creative, sync_creatives, and get_creative_delivery across multiple agents This page covers the patterns for building that orchestration layer on top of AdCP. ## The orchestrator role An orchestrator is a buyer-side system that connects to multiple AdCP agents and coordinates creative workflows across them. It does not implement the Creative Protocol itself — it consumes it. Typical orchestrators include agency platforms (think a holding company's internal toolchain), brand-side creative hubs, and multi-publisher campaign management systems. The orchestrator's responsibilities: * **Discover** what each connected agent can do * **Route** creative requests to the agent best suited for the job * **Distribute** finished creatives to every seller that needs them * **Aggregate** delivery data across agents into a single reporting view ## Capability discovery Before routing any requests, the orchestrator needs a map of what each agent supports. Call `get_adcp_capabilities` on every connected agent and index the `creative` section of each response. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["creative"], "creative": { "has_creative_library": true, "supports_generation": false, "supports_transformation": true, "supports_compliance": false } } ``` Build a capability map from the responses: | Agent | `supports_generation` | `supports_transformation` | `has_creative_library` | | ----------------------------------------- | --------------------- | ------------------------- | ---------------------- | | `https://creative.novastudio-example.com` | true | true | false | | `https://ads.flashtalking-example.com` | false | false | true | | `https://sales.pinnaclemedia-example.com` | true | true | true | Cache this map and refresh it periodically — the `last_updated` field on the capabilities response tells you when an agent's capabilities last changed. ## Format discovery across agents Call `list_creative_formats` on each agent in parallel. Each agent returns a `formats` array where every format includes a `format_id` with an `agent_url` identifying which agent owns that format definition. ```json theme={null} { "formats": [ { "format_id": { "agent_url": "https://creative.novastudio-example.com", "id": "display_300x250" }, "name": "Display 300x250", "renders": [{ "role": "primary", "dimensions": { "width": 300, "height": 250, "unit": "px" } }] } ] } ``` Merge the results into a unified format catalog. When the same standard format (same dimensions, same type) is available from multiple agents, keep all entries — the `agent_url` in each `format_id` distinguishes them. At routing time, prefer the agent whose capabilities best match the request (a generative agent for briefs, a library agent for tag retrieval). Some agents also return a `creative_agents` array pointing to additional agents the orchestrator can query. Follow these references to discover formats from agents not already in your connection list, but track visited URLs to avoid infinite loops. ## Routing creative requests Use the capability map and format catalog to route each request to the right agent. **Brief with no existing creative** — the brand team provides creative direction but no assets. Route to an agent with `supports_generation: true`. The agent accepts a natural language brief via `build_creative` and returns a manifest with generated assets. **Existing creative needs resizing** — the buyer has a manifest for one format and needs it adapted to another. Route to an agent with `supports_transformation: true`. Pass the existing `creative_manifest` and a `target_format_id` for the desired output. **Retrieve tags from an ad server** — the creative already exists in a platform library. Route to the agent with `has_creative_library: true` that hosts it. Pass the `creative_id` and `target_format_id` to `build_creative` to get a serving tag. **Target format determines the agent** — when the request targets a specific format (CTV, DOOH, a publisher's proprietary unit), route to the agent whose `format_id.agent_url` matches. That agent is the authority for that format and will produce the most reliable output. When multiple agents qualify, prefer agents that combine capabilities. A sales agent with `supports_generation: true` and `has_creative_library: true` can generate a creative and store it in one step, avoiding a separate sync. ## Build once, distribute to many The core multi-agent workflow: generate or build a creative once, then distribute it to every seller that needs it. ### Step 1: Build the creative Call `build_creative` on your creative agent to produce a manifest: ```json theme={null} { "message": "Create a holiday display campaign for Acme Corp featuring winter products", "target_format_id": { "agent_url": "https://creative.novastudio-example.com", "id": "display_300x250" }, "concept_id": "concept_holiday_2026" } ``` The agent returns a manifest with the generated assets. Assign your own `creative_id` to the result — this ID stays consistent everywhere you send the creative. ### Step 2: Sync to each seller Call `sync_creatives` on each sales agent, using the same `creative_id` and `concept_id`: ```json Pinnacle Media theme={null} { "account": { "account_id": "acct_acme_pinnacle" }, "creatives": [{ "creative_id": "acme_holiday_300x250", "name": "Holiday 2026 - Medium Rectangle", "concept_id": "concept_holiday_2026", "format_id": { "agent_url": "https://creative.novastudio-example.com", "id": "display_300x250" }, "assets": { "image": { "url": "https://cdn.acme-example.com/holiday-300x250.png", "width": 300, "height": 250 }, "click_url": { "url": "https://acme-example.com/holiday-sale" } } }], "assignments": [{ "creative_id": "acme_holiday_300x250", "package_id": "pkg_premium_display" }] } ``` ```json Nova Sports theme={null} { "account": { "account_id": "acct_acme_novasports" }, "creatives": [{ "creative_id": "acme_holiday_300x250", "name": "Holiday 2026 - Medium Rectangle", "concept_id": "concept_holiday_2026", "format_id": { "agent_url": "https://creative.novastudio-example.com", "id": "display_300x250" }, "assets": { "image": { "url": "https://cdn.acme-example.com/holiday-300x250.png", "width": 300, "height": 250 }, "click_url": { "url": "https://acme-example.com/holiday-sale" } } }], "assignments": [{ "creative_id": "acme_holiday_300x250", "package_id": "pkg_sports_display" }] } ``` Both calls use the same `creative_id` (`acme_holiday_300x250`) and `concept_id` (`concept_holiday_2026`). These become the keys for cross-agent correlation later. ### Step 3: Track approval status Each seller reviews the creative independently. Poll `list_creatives` on each agent to check status: ```json theme={null} { "filters": { "creative_ids": ["acme_holiday_300x250"] } } ``` The `status` field on each creative tells you where it is: `processing`, `pending_review`, `approved`, `rejected`, or `archived`. Build a consolidated view: | Seller | `creative_id` | Status | | -------------- | ---------------------- | ---------------- | | Pinnacle Media | `acme_holiday_300x250` | `approved` | | Nova Sports | `acme_holiday_300x250` | `pending_review` | A creative can be approved by one seller and rejected by another — each seller applies its own policies. See [creative review](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities#creative-review) for how status transitions work. ## Cross-agent delivery aggregation Once creatives are live, call `get_creative_delivery` on each agent to collect performance data. The response includes a `creatives` array with variant-level breakdowns: ```json theme={null} { "reporting_period": { "start": "2026-11-01T00:00:00-05:00", "end": "2026-11-15T00:00:00-05:00", "timezone": "America/New_York" }, "currency": "USD", "creatives": [{ "creative_id": "acme_holiday_300x250", "format_id": { "agent_url": "https://creative.novastudio-example.com", "id": "display_300x250" }, "totals": { "impressions": 145000, "clicks": 2900, "spend": 1450.00 }, "variant_count": 3, "variants": [{ "variant_id": "var_a1b2c3", "impressions": 80000, "clicks": 1700, "spend": 800.00 }] }] } ``` ### Correlating across agents **`creative_id`** is the primary correlation key. Because you assigned the same `creative_id` when syncing to each seller, you can match delivery records across agents directly. **`concept_id`** groups related creatives. When you need aggregate metrics for the "Holiday 2026" campaign across all sizes and sellers, filter by `concept_id` on each agent's `list_creatives` to get the set of `creative_id` values, then pull delivery for all of them. **`variant_id`** is scoped to a single agent and creative. Two agents may independently assign the same `variant_id` string. When aggregating variants across agents, prefix with the agent URL to create a globally unique key: `https://sales.pinnaclemedia-example.com/var_a1b2c3`. ### Normalizing timezones Each agent reports in its own timezone via `reporting_period.timezone`. Before summing metrics across agents, convert all timestamps to a common timezone. The `timezone` field uses IANA identifiers (`America/New_York`, `Europe/London`, `UTC`), so standard timezone libraries handle conversion. ## `concept_id` as a correlation key Concepts are buyer-assigned groupings — the protocol does not enforce them. The orchestrator decides what constitutes a concept and assigns the `concept_id` consistently when syncing related creatives to different agents. A typical mapping: one concept per campaign idea, with multiple creatives per concept (different sizes, formats, or variations). ``` concept_holiday_2026 ├── acme_holiday_300x250 (display, synced to Pinnacle Media + Nova Sports) ├── acme_holiday_728x90 (display, synced to Pinnacle Media) └── acme_holiday_video_30s (video, synced to Nova Sports) ``` Assign `concept_id` at sync time via the `concept_id` field on each creative in `sync_creatives`. Use it to: * Filter `list_creatives` with `concept_ids` to see all creatives in a concept on a given agent * Group `get_creative_delivery` results by concept for roll-up reporting * Track approval status across sizes and sellers for the same campaign idea ## Error handling across agents Multi-agent operations produce partial failures. One seller accepts a creative while another rejects it, or an agent is temporarily unreachable. Design for this from the start. ### Partial sync failures `sync_creatives` returns per-creative results. Check the `action` field on each item in the response: ```json theme={null} { "creatives": [ { "creative_id": "acme_holiday_300x250", "action": "created" }, { "creative_id": "acme_holiday_728x90", "action": "failed", "errors": ["Format not supported"] } ] } ``` When the response has top-level `errors` instead of `creatives`, the entire operation failed (authentication, network, invalid request). Retry the whole call. When individual creatives fail within a successful operation, handle them individually — fix the issue and re-sync just the failed creatives using the `creative_ids` filter: ```json theme={null} { "account": { "account_id": "acct_acme_pinnacle" }, "creative_ids": ["acme_holiday_728x90"], "creatives": [{ "creative_id": "acme_holiday_728x90", "name": "Holiday 2026 - Leaderboard", "format_id": { "agent_url": "https://creative.novastudio-example.com", "id": "display_728x90" }, "assets": { } }] } ``` ### Agent unavailability When an agent is unreachable, the orchestrator should: 1. Record which syncs or queries failed and for which agent 2. Continue processing other agents — do not block the entire workflow 3. Retry failed agents with exponential backoff 4. Use `idempotency_key` on `sync_creatives` so retries are safe ### Inconsistent approval states A creative approved on one seller and rejected on another is normal, not an error. The orchestrator should surface this clearly to the campaign team rather than treating it as a failure. If the rejection is due to a fixable issue (wrong aspect ratio, missing click URL), update the creative and re-sync to the rejecting seller only. ### Rate limiting and concurrency When calling many agents in parallel (common for cross-seller sync and delivery aggregation), expect that agents may rate-limit requests. Handle this with standard HTTP patterns: * Respect `429 Too Many Requests` responses and the `Retry-After` header * Use exponential backoff with jitter for retries * Set reasonable concurrency limits per agent (start with 5 concurrent requests per agent, adjust based on observed behavior) * Log rate-limit events per agent to identify which agents need lower concurrency Rate-limiting behavior is agent-specific and not standardized by AdCP. Some agents may return `429` with a `Retry-After` header; others may slow responses without explicit signaling. Build your orchestrator to handle both patterns. ## Specialized format orchestration The patterns on this page apply to all creative formats — display, video, CTV, conversational, audio, DOOH. The protocol-level operations (`list_creative_formats`, `sync_creatives`, `get_creative_delivery`) work identically regardless of format type. The format-specific differences are in asset requirements and preview behavior: * **CTV formats** produce multi-render previews (primary video + companion). See [CTV and connected TV](/dist/docs/3.1.20/creative/channels/ctv). * **Conversational formats** return `interactive_url` in previews for sandbox testing, and variant manifests include conversation transcripts. See [Conversational formats](/dist/docs/3.1.20/creative/generative-creative#conversational-and-interactive-formats). * **Feed-native/social formats** have platform-owned chrome that wraps buyer assets at render time. See [Pattern 4](/dist/docs/3.1.20/creative/implementing-creative-agents#pattern-4-feed-nativesocial-format-agent). The orchestrator does not need format-specific logic for routing or syncing — the `format_id.agent_url` and capability map handle that. Format-specific knowledge matters only when interpreting previews and delivery data for display to campaign teams. ## Next steps * [Orchestrator design patterns](/dist/docs/3.1.20/building/operating/orchestrator-design) — State machines, persistence, and retry patterns for multi-agent systems * [Creative libraries and concepts](/dist/docs/3.1.20/creative/creative-libraries) — Managing creatives in a single agent's library * [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities) — When the seller manages both media and creative * [Generative creative](/dist/docs/3.1.20/creative/generative-creative) — AI-powered creative generation workflows * [Specification](/dist/docs/3.1.20/creative/specification) — Full Creative Protocol specification and interaction models # Private assets Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/private-assets Private assets in AdCP use presigned URLs to grant temporary access to files stored in DAMs, S3 buckets, or authenticated sources. AdCP does not include an asset upload task. Creative agents are not expected to accept file uploads or manage storage on behalf of buyers. Instead, buyer agents are responsible for hosting their own assets and providing accessible URLs in [creative manifests](/dist/docs/3.1.20/creative/creative-manifests). When assets live in private storage — an internal DAM, a private S3 bucket, or behind authentication — the buyer agent must make them accessible before passing URLs in a manifest. ## Presigned URLs The recommended pattern is **presigned URLs**. Most cloud storage providers support generating time-limited URLs that grant temporary read access without requiring authentication headers. ### How it works 1. Buyer agent receives or locates a private asset (e.g., a brand logo in S3) 2. Buyer agent generates a presigned URL with a short expiration 3. Buyer agent passes the presigned URL in the creative manifest 4. Creative agent fetches the asset like any other public URL ```json theme={null} { "format_id": { "agent_url": "https://creatives.example.com", "id": "display_static", "width": 300, "height": 250 }, "assets": { "banner_image": { "url": "https://my-bucket.s3.amazonaws.com/brand/logo.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=...", "width": 300, "height": 250 }, "headline": { "content": "Spring collection" }, "clickthrough_url": { "url": "https://shop.example.com/spring" } } } ``` The only difference from a standard manifest is the URL itself. The `banner_image.url` contains presigned query parameters (`X-Amz-Algorithm`, `X-Amz-Expires`, `X-Amz-Signature`). No other fields change. ### Provider examples ```javascript AWS S3 theme={null} import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const client = new S3Client({ region: "us-east-1" }); const url = await getSignedUrl( client, new GetObjectCommand({ Bucket: "my-brand-assets", Key: "logos/primary.png", }), { expiresIn: 3600 } // 1 hour ); ``` ```javascript Google Cloud Storage theme={null} import { Storage } from "@google-cloud/storage"; const storage = new Storage(); const [url] = await storage .bucket("my-brand-assets") .file("logos/primary.png") .getSignedUrl({ action: "read", expires: Date.now() + 3600 * 1000, // 1 hour }); ``` ```javascript Cloudflare R2 theme={null} import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; const client = new S3Client({ region: "auto", endpoint: "https://.r2.cloudflarestorage.com", }); const url = await getSignedUrl( client, new GetObjectCommand({ Bucket: "my-brand-assets", Key: "logos/primary.png", }), { expiresIn: 3600 } ); ``` ```javascript Azure Blob Storage theme={null} import { BlobServiceClient, generateBlobSASQueryParameters, BlobSASPermissions, StorageSharedKeyCredential } from "@azure/storage-blob"; const credential = new StorageSharedKeyCredential(accountName, accountKey); const sasToken = generateBlobSASQueryParameters({ containerName: "brand-assets", blobName: "logos/primary.png", permissions: BlobSASPermissions.parse("r"), expiresOn: new Date(Date.now() + 3600 * 1000), // 1 hour }, credential).toString(); const url = `https://${accountName}.blob.core.windows.net/brand-assets/logos/primary.png?${sasToken}`; ``` ## Expiration guidelines Set presigned URL expiration long enough to cover the full workflow, but no longer than necessary. | Workflow | Suggested expiration | | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | | [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) only | 1 hour | | [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) + [`preview_creative`](/dist/docs/3.1.20/creative/task-reference/preview_creative) | 2 hours | | Full pipeline (build, preview, iterate, [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives)) | 4 hours | If a presigned URL expires mid-workflow, the creative agent will receive an HTTP error when fetching the asset. The buyer agent must generate a new presigned URL and resubmit the request. For assets that will be served at scale (e.g., a logo used across many impressions), use a CDN with long-lived public URLs instead of presigned URLs. Presigned query parameters defeat CDN caching since each generated URL is unique. ## Uploading local files For assets that don't already live in cloud storage — local files, Slack attachments, email attachments — the buyer agent should upload them to its own storage first, then generate a presigned URL. ```javascript AWS S3 theme={null} import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { readFile } from "fs/promises"; const client = new S3Client({ region: "us-east-1" }); // Upload the local file const fileBuffer = await readFile("./assets/logo.png"); await client.send(new PutObjectCommand({ Bucket: "my-brand-assets", Key: "logos/primary.png", Body: fileBuffer, ContentType: "image/png", })); // Generate a presigned URL for the creative agent to fetch const url = await getSignedUrl( client, new GetObjectCommand({ Bucket: "my-brand-assets", Key: "logos/primary.png", }), { expiresIn: 3600 } ); ``` ```javascript Google Cloud Storage theme={null} import { Storage } from "@google-cloud/storage"; const storage = new Storage(); const bucket = storage.bucket("my-brand-assets"); // Upload the local file await bucket.upload("./assets/logo.png", { destination: "logos/primary.png", contentType: "image/png", }); // Generate a presigned URL for the creative agent to fetch const [url] = await bucket .file("logos/primary.png") .getSignedUrl({ action: "read", expires: Date.now() + 3600 * 1000, }); ``` ## Why not auth headers? AdCP manifests are declarative data passed between agents as JSON. Adding per-URL authentication headers would mean sharing storage credentials across trust boundaries — every system that touches the manifest (creative agent, preview service, ad server, logging infrastructure) would need to handle those credentials securely. Presigned URLs avoid this by encoding authorization into the URL itself: * Scoped to a single object with read-only access * Time-limited with built-in expiration * No credential forwarding required * Revocation is automatic (the URL expires) ## Related * [Creative manifests](/dist/docs/3.1.20/creative/creative-manifests) — manifest structure and asset references * [Asset types](/dist/docs/3.1.20/creative/asset-types) — requirements for each asset type * [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) — generating creatives from manifests * [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) — syncing creatives to an agent-hosted creative library # AI provenance and disclosure Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/provenance AdCP provenance metadata declares AI involvement, tools used, and regulatory disclosure obligations on creative assets and manifests. Provenance metadata declares how creative content was produced — whether AI was involved, which tools were used, and what disclosure obligations the declaring party believes apply. Regulations like the EU AI Act and California SB 942 place disclosure obligations on deployers and covered platforms; AdCP carries the structured provenance signals those parties rely on through the programmatic supply chain so every participant can declare, transmit, and verify the same data. Provenance attaches to creative manifests, individual assets, or content-standards artifacts. It is a claim by the declaring party — receiving parties verify claims independently using their own detection tools. EU AI Act Article 50 enforcement begins August 2026. California SB 942 is already in effect. Major platforms mandate AI content labeling today. AdCP carries the structured, machine-readable provenance and disclosure metadata these regulations rely on through the programmatic supply chain, where no standard for it previously existed. The protocol moves the data; the legal obligation still sits with the deployer making the disclosure to the end user. ## The provenance object Provenance is an optional object that can attach to creative assets, creative manifests, individual typed assets, and content artifacts. No fields are required at the provenance level -- each section is independently useful. **Schema**: [provenance.json](https://adcontextprotocol.org/schemas/3.1.20/core/provenance.json) | Field | Type | Description | | --------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `digital_source_type` | enum | IPTC-aligned classification of AI involvement | | `ai_tool` | object | AI system used (`name` required, plus optional `version` and `provider`) | | `human_oversight` | enum | Level of human involvement in the creation process | | `declared_by` | object | Party attaching this provenance claim (`role` required, plus optional `agent_url`) | | `declared_at` | string (date-time) | When this provenance claim was made (ISO 8601), distinct from `created_time` | | `created_time` | string (date-time) | When the content was created (ISO 8601) | | `c2pa` | object | C2PA sidecar manifest reference (`manifest_url` required). Breaks under ad-server transcoding — use `embedded_provenance` for pipelines with intermediaries | | `embedded_provenance` | array | Provenance metadata embedded *within* the content stream (manifest wrapper or invisible markers); survives transcoding and re-encoding | | `watermarks` | array | Content watermarks encoding an identifier or fingerprint; survives perceptual transformations | | `disclosure` | object | Regulatory disclosure requirements and jurisdiction details | | `verification` | array | Third-party verification or detection results | | `ext` | object | Standard extension point | ### Minimal example Most provenance declarations answer one question: **was this AI-generated, and does it need a disclosure label?** ```json theme={null} { "$schema": "/schemas/3.1.20/core/provenance.json", "digital_source_type": "trained_algorithmic_media", "disclosure": { "required": true } } ``` That's it. `digital_source_type` says how the content was produced. `disclosure.required` says whether it needs a label. Everything else — tool details, C2PA references, jurisdiction-specific render guidance, verification results — is optional context that supply-chain participants can add when they need it. For content with no AI involvement, provenance is even simpler: ```json theme={null} { "$schema": "/schemas/3.1.20/core/provenance.json", "digital_source_type": "digital_capture" } ``` ### Full example ```json theme={null} { "$schema": "/schemas/3.1.20/core/provenance.json", "digital_source_type": "trained_algorithmic_media", "ai_tool": { "name": "DALL-E 3", "version": "3.0", "provider": "OpenAI" }, "human_oversight": "selected", "declared_by": { "agent_url": "https://creative.pinnaclemedia.example.com", "role": "agency" }, "declared_at": "2026-02-15T14:35:00Z", "created_time": "2026-02-15T14:30:00Z", "c2pa": { "manifest_url": "https://cdn.pinnaclemedia.example.com/c2pa/manifests/hero_img_abc123.c2pa" }, "disclosure": { "required": true, "jurisdictions": [ { "country": "US", "region": "CA", "regulation": "ca_sb_942", "label_text": "Created with AI", "render_guidance": { "persistence": "flexible", "positions": ["prominent", "footer"] } }, { "country": "DE", "regulation": "eu_ai_act_article_50", "label_text": "KI-generiert", "render_guidance": { "persistence": "continuous", "positions": ["overlay", "subtitle"] } } ] }, "verification": [ { "verified_by": "Reality Defender", "verified_time": "2026-02-15T15:00:00Z", "result": "ai_generated", "confidence": 0.97, "details_url": "https://realitydefender.example.com/reports/abc123" } ] } ``` ## Digital source type The `digital_source_type` enum classifies AI involvement in content production, aligned with the [IPTC digitalsourcetype vocabulary](https://cv.iptc.org/newscodes/digitalsourcetype/). **Schema**: [digital-source-type.json](https://adcontextprotocol.org/schemas/3.1.20/enums/digital-source-type.json) | Value | Description | When to use | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `digital_capture` | Captured by a digital device (camera, scanner, screen recording) with no AI involvement | Photos from a product shoot, screen recordings of app demos | | `digital_creation` | Created by a human using digital tools (Photoshop, Illustrator, After Effects) without AI generation | Hand-designed banner ads, manually composed layouts | | `trained_algorithmic_media` | Generated entirely by a trained AI model (DALL-E, Midjourney, Stable Diffusion, Sora) | AI-generated hero images, AI-produced video spots | | `composite_with_trained_algorithmic_media` | Human-created content combined with AI-generated elements | Product photo with AI-generated background, human-shot video with AI visual effects | | `algorithmic_media` | Produced by deterministic algorithms without machine learning (procedural generation, rule-based systems) | Programmatic visualizations, procedural pattern generation | | `composite_capture` | Multiple digital captures composited together without AI | Panoramic stitching, multi-exposure HDR composites | | `composite_synthetic` | Composite of multiple elements where at least one is AI-generated | Stock photo composited with AI-generated background, AI text overlay on captured video | | `human_edits` | Content augmented, corrected, or enhanced by humans using non-generative tools | Color-corrected product photography, manually retouched portraits, human copy editing | | `data_driven_media` | Assembled from structured data feeds (DCO templates, product catalogs, weather-triggered variants) | Dynamic creative optimization, catalog-driven product carousels, weather-responsive ads | ### Choosing the right value For mixed-production creatives, choose the value that best describes the **overall creative** at the level where provenance is attached. If you need to distinguish AI involvement per-asset, attach provenance at the individual asset level instead (see [Inheritance](#inheritance) below). Common patterns: * **AI image + human copy**: Attach `trained_algorithmic_media` to the image asset, `digital_creation` to the text asset, and `composite_with_trained_algorithmic_media` at the manifest level * **DCO with AI-generated headlines**: `data_driven_media` at the manifest level, `trained_algorithmic_media` on the AI-generated text assets * **Human photographer + AI background removal**: `composite_with_trained_algorithmic_media` at the manifest level ## Human oversight The `human_oversight` enum describes the level of human involvement in an AI-assisted creation process. | Value | Description | | ------------- | -------------------------------------------------------------------- | | `none` | Fully automated with no human involvement in generation | | `prompt_only` | Human provided the prompt or instructions but did not review outputs | | `selected` | Human selected from multiple AI-generated candidates | | `edited` | Human edited or refined AI-generated output | | `directed` | Human directed the creative process with AI as an assistive tool | This field is relevant when `digital_source_type` indicates AI involvement. For non-AI content, omit it. `human_oversight` and `disclosure.required` are independent fields, and the protocol does not derive one from the other. Some regulations carve out human-edited or human-directed AI output from disclosure obligations (for example, EU AI Act Article 50(4) where a human takes editorial responsibility), and the protocol leaves that determination to the declaring party's legal analysis. Asserting `human_oversight: edited` or `directed` does not by itself justify `disclosure.required: false` — the carve-out has factual prerequisites the schema cannot evaluate. Sellers and governance agents may treat the combination as an audit-worthy claim and require corroborating evidence from the declaring party. Governance agents surface this audit-worthy pattern through `get_creative_features.audit_observations[]` using `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED`. That observation is not a rejection code by itself; it lets sellers retain or route the claim for audit while the creative continues through normal review. ## Inheritance Provenance attaches at three levels in the creative hierarchy. The most specific provenance wins, and replacement is **full-object** -- there is no field-level merging. ``` creative-asset.provenance (1) default for the creative in the library creative-manifest.provenance (2) default for this manifest individual asset .provenance (3) override for a specific asset ``` ### Resolution rules 1. If an individual asset has `provenance`, use it 2. Otherwise, if the manifest has `provenance`, use it 3. Otherwise, if the creative asset has `provenance`, use it 4. Otherwise, no provenance is declared for that asset ### Example: mixed creative A creative where the image is AI-generated but the copy is human-written. The manifest-level provenance covers the overall creative. The image asset overrides with its own, more specific provenance. ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-manifest.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "provenance": { "digital_source_type": "composite_with_trained_algorithmic_media", "declared_by": { "role": "agency" } }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.novabrands.example.com/hero_ai.jpg", "width": 300, "height": 250, "provenance": { "digital_source_type": "trained_algorithmic_media", "ai_tool": { "name": "DALL-E 3", "version": "3.0", "provider": "OpenAI" }, "human_oversight": "selected", "declared_by": { "role": "agency" }, "c2pa": { "manifest_url": "https://cdn.novabrands.example.com/c2pa/hero_ai.c2pa" } } }, "headline": { "asset_type": "text", "content": "Nutrition dogs love" }, "clickthrough_url": { "asset_type": "url", "url": "https://novabrands.example.com/products?campaign={MEDIA_BUY_ID}" } } } ``` In this example: * `banner_image` uses its own provenance: `trained_algorithmic_media` with full AI tool details * `headline` inherits the manifest-level provenance: `composite_with_trained_algorithmic_media` * `clickthrough_url` also inherits the manifest-level provenance Note that the image's provenance is a complete replacement. Even though the manifest-level provenance has `declared_by`, the image asset must re-declare it in its own provenance object if that information should carry through. ### Artifact inheritance For content artifacts (publisher content), the same pattern applies: ``` artifact.provenance (1) default for the artifact artifact.assets[].provenance (2) override for a specific inline asset ``` ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/artifact.json", "property_rid": "01916f3a-a1d3-7000-8000-000000000030", "artifact_id": "article_ai_trends_2026", "provenance": { "digital_source_type": "digital_creation", "declared_by": { "role": "platform" } }, "assets": [ { "type": "text", "role": "title", "content": "AI trends reshaping the industry in 2026" }, { "type": "image", "url": "https://cdn.aimagazine.example.com/illustration.jpg", "alt_text": "Conceptual illustration of neural networks", "provenance": { "digital_source_type": "trained_algorithmic_media", "ai_tool": { "name": "Midjourney", "version": "v7" }, "human_oversight": "directed", "declared_by": { "role": "platform" } } } ] } ``` The article text inherits `digital_creation` from the artifact. The illustration overrides with its own `trained_algorithmic_media` provenance. ## Trust model Provenance is a **claim** by the declaring party. It is not proof. The enforcing party should verify independently. In advertising, the party declaring provenance and the party enforcing it have competing incentives. A buyer submitting a creative has reason to claim the content is human-made — AI-generated creatives may face placement restrictions, mandatory disclosure labels, or outright rejection on certain inventory. A seller accepting that creative has the opposite incentive: publishing AI-generated content without proper disclosure creates regulatory liability for the publisher, not the advertiser. AdCP handles this tension by treating provenance as a claim, not a fact. The buyer declares; the seller verifies. Verification happens at each enforcement point independently, using AI detection services (via `get_creative_features`), C2PA manifest validation, or both. No party needs to trust any other party's assertion. The protocol provides the structure for claims and the integration points for verification — the supply chain provides the adversarial pressure that keeps both honest. The `declared_by` field identifies who attached the provenance claim. The `verification` array carries any detection results the declaring party wants to disclose. But the party enforcing a provenance requirement runs its own verification through existing governance infrastructure. ```mermaid theme={null} sequenceDiagram participant Buyer as Buyer Agent participant Seller as Seller Agent participant Detector as AI Detection Agent Note over Buyer: 1. DECLARATION Buyer->>Seller: sync_creatives with provenance
digital_source_type: "digital_capture" Note over Seller: 2. POLICY CHECK Seller->>Seller: creative_policy.provenance_required = true
Provenance present? Yes Note over Seller: 3. INDEPENDENT VERIFICATION Seller->>Detector: get_creative_features
(creative manifest) Detector-->>Seller: feature_id: "ai_generated"
value: true, confidence: 0.94 Note over Seller: 4. ENFORCEMENT Seller->>Seller: Buyer claims "digital_capture"
Detection says "ai_generated"
Mismatch -- reject creative Seller-->>Buyer: Creative rejected:
"AI detection contradicts provenance claim" ``` ### Declaring party roles | Role | Description | | ------------ | ----------------------------------------------------------- | | `creator` | The party that created or generated the content | | `advertiser` | The brand or advertiser that owns the content | | `agency` | Agency acting on behalf of the advertiser | | `platform` | Ad platform or publisher that processed the content | | `tool` | Automated tool or service that attached provenance metadata | ### Buyer-attached verification The `verification` array on the provenance object lets the declaring party share detection results for transparency. Multiple services can independently evaluate the same content: ```json theme={null} { "verification": [ { "verified_by": "Hive Moderation", "verified_time": "2026-02-15T15:00:00Z", "result": "ai_generated", "confidence": 0.96, "details_url": "https://hive.example.com/reports/abc123" }, { "verified_by": "Reality Defender", "verified_time": "2026-02-15T15:05:00Z", "result": "ai_generated", "confidence": 0.93 } ] } ``` These results are **supplementary**. A seller that requires provenance verification runs its own detection through [`get_creative_features`](/dist/docs/3.1.20/governance/creative/get_creative_features) rather than trusting the buyer's attached results. Verification results use one of four outcomes: | Result | Description | | -------------- | ----------------------------------------------------------------------------- | | `authentic` | Content verified as non-AI-generated | | `ai_generated` | Content detected as AI-generated | | `ai_modified` | Content detected as AI-modified (original non-AI content with AI alterations) | | `inconclusive` | Detection was unable to reach a confident determination | ### Example: provenance through a campaign Acme Brands is running a spring campaign. Their agency, Meridian Media, uses an AI image generator to produce a set of display banners — photorealistic product shots with AI-generated backgrounds. Meridian attaches provenance to the creative manifest: `digital_source_type` is `composite_with_trained_algorithmic_media`, `ai_tool` identifies the generator, and `disclosure.required` is `true` with `eu_ai_act_article_50` and `ca_sb_942` listed as applicable regulations. For the EU jurisdiction, Meridian sets `render_guidance.persistence` to `continuous` with `positions` preferring `overlay` — expressing the EU AI Act's requirement for persistent labeling. The campaign is submitted to Pinnacle Publishing through AdCP. Pinnacle's ad operations platform checks the provenance claim, then runs the creative through its verification pipeline via `get_creative_features`. The AI detection service returns `ai_modified` with 0.94 confidence — consistent with the declared source type. With the claim verified, Pinnacle applies its own jurisdictional policy — as the covered platform under SB 942, it makes its own disclosure determination rather than relying on Meridian's `disclosure.required: true` as authoritative — reads the render guidance for the serving jurisdiction, applies the disclosure label with the specified persistence, and clears the creative for serving. The provenance metadata, the detection result, the render guidance, and the disclosure decision are all recorded and auditable. If Meridian had declared `digital_capture` instead — claiming no AI involvement — Pinnacle's detection service would have flagged the inconsistency. The creative would be held for review, not served. ## C2PA integration The `c2pa` field provides a soft reference to [C2PA Content Credentials](https://c2pa.org/) -- the cryptographic provenance standard developed by the Coalition for Content Provenance and Authenticity. ```json theme={null} { "c2pa": { "manifest_url": "https://cdn.acmecorp.example.com/c2pa/manifests/hero_abc123.c2pa" } } ``` ### Why a URL reference C2PA bindings are typically embedded in the media file itself. But ad tech pipelines routinely transcode, resize, and reformat creative assets -- breaking file-level C2PA bindings in the process. A URL reference to the original C2PA manifest store survives this transcoding, preserving the chain of provenance through the supply chain. The reference is a pointer, not a replacement for C2PA. Any party in the chain can fetch the manifest from the URL and verify the original content credentials, even after the media file has been transcoded. ### Usage pattern 1. Creator generates content and produces a C2PA manifest 2. Creator uploads the manifest store to a stable URL 3. Creator attaches the `manifest_url` in AdCP provenance 4. Downstream parties (agencies, platforms, sellers) can verify the original credentials at any time by fetching the manifest ## Disclosure requirements The `disclosure` object declares regulatory obligations for AI-generated content. ```json theme={null} { "disclosure": { "required": true, "jurisdictions": [ { "country": "US", "region": "CA", "regulation": "ca_sb_942", "label_text": "Created with AI", "render_guidance": { "persistence": "flexible", "positions": ["prominent", "footer"] } }, { "country": "DE", "regulation": "eu_ai_act_article_50", "label_text": "KI-generiert", "render_guidance": { "persistence": "continuous", "positions": ["overlay", "subtitle"] } }, { "country": "CN", "regulation": "cn_deep_synthesis", "label_text": "AI-generated content", "render_guidance": { "persistence": "initial", "min_duration_ms": 3000, "positions": ["overlay", "pre_roll"] } } ] } } ``` | Field | Required | Description | | ------------------------------------------------- | -------- | ------------------------------------------------------------------------------------- | | `required` | Yes | Whether AI disclosure is required based on applicable regulations | | `jurisdictions` | No | Array of jurisdictions where disclosure obligations apply | | `jurisdictions[].country` | Yes | ISO 3166-1 alpha-2 country code | | `jurisdictions[].region` | No | Sub-national region code (e.g., `CA` for California) | | `jurisdictions[].regulation` | Yes | Regulation identifier | | `jurisdictions[].label_text` | No | Required disclosure label text in the local language | | `jurisdictions[].render_guidance` | No | How the disclosure should be rendered for this jurisdiction | | `jurisdictions[].render_guidance.persistence` | No | How long the disclosure must persist: `continuous`, `initial`, or `flexible` | | `jurisdictions[].render_guidance.min_duration_ms` | No | Minimum display duration in milliseconds (required context for `initial` persistence) | | `jurisdictions[].render_guidance.positions` | No | Preferred disclosure positions in priority order (first supported wins) | ### Render guidance The `render_guidance` object on each jurisdiction expresses the declaring party's intent for how the disclosure should be rendered based on the regulation's requirements. Different regulations have different persistence requirements: * **`continuous`** — The disclosure must remain visible or audible throughout the content display duration. For video/audio, the full playback. For static formats (display, DOOH), the full display slot. For DOOH, "content duration" means the ad's display slot within the rotation, not the screen's full rotation cycle. * **`initial`** — The disclosure must appear at the start for a minimum duration before it may be removed. Pair with `min_duration_ms` to specify how long — without it, the duration is at the publisher's discretion. * **`flexible`** — Disclosure presence is sufficient; the publisher controls timing and duration. When multiple sources specify persistence for the same jurisdiction (e.g., brief `required_disclosures[].persistence` and provenance `render_guidance.persistence`), the most restrictive mode applies: `continuous` > `initial` > `flexible`. The `positions` array is an ordered preference list. The first position that the serving format supports should be used. For example, `["overlay", "subtitle"]` means "prefer overlay, fall back to subtitle if overlay is not available." Not all position-persistence combinations are meaningful. Positions with inherently bounded duration — `end_card`, `pre_roll` — cannot satisfy `continuous` persistence because they appear only for part of the content. Creative agents should not request `continuous` on these positions, and formats should not claim `continuous` support for them. For audio-only environments (podcast, streaming audio, smart speakers), only `audio`, `pre_roll`, and `companion` positions are applicable. Visual positions (`overlay`, `footer`, `subtitle`) are undefined without a screen. Creative agents building for audio formats should restrict `render_guidance.positions` to audio-compatible values. Render guidance travels with the creative through the supply chain. At serve time, the publisher reads the guidance from provenance and renders accordingly. Governance agents can audit whether the publisher followed the declared guidance. ### Multi-asset aggregation When a creative is assembled from multiple assets with different `render_guidance` for the same jurisdiction (common in DCO), the most restrictive persistence applies across the assembled creative: if any asset requires `continuous`, the assembled creative requires `continuous`. This follows the same precedence as conflict resolution: `continuous` > `initial` > `flexible`. ### Enforcement vs self-reported compliance For formats where the publisher controls the rendering surface (hosted video, display banners, SSAI), the publisher can enforce render guidance directly — rendering an overlay, controlling its duration, and verifying compliance. For opaque, self-rendering creatives (MRAID, JavaScript tags, VPAID), the creative controls its own viewport. The publisher cannot inject or enforce disclosure rendering inside the creative's sandbox. In these cases, disclosure compliance depends on the creative agent embedding the disclosure during build. The format's `disclosure_capabilities` should reflect this: only claim persistence modes the format's rendering layer can verify or enforce, not modes that rely on creative self-compliance. Governance agents can verify self-rendered disclosures post-hoc via `get_creative_features` by rendering the creative in a headless environment and inspecting for disclosure presence. ### Known regulation identifiers | Identifier | Regulation | Status | | ---------------------- | ------------------------------- | ----------------------- | | `eu_ai_act_article_50` | EU AI Act Article 50 | Enforcement August 2026 | | `ca_sb_942` | California SB 942 | Live since January 2026 | | `cn_deep_synthesis` | China Deep Synthesis Provisions | In effect | Regulation identifiers are conventions, not a closed enum. New regulations can be referenced without protocol changes. ## Embedded provenance and watermarks `c2pa.manifest_url` is a sidecar reference: a URL pointer to a detached cryptographic manifest. It is durable across the network, but file-level C2PA bindings do not survive ad-server transcoding, resize, or re-encode. For pipelines where assets pass through intermediaries before reaching the publisher, two transcoding-resilient fields carry provenance evidence inside the asset itself. ### `embedded_provenance[]` Provenance metadata carried *within* the content stream — either as a manifest embedded in the file container (e.g., a JUMBF box in JPEG, a C2PATextManifestWrapper in plaintext per C2PA Section A.7) or as invisible markers in the content that encode or reference a provenance record. ```json theme={null} { "embedded_provenance": [ { "method": "provenance_markers", "provider": "Encypher", "verify_agent": { "agent_url": "https://governance.encypher.example", "feature_id": "encypher.markers_present" }, "embedded_at": "2026-04-30T10:15:00Z" } ] } ``` | Field | Required | Description | | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `method` | Yes | `manifest_wrapper` or `provenance_markers` (see [`embedded-provenance-method`](https://adcontextprotocol.org/schemas/3.1.20/enums/embedded-provenance-method.json)) | | `provider` | Yes | Organization that performed the embedding (display label, not a wire identifier) | | `standard` | No | Standard the embedding conforms to (e.g., `c2pa`) | | `verify_agent` | No | AdCP governance agent that can verify this embedding via `get_creative_features` | | `embedded_at` | No | When the embedding was applied (ISO 8601) | `verify_agent` SHOULD be present for methods where the receiver cannot self-verify (e.g., `provenance_markers`); it MAY be omitted for self-verifiable embeddings such as a C2PA text manifest with a public key the receiver already trusts. ### `watermarks[]` Content watermarks that encode an identifier or fingerprint within the asset. Distinct from embedded provenance: a watermark carries an identifier (who generated it, who owns it), while embedded provenance carries or references a structured provenance record (the full chain of custody). A single asset may carry both. ```json theme={null} { "watermarks": [ { "media_type": "video", "provider": "Imatag", "verify_agent": { "agent_url": "https://governance.imatag.example", "feature_id": "imatag.watermark_detected" }, "c2pa_action": "c2pa.watermarked.unbound" } ] } ``` | Field | Required | Description | | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_type` | Yes | `audio`, `image`, `video`, or `text` (see [`watermark-media-type`](https://adcontextprotocol.org/schemas/3.1.20/enums/watermark-media-type.json)) | | `provider` | Yes | Organization that applied the watermark (display label, not a wire identifier) | | `verify_agent` | No | AdCP governance agent that can detect this watermark via `get_creative_features` | | `c2pa_action` | No | C2PA action classification: `c2pa.watermarked.bound` or `c2pa.watermarked.unbound` (see [`c2pa-watermark-action`](https://adcontextprotocol.org/schemas/3.1.20/enums/c2pa-watermark-action.json)) | | `embedded_at` | No | When the watermark was applied (ISO 8601) | ### The `verify_agent` shape `verify_agent` is the buyer's *representation* that this layer can be verified by a governance agent the seller already accepts. The seller is the verifier-of-record: it publishes which agents it will call on `creative_policy.accepted_verifiers[]` (returned by `get_products`), and the buyer's `verify_agent.agent_url` MUST be a canonicalized match of one of those entries. Off-list URLs are rejected with `PROVENANCE_VERIFIER_NOT_ACCEPTED` before any outbound call. This is buyer-supplied evidence, not buyer-driven routing. The seller chooses which agent it actually invokes, and may substitute a different on-list agent than the buyer nominated; it does not call buyer-asserted endpoints outside its allowlist. | Field | Required | Description | | ------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agent_url` | Yes | URL of the governance agent the buyer represents was used. MUST use `https://`. MUST match (canonicalized) one of the seller's `creative_policy.accepted_verifiers[].agent_url` values | | `feature_id` | No | Feature ID the buyer represents was used. SHOULD match the `feature_id` declared on the corresponding `accepted_verifiers[]` entry, or be omitted to defer to the seller | Buyers MAY omit `verify_agent` for self-verifiable embeddings (e.g., a C2PA text manifest with a public key the seller already trusts) — in that case, the seller selects an agent from `accepted_verifiers` at evaluation time. Inheritance follows the same rule as the parent `provenance` object: most-specific wins, replacement is full-object, no field-level merging. ## Creative policy enforcement Sellers express provenance requirements on `creative-policy` — a field on every Product, surfaced through `get_products` so buyers see requirements before submitting. ```json theme={null} { "$schema": "/schemas/3.1.20/core/creative-policy.json", "co_branding": "optional", "landing_page": "any", "templates_available": false, "provenance_required": true, "provenance_requirements": { "require_digital_source_type": true, "require_disclosure_metadata": true, "require_embedded_provenance": true }, "accepted_verifiers": [ { "agent_url": "https://governance.encypher.seller.example", "feature_id": "encypher.markers_present_v2", "providers": ["Encypher"] }, { "agent_url": "https://governance.imatag.seller.example", "feature_id": "imatag.watermark_detected", "providers": ["Imatag"] } ] } ``` `provenance_required: true` means the creative MUST carry *some* provenance object, anywhere in the inheritance chain. `provenance_requirements` refines that with field-level expectations. `accepted_verifiers[]` publishes the governance agents the seller operates or has allowlisted — the seller is the verifier-of-record, and buyers' `verify_agent` references MUST be a canonicalized match of one of these `agent_url` values. Field-level requirements are seller-enforced; JSON Schema does not validate them. Sellers that publish a requirement MUST enforce it. `sync_creatives` rejects non-compliant submissions with one of: | Error code | Trigger | | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PROVENANCE_REQUIRED` | No provenance object anywhere on the creative | | `PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING` | Resolved provenance has no `digital_source_type` | | `PROVENANCE_DISCLOSURE_MISSING` | Resolved provenance has no `disclosure.required`, or true with no jurisdictions | | `PROVENANCE_EMBEDDED_MISSING` | Resolved provenance has no `embedded_provenance[]` entry | | `PROVENANCE_VERIFIER_NOT_ACCEPTED` | `verify_agent.agent_url` is not on the seller's `accepted_verifiers` list (cross-checked before any outbound call) | | `PROVENANCE_CLAIM_CONTRADICTED` | Verifier (called from `accepted_verifiers`) actively refutes the buyer's claim — for example, buyer claims `digital_source_type: digital_capture` but the AI-detection feature returns `ai_generated: true` | `error.field` MUST point at the resolved provenance path that was inspected. `PROVENANCE_CLAIM_CONTRADICTED` `error.details` is limited to the audit-safe allowlist `{ agent_url, feature_id, claimed_value, observed_value, confidence }` plus `substituted_for` when the seller called an on-list agent other than the one the buyer nominated — sellers MUST NOT forward arbitrary verifier extension fields, `detail_url`, or any verifier response shape that may carry cross-tenant or PII data. These codes are correctable: a buyer's orchestrator reads them, fixes the creative, and resubmits. Auto-retry without correction will not pass. ## Where provenance attaches | Schema | Field | Description | | ----------------------------------------------- | ------------ | --------------------------------------- | | `creative-asset` | `provenance` | Default for the creative in the library | | `creative-manifest` | `provenance` | Default for all assets in this manifest | | `image-asset` | `provenance` | Override for a specific image | | `video-asset` | `provenance` | Override for a specific video | | `audio-asset` | `provenance` | Override for a specific audio file | | `text-asset` | `provenance` | Override for specific text content | | `html-asset` | `provenance` | Override for HTML content | | `css-asset` | `provenance` | Override for CSS content | | `javascript-asset` | `provenance` | Override for JavaScript content | | `vast-asset` | `provenance` | Override for a VAST tag | | `daast-asset` | `provenance` | Override for a DAAST tag | | `url-asset` | `provenance` | Override for a URL asset | | `artifact` | `provenance` | Default for the content artifact | | `artifact.assets[]` (text, image, video, audio) | `provenance` | Override for a specific inline asset | ## Schema reference | Schema | Location | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | Provenance object | [`/schemas/core/provenance.json`](https://adcontextprotocol.org/schemas/3.1.20/core/provenance.json) | | Creative policy | [`/schemas/core/creative-policy.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-policy.json) | | Digital source type enum | [`/schemas/enums/digital-source-type.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/digital-source-type.json) | | Embedded provenance method enum | [`/schemas/enums/embedded-provenance-method.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/embedded-provenance-method.json) | | Watermark media type enum | [`/schemas/enums/watermark-media-type.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/watermark-media-type.json) | | C2PA watermark action enum | [`/schemas/enums/c2pa-watermark-action.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/c2pa-watermark-action.json) | | Error code enum (provenance codes) | [`/schemas/enums/error-code.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/error-code.json) | | Creative asset (with provenance) | [`/schemas/core/creative-asset.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-asset.json) | | Creative manifest (with provenance) | [`/schemas/core/creative-manifest.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-manifest.json) | | Creative policy (provenance\_required) | [`/schemas/core/creative-policy.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-policy.json) | | Artifact (with provenance) | [`/schemas/content-standards/artifact.json`](https://adcontextprotocol.org/schemas/3.1.20/content-standards/artifact.json) | ## Related * [Provenance verification](/dist/docs/3.1.20/governance/creative/provenance-verification) -- How the governance infrastructure verifies AI provenance claims * [Creative Governance](/dist/docs/3.1.20/governance/creative/index) -- Feature-based creative evaluation via `get_creative_features` * [Content Standards](/dist/docs/3.1.20/governance/content-standards/index) -- Privacy-preserving brand suitability for publisher content * [Generative Creative](/dist/docs/3.1.20/creative/generative-creative) -- AI-powered creative generation with `build_creative` # Creative capabilities on sales agents Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/sales-agent-creative-capabilities Sales agents in AdCP implement the Creative Protocol alongside Media Buy for inline creative management and generative ad formats. Sales agents can implement the Creative Protocol alongside the Media Buy Protocol. When they do, a single agent endpoint handles both media buying and creative management — the buyer doesn't need to discover and connect to a separate service. This is the common case for sellers that generate creatives at serve time, manage a creative library internally, or offer format-specific creative services as part of their ad product. ## How it works A sales agent declares Creative Protocol support in `get_adcp_capabilities`: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [3], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["media_buy", "creative"], "account": { "supported_billing": ["operator", "agent"] }, "media_buy": { "features": { "inline_creative_management": true } }, "creative": { "has_creative_library": true, "supports_generation": true, "supports_transformation": false, "supports_compliance": false } } ``` Note that `inline_creative_management` (a media buy feature for attaching creatives to packages at buy time), Creative Protocol support, and `creative.has_creative_library` are distinct declarations. A sales agent can accept inline package creatives without a creative library, can host a library without inline package uploads, or can support both paths. `inline_creative_management` allows attaching creatives directly to packages in `create_media_buy` and `update_media_buy` — the creative travels with the buy or package update. Creative Protocol support (`"creative"` in `supported_protocols`) means the agent implements creative tasks such as `build_creative`, `list_creative_formats`, or [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives), depending on its creative capability flags. A reusable creative library is advertised specifically by `creative.has_creative_library: true`. A sales agent might support inline creative management without implementing a library (the buyer supplies package-scoped creative bodies with the media-buy request), or host a library without inline management (creatives are synced separately via `sync_creatives` before being assigned to packages). The buyer calls all tasks — media buy and creative — on the same agent URL. The protocol the task belongs to determines the schema, not the type of agent. ## Buyer creative path selection Buyers choose the creative delivery path from the media-buy inline flag and the Creative Protocol library capability: | `creative.has_creative_library` | `media_buy.features.inline_creative_management` | Buyer behavior | | ------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `true` | Absent or `false` | Use Creative Protocol tasks such as `sync_creatives`, then assign stored creatives with `packages[].creative_assignments`. | | Absent or `false` | `true` | Use `packages[].creatives` inline on `create_media_buy` and `update_media_buy`. Do not call `sync_creatives`; the seller has not advertised a creative library. | | `true` | `true` | Both paths are available. Use inline creatives for single-use package assets or immediate replacement, and `sync_creatives` plus `creative_assignments` for reusable library assets. Inline creatives become library creatives because the seller has advertised a creative library. | | Absent or `false` | Absent or `false` | The seller has not advertised creative delivery over AdCP. Buyers SHOULD NOT send `packages[].creatives` or `packages[].creative_assignments` unless another seller-specific extension explicitly defines that behavior. | When `inline_creative_management` is true and `creative.has_creative_library` is absent or false, `packages[].creatives` is the advertised creative surface. The seller may store those assets only as part of the media buy or package; buyers should not expect `list_creatives`, `sync_creatives`, or later `creative_assignments` reuse to work. Inline-only approval-state readback is package-scoped. `get_media_buys` surfaces the package's currently assigned inline creative approvals through `packages[].creative_approvals[]` (`creative_id`, `approval_status`, and optional `rejection_reason`), but it does not return the full creative body, placement routing, weights, or prior revisions. Buyers using inline-only sellers should retain the submitted creative manifest or asset payload on their side if they need to inspect or resubmit it later. ## Available creative tasks When a sales agent declares `"creative"` in `supported_protocols`, it can implement any Creative Protocol task: | Task | When to implement | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `list_creative_formats` | Always — buyers need to discover supported formats | | `sync_creatives` | When the agent hosts a creative library. Sales agents SHOULD support the `assignments` field for bulk creative-to-package mapping. | | `list_creatives` | When buyers need to browse the library | | `build_creative` | When the agent generates or transforms creatives | | `preview_creative` | When the agent can render previews | | `get_creative_delivery` | When the agent can report variant-level delivery data | These are the same tasks a standalone creative agent implements. The difference is operational, not protocol-level: the buyer doesn't need to discover and connect to a separate service. Sales agents that already implement the [accounts protocol](/dist/docs/3.1.20/accounts/overview) for media buys do not need additional account setup for creative tasks — the same account covers both protocols. ## Stored creative adapter handoff When a buyer uploads a creative through `sync_creatives`, then later attaches it to a package using `creative_assignments` in `create_media_buy` or `update_media_buy`, the AdCP protocol surface carries only one identifier: `creative_id`. The buyer chooses it when the creative is created or synced, and later reuses that same value to assign the stored creative without resending the asset bytes. The `id` field in the example below is not a second buyer-provided AdCP identifier. It is a seller-side adapter alias copied from `creative_id` for implementation layers that already use a generic asset `id`. AdCP does not require any upstream ad server, retail-media platform, or seller system of record to use this object. This shape is implementation guidance for seller SDKs, fixtures, and adapters that choose to transform an AdCP stored creative into an internal asset payload. When an implementation exposes a URL-backed stored creative to an adapter layer, the adapter-facing object SHOULD preserve both identifier spellings plus the portable metadata needed to traffic the asset. Adapter-facing internal object (not an AdCP request or response shape): ```json test=false theme={null} { "id": "cr_acme_spring_mrec", "creative_id": "cr_acme_spring_mrec", "name": "Acme Corp spring MREC", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "format_kind": "image", "asset_type": "image", "url": "https://cdn.acme.example/creative/spring-mrec.png", "width": 300, "height": 250 } ``` `creative_id` is the canonical AdCP stored creative identifier. `id` is an adapter-compatibility alias for generic asset contracts that expect an `id` field. Implementations SHOULD populate both with the same value when transforming a stored creative into an adapter asset; adapters should prefer `creative_id` when both are present. On the AdCP wire, buyers send only `creative_id` in `creative_assignments[]`. The adapter-only `id` alias is internal compatibility data, not a second buyer-supplied identifier; sellers MUST ignore it if present on input. The URL-backed path above is the portable adapter handoff. Snippet-backed or inline-content-backed stored creatives, such as HTML snippets, third-party tags, or seller-rendered brief content, require explicit adapter support because the transformation depends on that adapter's trafficking model. Docs and SDK scaffolds SHOULD NOT imply that every adapter receives fields such as `snippet`, `content`, or `media_url` unless that adapter's contract actually declares and handles them. ## Creative review Sales agents that receive creatives — whether via inline attachment in `create_media_buy` or via `sync_creatives` — may perform creative review before serving. This mirrors real publisher and SSP workflows where creatives are scanned for policy compliance, malware, or brand safety violations. Creative review status is surfaced at two levels: * **Creative library**: For sellers that advertise `creative.has_creative_library: true`, the `status` field on each creative in [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) uses the creative status enum: `processing`, `pending_review`, `approved`, `rejected`, or `archived`. * **Package level**: The `approval_status` field on each creative in [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) responses uses the creative approval status enum: `pending_review`, `approved`, or `rejected`. Rejected creatives include a `rejection_reason` with a human-readable explanation. A creative may be `approved` in the library but `rejected` at the package level if it violates placement-specific policies (e.g., a video creative on an audio-only placement). Buyers SHOULD poll `list_creatives` or `get_media_buys` after syncing or submitting a media buy with new creatives, especially for publishers with strict creative policies. For inline-only sellers, `get_media_buys` is the approval-state readback surface. For generative formats where the seller generates at serve time, creative review applies to the brief and brand identity rather than individual creatives. The seller validates that the brief's constraints and brand assets meet its policies before generation begins. **Platform and community guidelines**: Social platforms, UGC sites, and community-driven publishers often enforce content policies beyond standard ad policy (e.g., community standards, promoted content guidelines, category-specific restrictions). These platform-specific policies are surfaced through the same `rejection_reason` field — the buyer does not need a separate mechanism to handle them. When a creative is rejected for a community guideline violation, the `rejection_reason` explains what policy was violated so the buyer can fix and re-submit. ### Implementation checklist for generative creative Sales agents offering generative creative should: 1. **Declare capabilities** in `get_adcp_capabilities`: * `supports_generation: true` in creative capabilities * `"creative"` in `supported_protocols` * `inline_creative_management: true` if creatives travel with the media buy 2. **Define generative formats** via `list_creative_formats` where `format_id.agent_url` points to your own agent. Include descriptive format names and asset requirements (at minimum a `brief` asset). 3. **Implement `preview_creative`** — return representative previews for pre-flight review. Support `context_description` inputs so buyers can simulate different serve-time conditions. For conversational formats, include `interactive_url` for sandbox testing. 4. **Implement `get_creative_delivery`** — return variant manifests showing what was generated and served. Include `generation_context` (context\_type, topic, device\_class) and delivery metrics per variant. Plan for variant retention: retain at least 90 days of variant data for post-flight audit. 5. **Validate briefs at buy time** — when a buyer submits a brief in `create_media_buy`, validate that the brief's constraints (brand identity, guardrails, required disclosures) are compatible with your generation capabilities. Reject with a clear error if not. 6. **Handle creative review for briefs** — for generative formats, review applies to the brief and brand identity rather than individual creatives. Communicate review status via `approval_status` on the package in `get_media_buys`. ## Generative formats on sales agents A seller that generates creatives at serve time — contextual ads, page-matched display, AI-generated native — hosts its own generative format. The `format_id.agent_url` points to the sales agent itself: ```json theme={null} { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "name": "Contextual display (AI-generated)", "type": "display", "description": "Display ads generated at serve time based on page context and brand brief" } ``` The buyer discovers this format through `list_creative_formats` on the sales agent, then provides a brief when creating the media buy: ```json theme={null} { "packages": [{ "product_id": "premium_display", "pricing_option_id": "cpm_standard", "budget": 50000, "creatives": [{ "creative_id": "brand_contextual_brief", "name": "Q2 contextual campaign brief", "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "assets": { "brief": { "content": "Highlight our sustainability story. Match tone to editorial context." } } }] }] } ``` The seller generates creatives at serve time using this brief and the buyer's brand identity. No separate `build_creative` call is needed — the brief travels with the media buy. For regulated categories (financial services, pharma), look for `supports_compliance: true` in the agent's creative capabilities. Compliance-aware agents validate required disclosures and regulatory elements during generation — include compliance requirements in the brief so the agent can enforce them. ## Previewing generative output For generative formats, `preview_creative` serves two purposes: **Before the campaign** — pass the brief manifest with `context_description` inputs to see representative samples of what the agent could generate: ```json theme={null} { "request_type": "single", "quality": "draft", "creative_manifest": { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "assets": { "brief": { "content": "Highlight our sustainability story. Match tone to editorial context." } } }, "inputs": [ { "name": "Tech article", "context_description": "Article about semiconductor manufacturing" }, { "name": "Lifestyle blog", "context_description": "Blog post about sustainable living" } ] } ``` Use `quality: "draft"` for fast iteration on creative direction, `quality: "production"` for stakeholder review. These previews are illustrative — real output depends on live signals at serve time. Preview doesn't require an active media buy — you can preview before calling `create_media_buy`. **After the campaign** — pass a `variant_id` from `get_creative_delivery` to replay exactly what was served: ```json theme={null} { "request_type": "variant", "variant_id": "gen_tech_mobile_001" } ``` The response includes the variant's actual manifest and a rendered preview. This is a faithful replay, not a re-generation. See [Previewing generative creative](/dist/docs/3.1.20/creative/task-reference/preview_creative#previewing-generative-creative) for the full mental model and expectations table. ## Delivery reporting A sales agent implementing both protocols provides two complementary views of delivery data: | Task | Protocol | What it provides | | ------------------------ | --------- | ---------------------------------------------------------------------------------------- | | `get_media_buy_delivery` | Media Buy | WHERE and HOW MUCH: impressions, spend, placement data, optional `by_creative` breakdown | | `get_creative_delivery` | Creative | WHAT RAN and HOW: variant manifests, generation context, variant-level metrics | Both tasks are called on the same agent URL. Use `media_buy_id` and `creative_id` as join keys to correlate data across both responses. For generative formats, `get_creative_delivery` is where the buyer sees what was actually generated. Each variant includes a manifest (the actual rendered creative — headline text, image URLs, format) and generation context (the signals that triggered generation, like page topic or device class). `get_media_buy_delivery` provides the aggregate view with spend, pacing, and dimensional breakdowns. ### Example: variant-level reporting on a sales agent ```json theme={null} { "media_buy_id": "mb_12345", "currency": "USD", "reporting_period": { "start": "2026-03-01T00:00:00-05:00", "end": "2026-03-08T23:59:59-05:00", "timezone": "America/New_York" }, "creatives": [ { "creative_id": "brand_contextual_brief", "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "totals": { "impressions": 300000, "spend": 15000, "clicks": 12000, "ctr": 0.04 }, "variant_count": 47, "variants": [ { "variant_id": "gen_tech_mobile_001", "generation_context": { "context_type": "web_page", "topic": "technology, semiconductors", "device_class": "mobile" }, "manifest": { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "assets": { "headline": { "asset_type": "text", "content": "Sustainable tech for a better tomorrow" }, "hero_image": { "asset_type": "image", "url": "https://cdn.seller-example.com/generated/tech_mobile_001.jpg", "width": 300, "height": 250 } } }, "impressions": 45000, "spend": 2250, "clicks": 2700, "ctr": 0.06 } ] } ] } ``` ## When to use a separate creative agent A separate creative agent makes sense when: * The creative service is **independent of any seller** — a creative management platform, ad server, or format conversion service * Multiple sellers need to **serve the same creatives** — the buyer manages creatives in one place and distributes them * The buyer wants **centralized creative analytics** across sellers When the seller's ad product includes creative generation or management as an integrated capability, implementing both protocols on the sales agent is simpler for everyone. ## Capability discovery Buyers check `get_adcp_capabilities` to understand what a sales agent supports: ```javascript theme={null} const caps = await agent.getAdcpCapabilities({}); if (caps.errors) { throw new Error(`Capabilities check failed: ${caps.errors[0].message}`); } const sellsMedia = caps.supported_protocols.includes('media_buy'); const managesCreatives = caps.supported_protocols.includes('creative'); const acceptsInlineCreatives = caps.media_buy?.features?.inline_creative_management === true; if (managesCreatives) { const { has_creative_library, supports_generation } = caps.creative; if (supports_generation) { // Agent generates creatives — look for generative formats const formats = await agent.listCreativeFormats({}); if (formats.errors) { console.error('Format discovery failed:', formats.errors); } } if (has_creative_library) { // Agent hosts a library — can sync and browse creatives const creatives = await agent.listCreatives({ account: { account_id: 'acc_123' } }); if (creatives.errors) { console.error('Library browse failed:', creatives.errors); } } } if (sellsMedia && acceptsInlineCreatives && !managesCreatives) { // Inline-only seller: send package creatives on create_media_buy or update_media_buy. } ``` ## Related * [Creative Specification](/dist/docs/3.1.20/creative/specification) — Protocol requirements for creative agents * [Implementing Creative Agents](/dist/docs/3.1.20/creative/implementing-creative-agents) — Implementation guide for standalone creative agents * [Generative Creative](/dist/docs/3.1.20/creative/generative-creative) — Using `build_creative` for AI-powered generation * [get\_creative\_delivery](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery) — Variant-level delivery reporting * [get\_media\_buy\_delivery](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) — Media buy delivery reporting * [Media Buy Lifecycle](/dist/docs/3.1.20/media-buy/media-buys/lifecycle) — State machine, sequenced flow, guaranteed deal IO path, creative sync timing # Creative Specification Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/specification The AdCP Creative Protocol specification defines format discovery, manifest validation, AI creative generation, and preview rendering. **AdCP 3.0 Proposal** - This specification is under development for AdCP 3.0. Feedback welcome via [GitHub Discussions](https://github.com/adcontextprotocol/adcp/discussions). > **Canonical formats in 3.1**: this page describes the v1 specification model. For the canonical-formats model (inline `format_options` on products, `validate_input` primitive), see [canonical-formats](/dist/docs/3.1.20/creative/canonical-formats) and the [migration guide](/dist/docs/3.1.20/creative/canonical-formats-migration). **Status**: Request for Comments **Last Updated**: March 2026 The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). ## Abstract The Creative Protocol defines a standard interface for creative format discovery, manifest validation, creative generation, and preview rendering. This protocol enables AI agents to discover format specifications, build compliant creative assets, and generate previews across advertising platforms. ## Protocol overview The Creative Protocol provides: * Format discovery with full technical specifications * Manifest validation against format requirements * AI-powered creative generation and transformation * Preview rendering for creative verification * Universal macros for cross-platform tracking ## Transport requirements Creative agents MUST support at least one of the following transports: | Transport | Protocol | Description | | --------- | ---------------------- | ----------------------------------- | | MCP | Model Context Protocol | Tool-based interaction via JSON-RPC | | A2A | Agent-to-Agent | Message-based interaction | Creative agents SHOULD support MCP as the preferred transport. Creative agents MUST declare Creative Protocol support via `get_adcp_capabilities`: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [2], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["creative"], "creative": { "has_creative_library": true, "supports_generation": false, "supports_transformation": true, "supports_compliance": false } } ``` The `creative` capabilities tell the buyer which interaction models this agent supports. See [Interaction models](#interaction-models) below. ## Core concepts ### Creative agents A creative agent is any agent that implements the Creative Protocol. This includes standalone services (ad servers, creative management platforms, generative tools) and sales agents that declare `"creative"` in `supported_protocols`. A creative agent: * Defines and documents formats it owns * Validates manifests against format requirements * Generates previews showing how creatives will render * Optionally generates or transforms creatives from natural language briefs Sales agents that implement both the Media Buy Protocol and the Creative Protocol serve both roles from a single endpoint. See [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities). ### Interaction models Creative agents serve different roles depending on their capabilities. Buyers use `get_adcp_capabilities` to determine which interaction model applies: | Model | Description | Capabilities | Examples | | ------------------------ | --------------------------------------------------- | ------------------------------- | --------------------------- | | **Transformation agent** | Resizes or adapts existing manifests to new formats | `supports_transformation: true` | Format conversion services | | **Generative agent** | Creates manifests from natural language briefs | `supports_generation: true` | AI creative platforms | | **Creative ad server** | Hosts a creative library, generates ad-serving tags | `has_creative_library: true` | Flashtalking, CM360, Celtra | These models compose — an agent can support multiple. A creative ad server with `supports_generation: true` and `has_creative_library: true` can both generate creatives from briefs and retrieve existing ones from its library. The `supports_compliance` flag is orthogonal — any interaction model can support compliance requirements in briefs. **Buyer workflow by model:** * **Transformation**: `list_creative_formats` → `build_creative` (with `creative_manifest` + `target_format_id`) * **Generation**: `list_creative_formats` → `build_creative` (with `message` + `target_format_id`) * **Library retrieval**: `list_creatives` → `build_creative` (with `creative_id` + `target_format_id`) Agents that host a creative library should also implement the [accounts protocol](/dist/docs/3.1.20/accounts/overview) so buyers can establish access before querying. Sales agents that already implement accounts for media buys do not need to do anything additional. A transformation or generation agent that charges for its services implements the Accounts Protocol, exposes `pricing_options` on `list_creative_formats`, and returns pricing in `build_creative` responses. Agents that persist `creative_id` on build output can also expose pricing on `list_creatives`. Free transformation agents remain stateless and unchanged. ### Format authority Each format has exactly one authoritative creative agent, identified by the `agent_url` in the format ID: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_image" } } ``` Creative agents MUST only return authoritative format definitions for formats they own. Creative agents MAY reference other creative agents that provide additional formats. ### Formats Formats define how assets are assembled and rendered. A format specifies: * Media family (display, video, audio, dooh) * Required and optional asset types * Technical constraints (dimensions, duration, file size, codecs) * Rendering behavior and interaction expectations ### Assets Assets are the building blocks of creatives. Asset types include: * **image**: Static images (JPEG, PNG, WebP, GIF) * **video**: Video files (MP4, WebM, MOV) or VAST tags * **audio**: Audio files (MP3, M4A) or DAAST tags * **text**: Headlines, descriptions, CTAs * **html**: HTML5 creatives or third-party tags * **javascript**: JavaScript tags * **url**: Tracking pixels, clickthrough URLs ### Manifests Manifests pair format specifications with actual asset content. A manifest provides: * Format reference (agent\_url + id) * Asset values keyed by asset\_id from the format * Tracking URLs and macros Creative agents MUST validate manifests against format requirements before accepting them. ### Universal macros AdCP defines universal macros for cross-platform tracking. Creative agents MUST support these macros in tracking URLs: * `{TIMESTAMP}`: Unix timestamp * `{CACHEBUSTER}`: Random cache-busting value * `{CLICK_URL}`: Click tracking URL * `{REDIRECT_URL}`: Final destination URL Sales agents MUST translate universal macros to their ad server's native syntax. ## Creative Status Lifecycle **Schema**: [`enums/creative-status.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/creative-status.json) Creatives in a library progress through a defined set of states. Most transitions are seller-initiated (processing, review, approval/rejection). `suspended` is a recoverable offline state for approved creatives whose dependencies become unavailable, such as an expired published-post authorization. `archived` is reached either by buyer cleanup or by seller-side lifecycle policy on creatives without active assignments — see the rules below. ``` sync_creatives ──▶ processing ──▶ pending_review ──▶ approved │ │ │ │ │ ├──▶ suspended ──▶ approved │ │ │ │ │ │ │ └──▶ rejected │ │ ├──▶ pending_review │ │ ├──▶ rejected │ │ └──▶ archived │ │ └──────▶ rejected ◀──────────────┘ │ └── buyer fixes + resubmits ──▶ processing archived ── buyer unarchives ──▶ approved (or pending_review when re-review is required) ``` **Rules:** * `processing` → `pending_review`: automatic when ingestion and transcoding succeed * `processing` → `rejected`: automatic when processing fails (corrupt file, unsupported codec, constraint violation) * `pending_review` → `approved`: seller approves after content policy review * `pending_review` → `rejected`: seller rejects with `rejection_reason` * `approved` → `suspended`: seller detects a recoverable dependency/authorization loss, such as `identity_authorization_revoked`, `identity_authorization_expired`, or `source_private` for a `published_post` reference. Sellers MUST surface a corresponding `impairment` on affected active buys. * `suspended` → `approved`: seller observes that the dependency is restored and any required re-review passes. * `suspended` → `rejected`: seller determines that a previously recoverable dependency/authorization loss cannot be restored for this creative, or that replacement/resubmission is required. Example: a revoked identity/post authorization for a `published_post` reference cannot be reauthorized. Sellers MUST keep affected active buys impaired until the creative is replaced, reassigned, or the package/buy is otherwise remediated. * `approved` → `archived` (buyer-initiated): buyer issues archive via `sync_creatives` * `approved` → `archived` (seller-initiated): seller archives an unassigned creative for inactivity, post-flight expiry, or storage policy. Sellers MUST NOT seller-archive a creative that has active package assignments — the `approved` → `rejected` (revocation) path with an `impairment` on the affected buy is the only conformant route when active serving is involved. State-change observability for seller-initiated archive follows the [creative retention contract](/dist/docs/3.1.20/creative/creative-libraries#creatives-outlast-campaigns) — minimally, the new `status` MUST be visible on the next `list_creatives` read. * `archived` → `approved`: buyer-initiated via `sync_creatives` (unarchive). Sellers MAY require re-review, transitioning to `pending_review` instead. * `rejected` → `processing`: buyer fixes the creative and resubmits via `sync_creatives`. The creative re-enters the full processing and review pipeline. * `approved` → `pending_review`: seller-initiated re-review (e.g., policy change). Sellers MUST notify subscribers via `creative.status_changed` (fired to each `notification_configs[]` subscriber whose `event_types[]` includes this value — see below) when a previously approved creative is pulled back for re-review. Creative agents MUST reject operations that reference a `rejected` creative for delivery (e.g., assigning it to a package) with error code `CREATIVE_REJECTED`. Creative agents MUST also prevent delivery of `suspended` creatives until the dependency is restored. Creative agents MUST include `status` and `rejection_reason` (when rejected) in `list_creatives` responses. ### Lifecycle webhooks Seller-initiated and system-initiated transitions fire push notifications against the account's [`notification_configs[]`](/dist/docs/3.1.20/accounts/tasks/sync_accounts#account-level-webhook-subscriptions) subscribers — each entry whose `event_types[]` includes the fired type receives an independent fire. Two event types cover the surface: * **`creative.status_changed`** — fires on every seller-initiated or system-initiated transition: `pending_review → approved`/`rejected`, `approved → pending_review` (re-review), `approved → suspended` (recoverable dependency/authorization loss), `suspended → approved` (recovery), `suspended → rejected` (terminal dependency/authorization loss), `approved → rejected` (post-approval revocation), `approved → archived` (seller-initiated). Payload: [`creative-status-changed-webhook.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/creative-status-changed-webhook.json). * **`creative.purged`** — fires when a creative is destroyed (retention sweep, takedown, legal erasure). `soft` purges retain a tombstone on `list_creatives` (with `include_purged: true`) for 30 days; `hard` purges retain no record — the webhook is the buyer's only signal. Payload: [`creative-purged-webhook.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/creative-purged-webhook.json). Buyer-initiated transitions (archive, unarchive, resubmit) do NOT fire — those are acknowledged on the `sync_creatives` response path. The push channel exists only for transitions the buyer did not initiate. Both events carry a categorical `reason_code` drawn from [`creative-event-reason-code.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/creative-event-reason-code.json). Buyer-side remediation per reason code is documented inline on the enum's `enumDescriptions`. When a creative transitions to a state that breaks active serving (`approved → suspended`, `approved → rejected`, `suspended → rejected`, or seller-initiated `approved → archived` while assignments exist — which is non-conformant; see the transition rules above), the seller MUST also surface a corresponding `impairment` on every media buy referencing the creative (see [Media buy health](/dist/docs/3.1.20/media-buy/media-buys/lifecycle#health-and-impairments)). The creative-side `creative.status_changed` and the buy-side `impairment` are paired but distinct signals; buyers correlate by `creative_id`. The two surfaces have different anchors: creative events fire account-level (subscriptions outlive any one buy); impairments fire per-buy. **No ordering guarantee** between the paired fires — buyers MUST NOT assume one arrives before the other; reconcile via the snapshot (`list_creatives` and `get_media_buys`). Sellers declare which event types they support and per-type coalescence windows via `get_adcp_capabilities`. Default coalescence is 5 minutes for `creative.status_changed`; sellers MUST NOT coalesce `creative.purged`. Retroactive contract: when a seller declares support for these event types, the obligation covers all creatives in the library — no grace period for pre-existing creatives. Buyers MAY pull recent webhook fires per creative via `list_creatives` with `include_webhook_activity: true`. The read surface follows the [`webhook_activity[]` adoption checklist](/dist/docs/3.1.20/protocol/snapshot-and-log#webhook-activity-log-pattern) — 30-day retention, three-state presence semantics, `idempotency_key` correlation to buyer-side endpoint logs. ## Pricing Creative agents that charge for their services expose pricing through the same discover → build → report loop used by signals agents and content standards agents. ### Pricing discovery surfaces Pricing is discovered via two surfaces depending on the agent's interaction model: * **`list_creatives`** — ad servers and library-based agents expose `pricing_options[]` on each creative. The buyer discovers pricing for specific creatives they want to use. * **`list_creative_formats`** — transformation and generation agents expose `pricing_options[]` on each format. The buyer discovers pricing for the formats the agent can produce, before any creative exists. Both surfaces use the same `pricing_options[]` array of [`vendor-pricing-option`](https://adcontextprotocol.org/schemas/3.1.20/core/vendor-pricing-option.json) objects. Both require `account` and `include_pricing: true` on the request. An agent MAY expose pricing on both surfaces (e.g., a creative management platform that has both a library and transformation capabilities). ### Pricing flow 1. **Account setup** — rate card agreed. Determines pricing for all subsequent operations. 2. **Discovery** — `list_creatives` or `list_creative_formats` with `account` and `include_pricing: true` returns `pricing_options[]`. Vendors may offer multiple options (volume tiers, context-specific rates, different models per product line). 3. **Build** — `build_creative` with `account`. The agent computes the cost and returns `pricing_option_id`, `vendor_cost`, `currency`, and `consumption` in the response. 4. **Report** — `report_usage` with `creative_id` and `pricing_option_id` for reconciliation. ### Pricing models Creative agents reuse the vendor pricing models defined in [`vendor-pricing-option.json`](https://adcontextprotocol.org/schemas/3.1.20/core/vendor-pricing-option.json): | Model | Creative use case | | ------------------ | ------------------------------------------------------------------------------------------------------- | | `cpm` | Cost per thousand impressions served — ad server model, DCO platforms | | `percent_of_media` | Percentage of media spend — agency/platform model | | `flat_fee` | Fixed charge per period — licensed creative suites, subscription access | | `per_unit` | Fixed price per unit of work — per format adapted, per image generated, per token, per variant rendered | ### Consumption details **Schema**: [`core/creative-consumption.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-consumption.json) The `build_creative` response includes a `consumption` object with structured details about what was consumed. Well-known fields: `tokens` (LLM tokens consumed), `images_generated`, `renders` (render passes), `duration_seconds` (processing time). Agents MAY include additional fields. The `consumption` object is informational — it lets the buyer verify that `vendor_cost` is consistent with the rate card. `vendor_cost` is the billing source of truth. ### Accounts requirement Creative agents that charge for their services MUST implement the [Accounts Protocol](/dist/docs/3.1.20/accounts/overview). This applies to any creative agent with pricing — ad servers, generation platforms, and transformation agents that bill for usage. ### Bundled mode When a publisher uses a creative agent internally (bundled), the buyer never sees the creative agent's pricing. The cost is absorbed into product pricing. The sales agent is the buyer in the creative agent relationship — it establishes an account, calls `build_creative`, and handles `report_usage`. The protocol surface is the same. ## Tasks The Creative Protocol defines the following tasks. See task reference pages for complete request/response schemas and examples. ### list\_creative\_formats **Reference**: [`list_creative_formats` task](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) Discover creative formats and their specifications. **Requirements:** * Creative agents MUST return full format specifications for formats they own * Creative agents MUST include `agent_url` identifying the authoritative agent for each format * Creative agents MUST include technical constraints (dimensions, duration, file types) in format definitions * Creative agents MAY include references to other creative agents providing additional formats * When filtering by `format_ids`, creative agents MUST return only the requested formats ### list\_transformers **Reference**: [`list_transformers` task](/dist/docs/3.1.20/creative/task-reference/list_transformers) Discover the account-scoped transformers a creative agent offers — the creative analog of media-buy products: agent-offered, selectable units of build capability (voices, models, styles) that you select with `transformer_id` in `build_creative`. Offered only by agents that declare `creative.supports_transformers: true` in `get_adcp_capabilities`. **Requirements:** * Creative agents that set `creative.supports_transformers: true` MUST implement `list_transformers` * Creative agents MUST resolve transformers, their enumerable option values, and pricing for the calling account — including custom values configured for that account (e.g. cloned voices) * Creative agents MUST return account-scoped option values inline on `params[].options[]` for each `field` named in `expand_params`, and SHOULD omit them otherwise * When `include_pricing` is true, creative agents that charge MUST include `pricing_options` (the `per_unit` model) on each transformer ### build\_creative **Reference**: [`build_creative` task](/dist/docs/3.1.20/creative/task-reference/build_creative) Transform, generate, or retrieve creative manifests. Supports three modes: 1. **Generation**: Create a manifest from a brief or seed assets 2. **Transformation**: Adapt an existing manifest to a different format 3. **Library retrieval**: Resolve a `creative_id` from the agent's library into a manifest with ad-serving assets (HTML/JavaScript/VAST tags) **Requirements:** * Creative agents MUST validate input manifests against format requirements * Creative agents MUST return a valid manifest for the target format on success * Creative agents MUST return validation errors if the transformation cannot be completed * Creative agents SHOULD preserve tracking URLs and macros during transformation * Creative agents SHOULD respect `quality` for generative tasks (`"draft"` for fast iteration, `"production"` for final delivery) and MAY ignore it for non-generative transforms * Creative agents SHOULD honor `item_limit` when present, using the lesser of `item_limit` and the format's `max_items` * Creative agents MAY use AI/LLM processing for generation tasks * When `creative_id` is provided, creative agents MUST resolve the creative from their library * When `macro_values` is provided, creative agents SHOULD substitute the specified macros in the output manifest's assets and leave unresolved macros as `{MACRO}` placeholders * Creative agents MUST ignore unrecognized macro keys in `macro_values` — unknown macros are not an error * Creative agents SHOULD assign globally unique `creative_id` values; when they cannot guarantee uniqueness, `concept_id` is REQUIRED on `build_creative` requests to disambiguate * `build_creative` supports async responses (`status: "working"` with `context_id` polling) for generation and transformation tasks that take significant time. Library retrieval is typically synchronous. * When `account` is provided and the agent charges, the response MUST include `pricing_option_id`, `vendor_cost`, and `currency`. The `consumption` object SHOULD be included when relevant. * For async builds, pricing fields appear on the final completed response only, not on intermediate status responses. * When a paid creative agent receives a `build_creative` request without `account` and the agent requires an account, the agent MUST return an error. ### preview\_creative **Reference**: [`preview_creative` task](/dist/docs/3.1.20/creative/task-reference/preview_creative) Generate preview renderings of creative manifests. **Requirements:** * Creative agents MUST validate manifests before generating previews * Creative agents MUST return preview URLs or HTML for valid manifests * Creative agents MUST keep preview URLs dereferenceable until their `expires_at` timestamp. When `expires_at` is omitted, preview URLs do not expire at the protocol layer and remain valid until the agent explicitly revokes them out of band. * Creative agents SHOULD include `expires_at` for time-limited preview URLs * Creative agents SHOULD support batch preview for multiple creatives * Creative agents MAY support multiple output formats (URL, HTML, image) ### list\_creatives **Schema**: [`creative/list-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-request.json) / [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-response.json) **Reference**: [`list_creatives` task](/dist/docs/3.1.20/creative/task-reference/list_creatives) Browse and filter creative assets in a creative library. Implemented by any agent that hosts a creative library — ad servers, creative management platforms, and sales agents that manage creatives. **Requirements:** * Agents MUST return creatives accessible to the authenticated account * Agents MUST include approval status for each creative * Agents SHOULD support filtering by format, status, tags, and date range * Agents SHOULD support filtering by `concept_ids` and `format_ids` when the platform organizes creatives into concepts * Agents MAY include dynamic content variable definitions when `include_variables=true` * Agents MAY include a lightweight delivery snapshot when `include_snapshot=true`. The snapshot provides lifetime impressions and last-served date for operational use — detailed analytics belong in `get_creative_delivery`. * When `account` and `include_pricing=true` are provided, agents that charge MUST include `pricing_options` on each creative — an array of [`vendor-pricing-option`](https://adcontextprotocol.org/schemas/3.1.20/core/vendor-pricing-option.json) objects. Vendors may offer multiple options per creative (volume tiers, context-specific rates, different pricing models). **Account requirements:** * Creative agents that charge for their services MUST implement the [Accounts Protocol](/dist/docs/3.1.20/accounts/overview). This applies to any creative agent with pricing — ad servers, generation platforms, and transformation agents that bill for usage. * Creative agents that host a library but do not charge SHOULD implement the Accounts Protocol so buyers can establish access before querying. * This is the same accounts protocol used by sales agents — there is no separate version. * Sales agents that already implement accounts for media buys do not need to do anything additional. ### sync\_creatives **Schema**: [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-request.json) / [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-response.json) **Reference**: [`sync_creatives` task](/dist/docs/3.1.20/creative/task-reference/sync_creatives) Upload and synchronize creative assets in a library. Implemented by any agent that hosts a creative library — ad servers, creative management platforms, and sales agents that manage creatives. **Requirements:** * Agents MUST validate creatives against format specifications * Agents MUST return validation errors for non-compliant creatives * Agents MAY require approval before creatives are available for use * Agents SHOULD support `dry_run` for validation without applying changes * Agents MUST reject requests that combine `delete_missing: true` with `creative_ids` — `delete_missing` applies to the entire library, not a filtered subset * Agents that also manage media buys SHOULD support the `assignments` field for bulk creative-to-package mapping * Standalone creative agents that do not manage media buys SHOULD ignore the `assignments` field ### get\_creative\_delivery **Reference**: [`get_creative_delivery` task](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery) Retrieve creative delivery data with variant-level metrics. **Requirements:** * Agents MUST return delivery data for the requested creatives * Agents SHOULD include variant-level breakdowns when available * Sales agents implementing the Creative Protocol SHOULD support this task when their products generate or optimize creative variants ## Error handling Creative agents MUST return errors using the [standard AdCP error schema](/dist/docs/3.1.20/building/by-layer/L3/error-handling). Common error codes: * `REFERENCE_NOT_FOUND`: Requested format does not exist or is not accessible (`error.field` identifies the `format_id`) * `VALIDATION_ERROR`: Manifest failed format validation: a required asset is missing from the manifest, or an asset does not meet the format's constraints (`error.field` identifies the offending asset path) Generation-pipeline failures (model or rendering errors during `build_creative`) surface as task-level failure (`status: "failed"`) with the most specific applicable code in `errors[]`. The error-code vocabulary is open: sellers MAY return platform-specific codes for failure modes the canonical enum does not cover, and agents MUST handle unknown codes by falling back to the recovery classification. ## Security considerations ### Transport security All Creative Protocol communications MUST use HTTPS with TLS 1.2 or higher. ### Asset security * Creative agents SHOULD validate that asset URLs are accessible * Creative agents SHOULD scan assets for malware and malicious content * Creative agents MUST NOT execute untrusted JavaScript during validation ### Preview security * Preview URLs SHOULD be time-limited (indicated by `expires_at`) * Preview URLs MUST NOT depend on pod-local or process-local state unless the agent can guarantee that state for the advertised lifetime of the URL * Creative agents SHOULD sandbox HTML previews to prevent script execution * Consumers of `output_format: "html"` MUST only use trusted creative agents ## Conformance ### Creative agent conformance A conformant Creative Protocol agent MUST: 1. Support at least one specified transport (MCP or A2A) 2. Implement `list_creative_formats` for format discovery 3. Return authoritative format definitions only for formats it owns 4. Validate manifests against format specifications 5. Use specified error codes A conformant Creative Protocol agent SHOULD: 1. Implement `build_creative` for creative generation 2. Implement `preview_creative` for preview rendering 3. Support universal macros in tracking URLs 4. Implement `list_creatives` when the agent hosts a creative library 5. Implement `sync_creatives` when the agent accepts creative uploads 6. Support `creative_id` in `build_creative` when the agent hosts a creative library 7. Implement the accounts protocol (`sync_accounts` / `list_accounts`) when hosting a creative library 8. Declare `supports_generation`, `supports_transformation`, and `has_creative_library` in `get_adcp_capabilities` so buyers can determine the correct interaction model ### Consumer conformance A conformant Creative Protocol consumer MUST: 1. Use the `agent_url` from format IDs to identify the authoritative creative agent 2. Validate manifests against format specifications before submission 3. Handle validation errors appropriately 4. Track visited URLs when recursively discovering formats to avoid infinite loops ## Implementation notes ### Response time expectations Creative agents SHOULD target the following response times: | Operation Type | Target Latency | | ---------------------------------------- | -------------- | | Format listing (list\_creative\_formats) | \< 1 second | | Library query (list\_creatives) | \< 1 second | | Creative sync (sync\_creatives) | \< 5 seconds | | Preview generation (preview\_creative) | \< 5 seconds | | Batch preview (10 creatives) | \< 10 seconds | | Creative generation (build\_creative) | \< 60 seconds | ### Recursive format discovery Creative agents MAY reference other creative agents in their `list_creative_formats` response: ```json theme={null} { "creative_agents": [{ "agent_url": "https://creative.adcontextprotocol.org", "agent_name": "AdCP Reference Creative Agent", "capabilities": ["validation", "assembly", "preview"] }] } ``` Consumers MAY recursively query referenced agents to discover additional formats. Consumers MUST track visited URLs to prevent infinite loops during recursive discovery. ### Format-aware validation Manifest validation MUST be performed in the context of the format specification: 1. Look up the format definition from the authoritative creative agent 2. For each asset in the manifest, find the corresponding entry in the format's `assets` array 3. Validate the asset value against the type and constraints defined in the format The format definition determines what type each asset\_id should be. Asset type information is NOT included in the manifest itself. ### Standard vs custom formats * **Standard formats**: Based on IAB specifications, hosted by the reference creative agent (`https://creative.adcontextprotocol.org`) * **Custom formats**: Defined by individual publishers or creative platforms for specialized inventory Both work identically—the `agent_url` field identifies which agent is authoritative for each format. ## Schema reference Some creative protocol schemas (`build_creative`, `list_creative_formats`, `preview_creative`) have paths under `media-buy/` because they were originally released as part of the media-buy protocol. The schema paths are stable identifiers and do not affect which protocol the task belongs to. | Schema | Description | | --------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | | [`core/format.json`](https://adcontextprotocol.org/schemas/3.1.20/core/format.json) | Format definition | | [`core/creative-manifest.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-manifest.json) | Creative manifest | | [`core/creative-asset.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-asset.json) | Asset definition | | [`media-buy/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/list-creative-formats-request.json) | list\_creative\_formats request | | [`media-buy/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/list-creative-formats-response.json) | list\_creative\_formats response | | [`creative/list-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-request.json) | list\_creatives request | | [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-response.json) | list\_creatives response | | [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-request.json) | sync\_creatives request | | [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-response.json) | sync\_creatives response | # build_creative Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/task-reference/build_creative build_creative generates, transforms, or retrieves ad creative manifests in AdCP from a natural language brief to production-ready assets. Transform, generate, or retrieve a creative manifest for a specific format. To render a visual preview of the resulting manifest, pass it to [`preview_creative`](/dist/docs/3.1.20/creative/task-reference/preview_creative). Supports three modes: 1. **Generation**: Create a manifest from a brief or seed assets (`message` + `creative_manifest`) 2. **Transformation**: Adapt an existing manifest to a different format (`creative_manifest` + `target_format_id`) 3. **Library retrieval**: Resolve a `creative_id` from the agent's library into a manifest with ad-serving assets For generation and transformation, `build_creative` takes a creative manifest as input and produces a creative manifest as output. For library retrieval, provide a `creative_id` (from [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives)) and the agent resolves it from its library. For information about legacy format IDs and how to reference formats, see [Creative Formats - Referencing Formats](/dist/docs/3.1.20/creative/formats#referencing-formats). On the 3.1 canonical path, creative agents advertise buildable outputs through `get_adcp_capabilities.creative.supported_formats[]`; targetable entries SHOULD include a stable `capability_id`, and buyers pass that value as `target_format_id.id` (or `target_format_ids[N].id`) when calling `build_creative`. ## Request parameters | Parameter | Type | Required | Description | | ------------------------------ | ------------------------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `message` | string | No | Natural language instructions for the creative agent. For generation, this provides creative direction. For transformation, this guides how to adapt the creative. For refinement, this describes the desired changes. | | `creative_manifest` | object | No | Creative manifest to transform or generate from (see [Creative Manifest](https://adcontextprotocol.org/schemas/3.1.20/core/creative-manifest.json)). For pure generation, this should include the target format\_id and any required input assets. For transformation, this is the complete creative to adapt. When `creative_id` is provided, the agent resolves the creative from its library and this field is ignored. | | `creative_id` | string | No | Reference to a creative in the agent's library. The creative agent resolves this to a manifest from its library. Use this instead of `creative_manifest` when retrieving an existing creative for tag generation or format adaptation. | | `concept_id` | string | No | Creative concept containing the creative. Used to disambiguate when the same `creative_id` exists in multiple concepts. | | `media_buy_id` | string | No | Buyer's media buy reference for tag generation context. When the creative agent is also the ad server (e.g., CM360), this provides the trafficking context needed to generate placement-specific tags. Omit when the platform generates tags at the creative level (Flashtalking, Celtra). This is the buyer's reference — the seller-assigned identifier from `create_media_buy`. | | `package_id` | string | No | Buyer's package or line item reference within the media buy. Used with `media_buy_id` when the creative agent needs line-item-level context. Omit to get a tag not scoped to a specific package — the ad server may return the same tag regardless. | | `target_format_id` | object | Conditional | Single format ID to generate. Object with `agent_url` and `id` fields. For 3.1 canonical creative-agent routing, `id` is the advertised `creative.supported_formats[].capability_id`; legacy named-format IDs remain accepted during the migration window. Mutually exclusive with `target_format_ids` — provide exactly one. | | `target_format_ids` | array | Conditional | Array of format IDs to generate in a single call. Each element is an object with `agent_url` and `id` fields. For 3.1 canonical creative-agent routing, each `id` is an advertised `creative.supported_formats[].capability_id`; legacy named-format IDs remain accepted during the migration window. Mutually exclusive with `target_format_id` — provide exactly one. Returns one manifest per format. | | `brand` | object | No | Brand reference with `domain` field. Resolves brand identity via `/.well-known/brand.json`. Provides brand-level context (colors, logos, tone). | | `quality` | string | No | Quality tier: `"draft"` (fast, lower-fidelity for iteration) or `"production"` (full quality for final delivery). If omitted, the creative agent uses its own default. | | `item_limit` | integer | No | Maximum number of catalog items to use **within one creative**. Caps generation cost for catalog-driven formats (e.g. a 6-card carousel built from a 1,000-product catalog). Distinct from `max_creatives`, which fans out across items. | | `transformer_id` | string | No | Select one transformer (discovered via [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers)) to perform the build. The requested target format(s) MUST be a subset of that transformer's `output_format_ids`. The transformer's `per_unit` rate is the pricing source for the build. | | `config` | object | No | Typed configuration bag keyed to the selected transformer's `params[].field` (e.g. `{ "voice": "isaac", "speaking_rate": 1.1 }`). The creative agent MUST reject unknown keys and out-of-range values with field-attributed errors (strict validation) — vendor-specific knobs that are not declared params go in `ext`. Only meaningful with `transformer_id`. | | `max_creatives` | integer | No | Catalog fan-out axis: produce up to N **distinct creatives, one per catalog item** (a sample — e.g. 5 of 150). Distinct from `item_limit`, which caps items used *within* a single creative. Mutually exclusive with `refine_from_build_variant_id`. Triggers the `BuildCreativeVariantSuccess` response shape. Supported only when the agent advertises `creative.multiplicity.supports_catalog_fanout`; values above `max_creatives_limit` are **clamped** (shortfall via `items_returned` \< `items_total`), not rejected. | | `signal_conditions` | array | No | Signal fan-out axis: produce one distinct creative **per signal condition**, each kept and trafficked with its own targeting (a rain creative AND a sun creative). Each item is a [SignalTargeting](/dist/docs/3.1.20/signals/specification). Sibling to `max_creatives`; composes with it (catalog × conditions). Advisory at this layer ([#5280](https://github.com/adcontextprotocol/adcp/issues/5280)) — trafficking-compatibility is enforced sales-side via `SIGNAL_TARGETING_INCOMPATIBLE`. Supported only when the agent advertises `creative.multiplicity.supports_signal_fanout`; counts above `max_signal_conditions_limit` are **clamped**. See [Signal fan-out](#signal-fan-out). | | `selection_strategy` | string | No | How the agent samples when `max_creatives` \< eligible items: `audience_relevance` (user-side, ranks by the same `signal_ref` pointers), `contextual_fit` (content-side, same mechanism with a context signal), `performance` (historical delivery), `proximity`, `inventory_priority` (seller-side), or `random` (default). Ordering surfaces on `rank`/`recommended`. Supported set in `creative.multiplicity.selection_strategies[]`. | | `max_variants` | integer | No | Number of alternative renders to produce **per creative** (best-of-N). Default `1`. You pay for every produced variant; keeping one or more is a separate trafficking step. Supported only when the agent advertises `creative.multiplicity.supports_variants`; values above `max_variants_limit` are clamped. | | `variant_axis` | object | No | Describes the dimension along which variants differ. Object with `dimension` (`voice` \| `theme` \| `best_of_n` \| `transformer_config` \| `custom`), optional `values[]` (explicit values to enumerate along the axis), optional `field` (the `config` param to sweep — required when `dimension` is `transformer_config`), and optional `label`. | | `keep_mode` | string | No | Advisory hint to the agent on how many variants you intend to keep: `"keep_all"`, `"keep_one"`, or `"keep_some"`. Default `"keep_all"`. Advisory only — you are billed for all produced variants regardless. The response echoes `keep_mode_applied` so you have confirmation the hint was received (audit trail for billing disputes). | | `evaluator` | object | No | **Experimental** ([status](/dist/docs/3.1.20/reference/experimental-status); feature id `creative.evaluator`). Advisory evaluator ([Evaluator Spec](https://adcontextprotocol.org/schemas/3.1.20/core/evaluator-spec.json)) driving a **gate-then-rank** pipeline over the agent's best-of-N: one source form (`exemplars` / account-arranged `evaluator_id` / `agent_url`), an optional hard `feature_requirement[]` **gate** (drop fails — internal pruning of recommended leaves, never a block on an already-produced billable leaf), an explicit `rank_by` ordering (`[{feature_id, direction: maximize\|minimize}]`), and an allowlisted `feature_agent` pointer. Feature discovery uses [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) `governance.creative_features`; the evaluator returns those same feature IDs in `variants[].eval.features[]`. `evaluator_id` is not discovered from that catalog; it is a pre-provisioned account preset. External agents (`feature_agent.agent_url` / `agent_url`) MUST be in the seller's `creative_policy.accepted_verifiers[]` — off-list → `EVALUATOR_AGENT_NOT_ACCEPTED`. Requires `creative.supports_evaluator`; otherwise ignored. Populates the per-leaf `variants[].eval` block. See [`variants[].eval`](#field-descriptions) and [Evaluator authentication](#evaluator-authentication). | | `refine_from_build_variant_id` | string | No | Refine a prior produced variant: re-build from its `build_variant_id` applying the NL instruction in `message` plus any `config` delta, returning **new** lineage-linked variants (never a mutation). `transformer_id` and target format(s) are inherited from the parent. Composes with `max_variants`/`variant_axis`; mutually exclusive with `max_creatives`. Requires `creative.supports_refinement` — otherwise `UNSUPPORTED_FEATURE`; an unknown/no-longer-retained ref is `REFERENCE_NOT_FOUND` (`error.field` = `refine_from_build_variant_id`). See [Refinement](#refinement). | | `mode` | string | No | `"execute"` (default) produces and bills. `"estimate"` is a **dry run** — produces and bills nothing, returns a `BuildCreativeEstimate` cost band (`cost_low`/`cost_high`) computed against this request's inputs. Requires `creative.supports_spend_controls`. See [Spend controls](#spend-controls). | | `max_spend` | object | No | Hard per-call spend ceiling `{ amount, currency }`. The agent produces leaves until the next would exceed `amount`, then stops and returns the partial result with `budget_status: "capped"`. Requires `creative.supports_spend_controls`. Caps one call — bound refinement loops buyer-side. See [Spend controls](#spend-controls). | | `include_preview` | boolean | No | When true, requests preview renders alongside the manifest. Agents that support this return a `preview` object in the response. Agents that don't simply omit it. | | `preview_inputs` | array | No | Input sets for preview generation when `include_preview` is true. Each entry has `name` (required), optional `macros`, and optional `context_description`. If omitted, the agent generates a single default preview. Only supported with `target_format_id` (single-format) — ignored for multi-format requests. | | `preview_quality` | string | No | Render quality for inline previews: `"draft"` or `"production"`. Independent of the build `quality` — you can build at draft and preview at production, or vice versa. Only used when `include_preview` is true. | | `preview_output_format` | string | No | Output format for preview renders: `"url"` (default) or `"html"`. Only used when `include_preview` is true. | | `macro_values` | object | No | Macro values to pre-substitute into the output manifest's assets. Keys are universal macro names (e.g., `CLICK_URL`, `CACHEBUSTER`); values are the literal substitution strings. The creative agent translates universal macros to its platform's native syntax. Macros not provided here remain as `{MACRO}` placeholders for the sales agent to resolve at serve time. | | `account` | [AccountRef](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | No | Account reference for pricing and billing. When present, the creative agent applies account-specific pricing from the rate card, records the build against the account, and can enforce quotas. Required by creative agents that charge for their services. | | `push_notification_config` | object | No | Operation-scoped webhook configuration for async terminal completion/failure notifications when `build_creative` returns `submitted`. Submitted tasks remain pollable via `get_task_status` whether or not this field is present; agents MUST NOT return `submitted` solely because this field is present. | ### Pricing response fields When the creative agent charges and `account` is provided, the response includes pricing fields: | Field | Type | Description | | ------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `pricing_option_id` | string | Which rate card pricing option was applied | | `vendor_cost` | number | Cost incurred for this build. May be 0 for CPM-priced creatives where cost accrues at serve time. | | `currency` | string | ISO 4217 currency code | | `consumption` | object | Structured consumption details — `tokens`, `images_generated`, `renders`, `duration_seconds`. See [`creative-consumption.json`](https://adcontextprotocol.org/schemas/3.1.20/core/creative-consumption.json). Informational for cost verification; `vendor_cost` is the billing source of truth. | For async builds (`status: "working"` with `context_id` polling), pricing fields appear on the final completed response only. ### Recipe identity Agents MAY return a top-level `recipe_hash` on single-format success responses, and MAY return `variants[].recipe_hash` on multiplicity/refinement leaves. Multi-format `creative_manifests[]` responses do not carry `recipe_hash` in this revision; request the variant shape (`max_variants`) when you need per-output recipe identities. The value is an ETag-style identity for the build-determining inputs: agent-computed, opaque, and scoped to that agent. Buyers can compare it only across responses from the same agent. `recipe_hash` identifies the input recipe, not the output bytes or legal/disclosure envelope. A nondeterministic build can return different pixels, tags, or variants with the same `recipe_hash`; the value means "same instructions," not "same creative." For fan-out responses, best-of-N leaves produced from the same recipe SHOULD share the same value so clients can group alternatives by their shared source. When both `recipe_hash` and `build_variant_id` appear, `build_variant_id` identifies the output leaf and lineage while `recipe_hash` identifies the input recipe; neither implies the other. The build-to-delivery performance join still uses the lineage identifiers that survive trafficking, not `recipe_hash` alone. **Important**: Required input assets should be included in the `creative_manifest.assets` object, not as separate task parameters. The format definition specifies what assets it requires. Catalog context for dynamic creatives should be provided via the `creative_manifest.assets` map. ### Evaluator authentication `build_creative.evaluator` selects or calibrates the evaluator; it does not authenticate the evaluator call. Do not put evaluator API keys, bearer tokens, client secrets, `Authorization` values, JWKs, JWKS documents, or JWKS URIs in `evaluator`, `context`, `ext`, or any other payload field. Credential- or trust-material payload keys are non-conforming and should be rejected with [`CREDENTIAL_IN_ARGS`](/dist/docs/3.1.20/building/by-layer/L3/error-handling#authentication-and-access). When `evaluator.agent_url` or `evaluator.feature_agent.agent_url` names an external evaluator, the producing creative agent calls that evaluator's [`get_creative_features`](/dist/docs/3.1.20/governance/creative/get_creative_features) endpoint using the normal AdCP transport authentication channel. The evaluator authenticates the creative/seller agent as caller via [RFC 9421 request signing](/dist/docs/3.1.20/building/by-layer/L1/security#request-signing) and JWKS discovery, mTLS, or a pre-provisioned Bearer/API-key credential. Payload fields such as `agent_url`, `account`, `context`, and `ext` are not identity assertions and must not be treated as credentials. The allowlist check still happens first: an external evaluator URL that is not in `creative_policy.accepted_verifiers[]` is rejected with `EVALUATOR_AGENT_NOT_ACCEPTED` before any outbound call. Buyers learn accepted evaluator URLs from the seller's published `creative_policy.accepted_verifiers[]` where a media-buy/product context exposes one, or from account provisioning in standalone creative-agent integrations. If the URL is on-list but the evaluator is unreachable or rejects the creative agent's transport authentication, the build degrades to seller-default ranking with an advisory `errors[]` note rather than failing the entire build. ### Generation controls For generative formats, two optional parameters control the generation process: * **`quality`**: Controls generation fidelity. Use `"draft"` for rapid iteration (reviewing layouts, copy, composition) and `"production"` for final renders. Draft outputs may use lower-resolution images, simplified effects, or placeholder elements. To produce a production version of a draft you like, pass the draft's output manifest back as `creative_manifest` with `quality: "production"`. Note: `preview_creative` also accepts `quality` to control *render* fidelity independently — see [Previewing generative creative](/dist/docs/3.1.20/creative/task-reference/preview_creative#previewing-generative-creative). * **`item_limit`**: For catalog-driven formats, caps how many catalog items are used during generation. A catalog might contain 1,000 products but you only need 4 hero images. The creative agent selects top items based on relevance or catalog ordering. When `item_limit` exceeds the format's `max_items` (from catalog requirements), the creative agent should use the lesser of the two. If omitted, the creative agent decides based on catalog size and format requirements. ## Use cases ### Pure generation (creating from scratch) For pure generation, provide a minimal source manifest with the required input assets defined by the format: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "a1b2c3d4-e5f6-4789-a012-3456789abcde", "message": "Create a banner promoting our winter sale with a warm, inviting feel", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "brand": { "domain": "mybrand.com" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "assets": { "offering_catalog": { "asset_type": "catalog", "type": "offering", "items": [ { "offering_id": "winter-sale", "name": "Winter Sale Collection", "description": "50% off all winter items" } ] } } } } ``` ### Transformation (adapting existing creative) For transformation, provide the complete source manifest: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "b2c3d4e5-f6a7-4890-b123-456789abcdef", "message": "Adapt this creative for mobile, making the text larger and CTA more prominent", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.example.com/original-banner.png", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "Winter Sale - 50% Off" } } }, "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_mobile_320x50" } } ``` ### Format resizing Transform an existing creative to a different size: ```json theme={null} { "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90" }, "assets": { /* complete assets */ } }, "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } } ``` ### Library retrieval Retrieve a creative from the agent's library and resolve it into a manifest with ad-serving assets. Use this when you know the `creative_id` from [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) and want the creative agent to produce a delivery-ready manifest with tags (HTML, JavaScript, or VAST): ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "c3d4e5f6-a7b8-4901-c234-56789abcdef0", "creative_id": "ft_88201", "concept_id": "concept_holiday_2026", "target_format_id": { "agent_url": "https://creative.example.com", "id": "display_static", "width": 300, "height": 250 }, "macro_values": { "CLICK_URL": "https://publisher.example.com/click/abc123" } } ``` **Response** — the manifest includes ad-serving tag assets with macros resolved: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "recipe_hash": "rh_scope3_9f2c7a1d5b3e", "creative_manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_static", "width": 300, "height": 250 }, "assets": { "ad_tag": { "asset_type": "javascript", "content": "" }, "clickthrough_url": { "asset_type": "url", "url": "https://acmecorp.example.com/holiday-sale?campaign={MEDIA_BUY_ID}" } } } } ``` The `CLICK_URL` macro was substituted with the provided value; `CACHEBUSTER` remains as a placeholder for the sales agent to resolve at serve time. Do not use `recipe_hash` to deduplicate ad tags, prove legal/disclosure equivalence, or join build results to served delivery data. Use `build_variant_id` and the promoted `creative_id` for lineage and reporting. **Cross-agent workflow**: When creative generation and media buying are handled by different agents, use `build_creative` on the creative agent to produce a manifest with tags, then [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) on the sales agent to upload it. When the sales agent implements both protocols, this happens on a single endpoint — see [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities). ### Multi-format generation Generate creatives for multiple formats in a single call using `target_format_ids`. The agent produces one manifest per format from the same source assets and brief: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "d4e5f6a7-b8c9-4012-d345-6789abcdef01", "message": "Create display banners for our spring campaign", "target_format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 300, "height": 250 }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 728, "height": 90 }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 320, "height": 50 } ], "brand": { "domain": "acmecorp.com" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static" }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.acmecorp.com/spring-hero.png", "width": 1200, "height": 628 }, "headline": { "asset_type": "text", "content": "Spring Collection Now Available" }, "clickthrough_url": { "asset_type": "url", "url": "https://acmecorp.example.com/spring?campaign={MEDIA_BUY_ID}" } } } } ``` The response uses `creative_manifests` (array) instead of `creative_manifest` (singular). Each manifest is a complete creative manifest with its own `format_id`, ready for `sync_creatives` or `preview_creative`: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "creative_manifests": [ { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 300, "height": 250 }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/generated/spring_300x250.png", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "Spring Collection Now Available" }, "clickthrough_url": { "asset_type": "url", "url": "https://acmecorp.example.com/spring?campaign={MEDIA_BUY_ID}" } } }, { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 728, "height": 90 }, "assets": { /* same structure, adapted for 728x90 */ } }, { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 320, "height": 50 }, "assets": { /* same structure, adapted for 320x50 */ } } ] } ``` Multi-format requests are atomic — if any format fails (e.g., `FORMAT_NOT_SUPPORTED`), the entire request fails with an error response. The response array order corresponds to the `target_format_ids` request order. Match manifests to requested formats by array position or by comparing the `format_id` on each manifest. ### Multi-format workflow After a multi-format build, preview all results using `preview_creative` batch mode. Each element of `creative_manifests` in the build response becomes a `creative_manifest` in the batch preview request: ```json theme={null} { "request_type": "batch", "quality": "draft", "requests": [ { "creative_manifest": { /* 300x250 manifest from build response */ } }, { "creative_manifest": { /* 728x90 manifest from build response */ } }, { "creative_manifest": { /* 320x50 manifest from build response */ } } ] } ``` To refine a single format from a multi-format build, call `build_creative` again with `target_format_id` (singular) and pass back that format's manifest. You don't need to rebuild all formats — just iterate on the one that needs work. Multi-format requests with `include_preview: true` generate one default preview per format. Custom `preview_inputs` are only supported with single-format requests. For multi-format builds where you need context-specific previews (device variants, different contexts), use a separate `preview_creative` batch call after building. ### Transformers & variants Select a transformer (discovered via [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers)) with `transformer_id`, supply a typed `config` keyed to its params, and ask for alternatives with `max_variants`. The agent returns the [variant response](#variant-response): `creatives[]`, each holding a `variants[]` array. Read `recommended` / `rank` to surface the agent's best-of-N pick; traffic the variant(s) you want by their `build_variant_id`. Resolutions and quality tiers go on `target_format_ids` (or `quality`), not on `variant_axis` — variants are alternatives for the *same* format. ```javascript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { BuildCreativeResponseSchema } from '@adcp/sdk'; const result = await testAgent.buildCreative({ account: { account_id: 'acct_acme' }, transformer_id: 'audiostack_voiceover', config: { voice: 'isaac', speaking_rate: 1.1, mastering_preset: 'podcast' }, target_format_id: { agent_url: 'https://creative.audiostack.example', id: 'audio_vo' }, creative_manifest: { format_id: { agent_url: 'https://creative.audiostack.example', id: 'script' }, assets: { script: { asset_type: 'text', content: 'Discover the new winter collection.' } }, }, max_variants: 3, variant_axis: { dimension: 'best_of_n', label: 'Read takes' }, keep_mode: 'keep_one', idempotency_key: '0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5', }); const parsed = BuildCreativeResponseSchema.parse(result); for (const creative of parsed.creatives ?? []) { for (const variant of creative.variants) { console.log(variant.build_variant_id, variant.rank, variant.recommended, variant.vendor_cost); } } ``` ```python test=false theme={null} import asyncio from adcp.testing import test_agent from adcp import BuildCreativeResponse async def main(): result = await test_agent.build_creative( account={"account_id": "acct_acme"}, transformer_id="audiostack_voiceover", config={"voice": "isaac", "speaking_rate": 1.1, "mastering_preset": "podcast"}, target_format_id={"agent_url": "https://creative.audiostack.example", "id": "audio_vo"}, creative_manifest={ "format_id": {"agent_url": "https://creative.audiostack.example", "id": "script"}, "assets": {"script": {"asset_type": "text", "content": "Discover the new winter collection."}}, }, max_variants=3, variant_axis={"dimension": "best_of_n", "label": "Read takes"}, keep_mode="keep_one", idempotency_key="0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5", ) parsed = BuildCreativeResponse.model_validate(result) for creative in parsed.creatives or []: for variant in creative.variants: print(variant.build_variant_id, variant.rank, variant.recommended, variant.vendor_cost) asyncio.run(main()) ``` You pay for all three takes (`per_unit` × 3); `keep_mode` is advisory. Keeping is a separate trafficking step on the chosen `build_variant_id` — use that id as the `creative_id` when promoting the produced manifest through [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives). The kept variant then lazily earns a creative record whose `creative_id` is the build leaf id and flows to [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) and delivery reporting. ### Paid build with pricing When the creative agent charges and `account` is provided, the response includes pricing fields. The agent selects the applicable pricing option server-side based on the account's rate card and the work performed — the buyer does not pass `pricing_option_id` in the request. **Per-unit pricing (transformation agent)**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "e5f6a7b8-c9d0-4123-e456-789abcdef012", "account": { "account_id": "acct_acme_creative" }, "creative_id": "cr_hero_banner", "target_format_id": { "agent_url": "https://creative.example.com", "id": "display_728x90" } } ``` ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creative_manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_728x90" }, "assets": { "ad_tag": { "content": "
...
" } } }, "pricing_option_id": "po_standard_per_format", "vendor_cost": 2.00, "currency": "USD", "consumption": { "renders": 1 } } ``` The `pricing_option_id` corresponds to one of the options from [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives#pricing). The buyer passes it in [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) for reconciliation. **CPM pricing (ad server)** — `vendor_cost` is 0 at build time because cost accrues when impressions are served: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creative_manifest": { "..." : "..." }, "pricing_option_id": "po_video_cpm", "vendor_cost": 0, "currency": "USD" } ``` ## Response format ### Single-format response When the request uses `target_format_id`, the response contains a single creative manifest: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "recipe_hash": "rh_winter_300x250_4c2a", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "offering_catalog": { "asset_type": "catalog", "type": "offering", "catalog_id": "winter-sale" }, "banner_image": { "asset_type": "image", "url": "https://cdn.example.com/generated-banner.png", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "50% Off Winter Sale" }, "clickthrough_url": { "asset_type": "url", "url": "https://mybrand.example.com/winter-sale?campaign={MEDIA_BUY_ID}" } } } } ``` ### Multi-format response When the request uses `target_format_ids`, the response contains an array of creative manifests: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "creative_manifests": [ { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 300, "height": 250 }, "assets": { /* ... */ } }, { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 728, "height": 90 }, "assets": { /* ... */ } } ] } ``` ### Variant response When the request uses `max_creatives`, `max_variants` > 1, `variant_axis`, or `refine_from_build_variant_id`, the agent returns the `BuildCreativeVariantSuccess` shape — the third success shape (`oneOf` member 3 of 6) alongside the single- and multi-format responses, which are unchanged and still used when you build one creative with one variant. **No fallback.** If you send any of `max_creatives`, `max_variants > 1`, `variant_axis`, or `refine_from_build_variant_id`, you **must** handle `creatives[]` — you will not get `creative_manifest`/`creative_manifests` back. There is no automatic downgrade to the single/multi shapes. ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creatives": [ { "build_creative_id": "bc_card_01", "catalog_item_ref": { "catalog_type": "product", "item_id": "sku_winter_parka" }, "variants": [ { "build_variant_id": "bv_card01_a", "recipe_hash": "rh_card01_bestofn_7b91", "creative_manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "assets": { "...": "..." } }, "variant_axis_value": "take_1", "recommended": true, "rank": 1, "pricing_option_id": "po_per_image", "vendor_cost": 0.40, "currency": "USD", "consumption": { "images_generated": 1 } }, { "build_variant_id": "bv_card01_b", "recipe_hash": "rh_card01_bestofn_7b91", "creative_manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "assets": { "...": "..." } }, "variant_axis_value": "take_2", "recommended": false, "rank": 2, "pricing_option_id": "po_per_image", "vendor_cost": 0.40, "currency": "USD", "consumption": { "images_generated": 1 } } ] } ], "items_total": 150, "items_returned": 5, "vendor_cost": 4.00, "currency": "USD" } ``` *The `creatives[]` array above is abbreviated to one of the 5 returned groups; the aggregate `vendor_cost` of $4.00 covers all 5 groups × 2 variants × $0.40 — it equals the sum of every produced leaf's `vendor_cost`, not just the leaves shown.* * **creatives\[]**: One entry per built creative group. With `max_creatives`, there is one entry per sampled catalog item (`catalog_item_ref` identifies which item); without catalog fan-out there is a single entry. Treat this as the produced group set; choose among alternatives inside each group's `variants[]`. * **creatives\[].build\_creative\_id**: Identifies a built creative within this response. * **creatives\[].catalog\_item\_ref**: Present on catalog fan-out — an object identifying the source catalog item via `item_id` (plus optional `catalog_type`). * **creatives\[].signal\_condition**: Present on signal fan-out — the SignalTargeting condition this creative group is FOR (e.g. weather=rain). Shares `signal_ref` identity with sales-side package targeting so the sales agent can match and reject incompatible assignments. * **creatives\[].errors\[]**: Present only on a *failed* catalog item — catalog fan-out is non-atomic, so a failed item is returned as a `creatives[]` entry carrying `errors[]` and no `variants[]`, without failing the batch. * **creatives\[].variants\[]**: The choose-among alternatives produced for this creative group. Length is at most `max_variants`. Each variant carries its own complete `creative_manifest`. * **variants\[].build\_variant\_id**: Identifies a single variant — the leaf-level **lineage anchor**. This is its **own namespace** — never reuse a `preview_id` (a `preview_creative` render) or a served `variant_id` (a delivery-time identifier) here. You traffic a chosen build by passing its `build_variant_id`. * **variants\[].recipe\_hash**: Optional ETag-style identity for the build-determining inputs that produced the leaf. It is agent-computed, opaque, and comparable only within the same agent. Best-of-N leaves from the same source recipe SHOULD share a value. It is useful for cache transparency, cost-avoidance hints, and recipe-level grouping, but it is not output equality, not legal/disclosure equivalence, and not the build-to-delivery join. * **variants\[].parent\_build\_variant\_id**: Present only on a refined variant ([Refinement](#refinement)) — the `build_variant_id` of the source it was refined from. Absent for first-generation builds. * **variants\[].variant\_axis\_value**: The value of the `variant_axis` dimension this variant represents (e.g. a voice, a theme). * **variants\[].recommended** / **variants\[].rank**: Best-of-N signals — `recommended` flags the agent's top pick; `rank` orders the variants. These plus `variant_axis` and `keep_mode` are how best-of-N is expressed. * **variants\[].eval**: Present only when the request supplied an advisory [`evaluator`](#request-parameters) and the agent advertises `creative.supports_evaluator`. The rank-side of the [`get_creative_features`](/dist/docs/3.1.20/governance/creative/get_creative_features) feature oracle. `eval.features[]` is **`creative-feature-result[]`** — the same shape `get_creative_features.results[]` returns (e.g. a `creative_quality_score` number, an `on_brief` categorical, a calibrated `predicted_performance`); the wrapper is open, each feature item is closed. The agent runs a **gate-then-rank** pipeline over its best-of-N: it evaluates each leaf, drops leaves failing the hard `evaluator.feature_requirement[]` gate from its recommended survivors, then orders survivors by `evaluator.rank_by` (`[{feature_id, direction}]`) — which is what `recommended`/`rank` reflect. The gate is **internal pruning** of which leaves the agent recommends from its own exploration, not an AdCP-layer block: what is produced and billed is governed by `max_variants`/`max_creatives`/`max_spend`, so this block appears only on returned (billed) leaves. Feature discovery uses `get_adcp_capabilities.governance.creative_features`; `evaluator_id` is an account-scoped preset that emits values over that feature vocabulary, not a discovered feature ID and not a separate evaluator feature catalog. The exemplars form calibrates one `predicted_performance` feature in \[0,1]; a pass/warn/fail verdict is a categorical string feature value gated via `feature_requirement.allowed_values` — `eval.features[]` is a feature measurement, never a stored verdict. When the evaluator names an external agent, it must be on the seller's `creative_policy.accepted_verifiers[]` (off-list → `EVALUATOR_AGENT_NOT_ACCEPTED`); the producing agent authenticates its outbound call on the transport, not inside the evaluator payload. Sellers SHOULD populate `eval.calls_used` / `eval.seconds_used` when `agent_url` was used and an `eval_budget` was supplied. * **variants\[] pricing receipt**: Each variant carries a per-leaf receipt (`pricing_option_id`, `vendor_cost`, `currency`, `consumption`) for the work that produced it. Whenever the build reports cost (the aggregate `vendor_cost` is present), every produced leaf MUST carry its own `vendor_cost` + `currency` (schema-enforced) — leaves are the billing source of truth, and an untrafficked leaf is reconciled from this receipt alone. A CPM-deferred leaf reports `vendor_cost: 0`. * **items\_total** / **items\_returned**: For catalog fan-out, the total eligible catalog items and how many creatives were returned (e.g. "5 of 150"). * **leaves\_total** / **leaves\_returned**: Leaf-granular counts — total leaves the request would produce vs. how many were actually produced/billed. When `budget_status` is `capped`, `leaves_returned` \< `leaves_total` is the shortfall (works even for variant-only fan-out with no catalog). * **vendor\_cost** / **currency**: Aggregate cost across all produced variants in the response. * **keep\_mode\_applied**: Echoes the `keep_mode` the agent applied (present when the request set it). `keep_mode` is advisory and doesn't change what's produced or billed, so this is your confirmation the hint was received — the audit trail for a "I asked for keep\_one but was billed for N" dispute. * **selection\_strategy\_applied**: Echoes the `selection_strategy` the agent applied (when `max_creatives` sampling occurred). The ranking itself shows in `rank`/`recommended` on the leaves. * **budget\_status**: `complete` (default; absent == complete) or `capped` — a `max_spend` ceiling stopped production early (a successful partial build, not a failure). See [Spend controls](#spend-controls). * **errors\[]**: Advisory (non-terminal) entries on an otherwise-successful build — e.g. a `BUDGET_CAP_REACHED` notice when `budget_status` is `capped`. You pay for **every produced variant** (`per_unit` × N), not just the ones you keep. Keeping is a separate client act of trafficking the chosen `build_variant_id`(s), typically by using each kept leaf as `creative_id` on [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives). A kept variant lazily earns a creative record when it is added to the library or first trafficked; that `creative_id` is what flows to [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) and delivery reporting. An unkept variant is still billed but never earns a `creative_id`. **Atomicity differs by axis.** Variant production *per format* is atomic — if one variant for a format fails, that format's build fails. Catalog fan-out (`max_creatives`, per item) is **non-atomic**: individual `creatives[]` entries may succeed while others fail. A failed item is returned as a `creatives[]` entry carrying `errors[]` (and no `variants[]`); the batch still succeeds. **Build-time variants are not delivery variants.** The `build_variant_id` here is a pre-delivery handle for an alternative you produced. It is a different namespace from the served `variant_id` in [`get_creative_delivery`](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery), which identifies a creative *execution that was delivered*. A build-time variant becomes a delivery variant only once you keep and traffic it. **Resolutions and quality tiers are not variants.** Multiple sizes or quality levels belong on the format axis — pass them as `target_format_ids` (or `quality`), not as `variant_axis` values. Variants are alternatives *for the same format* (different voice, theme, or best-of-N take). ### Refinement Conversational refinement re-builds a prior variant with free-form direction — "make it warmer", "tighten the CTA". The typed `config` surface is closed by design, so free-form intent rides the **open** surface that already exists: the `message` field (whose description is already *"For refinement, this describes the desired changes"*). Refinement is therefore not a new task — it's `build_creative` with one extra input: * Pass `refine_from_build_variant_id` (a prior leaf's `build_variant_id`) plus your instruction in `message` and any `config` delta. * The agent re-builds from that leaf and returns **new** variants, each with `parent_build_variant_id` set to the source. A refinement is **never a mutation** — the parent leaf is unchanged, and the new leaf earns its own `build_variant_id` (and, on trafficking, its own `creative_id`). * `transformer_id` and the target format(s) are **inherited** from the parent; you don't repeat them. Passing a `transformer_id` or target format that differs from the parent's is an `INVALID_REQUEST`. `config` is applied as a **delta** over the parent's config. * Composes with `max_variants` / `variant_axis` (e.g. "three warmer takes" → three refined leaves), but **not** with `max_creatives` / catalog fan-out — you refine one produced creative, not a catalog. * Requires the agent to advertise `creative.supports_refinement: true` (it retains produced leaves for an agent-defined window). Agents that retain nothing return `UNSUPPORTED_FEATURE`; refine a buyer-held manifest instead via the transform path (`creative_manifest` + `message`). An unknown or no-longer-retained ref returns `REFERENCE_NOT_FOUND` with `error.field` set to `refine_from_build_variant_id`. Any build can be refined, not just variant builds: a single-format `BuildCreativeSuccess` carries an optional `build_variant_id` (present when the agent supports refinement) that you pass as `refine_from_build_variant_id`. Multi-format builds that need per-output refinable leaves should request the variant shape (`max_variants`), since the bare `creative_manifests[]` array carries no leaf ids. AI-derivative attribution rides the manifest's existing `provenance`; `parent_build_variant_id` carries only the lineage edge (refinements chain into a tree). ```json test=false theme={null} { "refine_from_build_variant_id": "bv_card01_a", "message": "Warmer lighting, and move the logo to the lower-right.", "max_variants": 3, "account": { "account_id": "acct_acme" }, "idempotency_key": "7f2a1b3c-4d5e-6f70-8192-a3b4c5d6e7f8" } ``` ### Spend controls Fan-out and refinement can produce many independently-billed leaves (`max_creatives` × `max_variants`), and `per_unit` pricing gives a *rate* but not the *unit count* in advance (a 6-second voiceover and a 60-second one cost 10× at the same rate). Two opt-in controls, both gated by `creative.supports_spend_controls`: * **Estimate first (`mode: "estimate"`).** A dry run: the agent produces and bills nothing and returns a `BuildCreativeEstimate` with a `cost_low`/`cost_high` band (and `basis`: `fixed` = exact, `estimated_units` = generative projection, `cpm_deferred` = cost accrues at serve). The band is the load-bearing piece — the seller derives it from your actual inputs, so you don't have to guess unit counts. * **Cap the call (`max_spend: { amount, currency }`).** A hard stop: the agent produces leaves until the next would push aggregate `vendor_cost` over `amount`, then returns the partial `BuildCreativeVariantSuccess` with `budget_status: "capped"` and an advisory `BUDGET_CAP_REACHED` in `errors[]` — every returned leaf is real and billed, nothing produced is discarded. The leaf-granular shortfall is `leaves_returned` \< `leaves_total` (not `items_returned`/`items_total`, which count catalog items and don't capture a variant-only or mid-item cap); the `BUDGET_CAP_REACHED` advisory is the authoritative cap signal. If even the first leaf would exceed the cap, the call fails with a terminal `BUDGET_CAP_REACHED`. `currency` must match the rate card (no FX) or the request is rejected `INVALID_REQUEST` (`error.field: max_spend.currency`). `max_spend` bounds only **build-time** `vendor_cost` — CPM-priced builds (`basis: cpm_deferred`) are 0 at build time and accrue at serve, so the cap never engages; bound a CPM fan-out with `max_creatives` instead. They compose: estimate to get `cost_high`, then execute with `max_spend` = `cost_high` × a safety margin (CPM builds excepted — their `cost_high` is 0 at build time). `max_spend` caps a **single call**; to bound an autonomous **refinement loop**, track aggregate `vendor_cost` across calls and stop issuing them (a buyer responsibility in this revision — a protocol-level session budget is deferred to the working group). `max_spend` and `mode: "estimate"` require the agent to advertise `creative.supports_spend_controls`; otherwise they're rejected with `UNSUPPORTED_FEATURE`. They're only meaningful with `bills_through_adcp: true` (an out-of-band biller has no AdCP cost to cap against). ### Estimate response A `mode: "estimate"` request returns the `BuildCreativeEstimate` shape (`oneOf` member 4 of 6) — produced and billed nothing, just a projected cost band: ```json test=false theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "mode": "estimate", "estimate": { "items_total": 150, "items_to_produce": 5, "variants_per_item": 3, "leaves_total": 15, "currency": "USD", "cost_low": 6.00, "cost_high": 9.00, "cost_expected": 7.50, "basis": "estimated_units" }, "expires_at": "2026-06-01T00:00:00Z" } ``` * **estimate.leaves\_total** = `items_to_produce` × `variants_per_item` (× `conditions_total` when `signal_conditions` was sent) — the number of billable leaves `mode: "execute"` would produce. * **estimate.cost\_low / cost\_high / cost\_expected**: the projected aggregate cost band. The seller derives it from your actual inputs. * **estimate.basis**: `fixed` (per-format flat — `cost_low == cost_high`, exact), `estimated_units` (generative `per_unit`; the band reflects unit-count uncertainty), or `cpm_deferred` (CPM — build-time cost 0, accrues at serve, so the band is 0). * **estimate.per\_leaf** (optional): per-leaf breakdown. * Estimates are **advisory / non-binding** in this revision (binding quotes are deferred to the working group). ### Field descriptions * **creative\_manifest**: (single-format) The complete creative manifest ready for use with `sync_creatives` or `preview_creative` * **creative\_manifests**: (multi-format) Array of complete creative manifests, one per requested format. Each contains its own `format_id`. * **format\_id**: The target format (matches the requested format) * **assets**: Map of asset keys to asset content — includes creative content (images, text, URLs), catalogs, briefs, and anything else the format requires * **expires\_at**: Optional ISO 8601 timestamp when generated asset URLs in the manifest expire. Set to the earliest expiration across all generated assets. Re-build the creative after this time to get fresh URLs. Not present when the manifest contains no expiring URLs (e.g., pure text generation or assembly-only transforms). * **preview**: Optional. Present when `include_preview` was true in the request and the agent supports inline preview. Contains the same content fields as a `preview_creative` single response (`previews`, `interactive_url`, `expires_at`) minus the `response_type` discriminator, so clients can reuse the same preview rendering logic. Preview URLs follow the same durability contract as `preview_creative`: they remain dereferenceable until `expires_at`, or until explicit out-of-band revocation when no expiration is present. For single-format responses, each entry in `previews[]` corresponds to an input set from `preview_inputs`. For multi-format responses, each entry includes a `format_id` and corresponds to one requested format (one default preview per format; `preview_inputs` is ignored). * **preview\_error**: Optional. Standard error object (`code`, `message`, `recovery`) present when `include_preview` was true but preview generation failed. The `recovery` field indicates whether the failure is `transient` (retry later), `correctable`, or `terminal`. Distinguishes "agent doesn't support inline preview" (field absent, no error) from "preview generation failed" (field present with structured error). ### Compliance errors If the manifest includes a brief asset with `compliance` requirements that the creative agent cannot satisfy, the agent MUST return an error — not a partial success. Unsatisfied disclosures are a hard failure. ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "failed", "errors": [ { "code": "COMPLIANCE_UNSATISFIED", "message": "Required disclosure cannot be rendered in this format", "field": "creative_manifest.assets.brief.compliance.required_disclosures[0]", "details": { "disclosure_text": "Past performance is not indicative of future results.", "position": "footer", "reason": "Format display_mobile_320x50 does not support footer position" }, "suggestion": "Use a format that supports footer disclosures, or change position to 'overlay'" } ] } ``` Creative agents MUST validate that all `required_disclosures` can be satisfied in the target format before generating the creative. If any disclosure cannot be placed as specified, the entire request fails. This ensures regulated creatives are never served without required legal text. ## Response timing How the creative agent responds depends on how long the operation will take: | Expected duration | Status | Caller experience | | ------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Under 30 seconds | `completed` | Result returned directly — synchronous | | Over 30 seconds, server actively processing | `working` | Out-of-band status updates while the server continues processing. Caller holds the connection — still synchronous from their perspective | | Blocked on external dependency (human review, approval) | `submitted` | Truly async — caller should configure a webhook via `push_notification_config` and move on | The creative agent decides which path to take based on the work involved. Library retrieval is instant. Simple transformations take seconds. AI generation varies — a quick banner might complete in 10 seconds, a complex video composition might take minutes. ### `working` is a progress signal, not a polling trigger When the server expects to take more than 30 seconds but is actively processing, it sends `working` as an out-of-band MCP status update. This keeps the client informed ("I'm on it") without requiring the caller to switch to a polling or webhook pattern. The connection stays open and the result arrives when it's ready. ### When to go async `submitted` means the operation is blocked on something outside the server's control: * **Human creative review** — brand guidelines require approval before returning * **External approval workflows** — third-party compliance or legal review For these cases, configure a webhook via `push_notification_config` to receive the result. See [Async Operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations) and [Push Notifications](/dist/docs/3.1.20/building/by-layer/L3/webhooks). ### Human-in-the-loop The agent may return `status: "input-required"` when it needs human input — for example, when brand guidelines require creative approval or when the agent needs clarification on creative direction: ```json theme={null} { "reason": "CREATIVE_DIRECTION_NEEDED" } ``` **Reason codes:** * `APPROVAL_REQUIRED` — Creative needs human approval before finalizing * `CREATIVE_DIRECTION_NEEDED` — Agent needs clarification on creative brief or direction * `ASSET_SELECTION_NEEDED` — Agent needs the caller to choose between asset options ## Workflow integration ### Typical generation workflow 1. **Build**: Use `build_creative` to generate/transform the manifest 2. **Preview**: Use `preview_creative` to see how it renders (see [preview\_creative](/dist/docs/3.1.20/creative/task-reference/preview_creative)) 3. **Sync**: Use `sync_creatives` to traffic the finalized creative You can combine steps 1 and 2 by setting `include_preview: true` on the build request. If the agent supports it, the response includes a `preview` object alongside the manifest — same content fields as `preview_creative`, no extra round trip. If the agent doesn't support inline preview, the field is simply absent and you fall back to a separate `preview_creative` call. Always check for the presence of `preview` rather than assuming it will be there when requested. Use `preview_quality` to control render fidelity independently from build quality. For example, build at `quality: "draft"` (fast concept generation) but preview at `preview_quality: "production"` (full-fidelity render to show stakeholders the layout). If `preview_quality` is omitted, the agent uses its own default. ```json theme={null} // Build at draft quality, but preview at production quality for stakeholder review { "message": "Create a display banner for our winter sale", "target_format_id": {"agent_url": "...", "id": "display_300x250_generative"}, "brand": { "domain": "mybrand.com" }, "quality": "draft", "include_preview": true, "preview_quality": "production", "creative_manifest": { "format_id": {"agent_url": "...", "id": "display_300x250_generative"}, "assets": { "product_catalog": { "type": "product", "catalog_id": "winter-products" } } } } // Or: Build first, preview separately // Step 1: Build { "message": "Create a display banner for our winter sale", "target_format_id": {"agent_url": "...", "id": "display_300x250_generative"}, "brand": { "domain": "mybrand.com" }, "creative_manifest": { "format_id": {"agent_url": "...", "id": "display_300x250_generative"}, "assets": { "product_catalog": { "type": "product", "catalog_id": "winter-products" } } } } // Step 2: Preview (using the output manifest from step 1) { "request_type": "single", "format_id": {"agent_url": "...", "id": "display_300x250"}, "creative_manifest": { /* output from build_creative - includes all assets */ }, "inputs": [{"name": "Desktop view"}, {"name": "Mobile view"}] } // Step 3: Sync (if preview looks good) { "creative_manifests": [{ /* approved manifest from build_creative */ }] } ``` **Key insight**: The manifest carries everything. Briefs, catalogs, images, text — all live in the assets map and flow through from input to output. No need to pass them separately at each step. ## Examples ### Example 1: Pure generation (generative format) Generate a creative from scratch using a generative format: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "f6a7b8c9-d0e1-4234-f567-89abcdef0123", "message": "Create a display banner for our winter sale. Use warm colors and emphasize the 50% discount", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "brand": { "domain": "mybrand.com" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "assets": { "offering_catalog": { "asset_type": "catalog", "type": "offering", "items": [ { "offering_id": "winter-sale", "name": "Winter Sale Collection", "description": "50% off all winter items" } ] } } } } ``` **Response**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "offering_catalog": { "asset_type": "catalog", "type": "offering", "catalog_id": "winter-sale" }, "banner_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/generated/banner_12345.png", "width": 300, "height": 250 }, "clickthrough_url": { "asset_type": "url", "url": "https://mybrand.example.com/winter-sale?campaign={MEDIA_BUY_ID}" } } } } ``` ### Example 2: Format transformation Transform an existing 728x90 leaderboard to a 300x250 banner: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "a7b8c9d0-e1f2-4345-a678-9abcdef01234", "message": "Adapt this leaderboard creative to a 300x250 banner format", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_728x90" }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.mybrand.com/leaderboard.png", "width": 728, "height": 90 }, "headline": { "asset_type": "text", "content": "Spring Sale - 30% Off Everything" }, "clickthrough_url": { "asset_type": "url", "url": "https://mybrand.example.com/spring?campaign={MEDIA_BUY_ID}" } } }, "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } } ``` **Response**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/resized/banner_67890.png", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "Spring Sale - 30% Off" }, "clickthrough_url": { "asset_type": "url", "url": "https://mybrand.example.com/spring?campaign={MEDIA_BUY_ID}" } } } } ``` ### Example 3: Transformation with specific instructions Adapt a creative for mobile with specific design changes: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "b8c9d0e1-f2a3-4456-b789-abcdef012345", "message": "Make this mobile-friendly: increase text size, simplify the layout, and make the CTA button more prominent", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x600" }, "assets": { "background_image": { "asset_type": "image", "url": "https://cdn.mybrand.com/bg.jpg", "width": 300, "height": 600 }, "headline": { "asset_type": "text", "content": "Discover Our New Collection" }, "body_text": { "asset_type": "text", "content": "Shop the latest styles with free shipping on orders over $50" }, "cta_text": { "asset_type": "text", "content": "Shop Now" } } }, "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_mobile_320x50" } } ``` **Response**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_mobile_320x50" }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/mobile/banner_mobile_123.png", "width": 320, "height": 50 }, "headline": { "asset_type": "text", "content": "New Collection - Shop Now" }, "clickthrough_url": { "asset_type": "url", "url": "https://mybrand.example.com/new?campaign={MEDIA_BUY_ID}" } } } } ``` ### Example 4: Generation with creative brief Generate a creative using structured campaign context via `brand` and a brief asset on the manifest: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "c9d0e1f2-a3b4-4567-c890-bcdef0123456", "message": "Create a display banner for the holiday campaign targeting gift shoppers", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "brand": { "domain": "acmecorp.com" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "assets": { "brief": { "asset_type": "brief", "name": "Holiday Sale 2025", "objective": "conversion", "audience": "Holiday gift shoppers aged 25-55", "territory": "festive savings", "messaging": { "headline": "Holiday Deals Are Here", "cta": "Shop Now", "key_messages": [ "Up to 50% off select items", "Free shipping on orders over $50" ] }, "reference_assets": [ { "url": "https://cdn.acmecorp.com/holiday-mood-board.pdf", "role": "mood_board", "description": "Holiday campaign mood board with festive color palette" } ] }, "offering_catalog": { "asset_type": "catalog", "type": "offering", "items": [ { "offering_id": "holiday-sale", "name": "Holiday Sale Collection", "description": "Up to 50% off select holiday items" } ] } } } } ``` ### Example 5: Generation with compliance requirements Generate a financial services creative with regulatory disclosures and prohibited claims: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "d0e1f2a3-b4c5-4678-d901-cdef01234567", "message": "Create a display banner promoting retirement planning advisory services", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "brand": { "domain": "pinnaclewealth.com" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "assets": { "brief": { "asset_type": "brief", "name": "Retirement Advisory Q1 2026", "objective": "consideration", "audience": "Pre-retirees aged 50-65 with investable assets", "territory": "trusted financial guidance", "messaging": { "headline": "Plan Your Retirement with Confidence", "cta": "Schedule a Consultation", "key_messages": [ "Personalized retirement planning", "Tax-efficient investment strategies" ] }, "compliance": { "required_disclosures": [ { "text": "Past performance is not indicative of future results.", "position": "footer", "jurisdictions": ["US"], "regulation": "SEC Rule 156" }, { "text": "Securities offered through Pinnacle Wealth Securities, LLC. Member FINRA/SIPC.", "position": "footer", "jurisdictions": ["US"], "regulation": "FINRA Rule 2210" }, { "text": "Capital at risk. The value of investments can go down as well as up.", "position": "prominent", "jurisdictions": ["GB"], "regulation": "FCA COBS 4.5" }, { "text": "Pinnacle Wealth Advisors is a registered investment adviser.", "position": "footer" } ], "prohibited_claims": [ "guaranteed returns", "risk-free investment", "outperform the market" ] } } } } } ``` Compliance requirements differ by jurisdiction: the US requires SEC-mandated disclosures while the UK requires FCA-mandated risk warnings. The third disclosure (no `jurisdictions`) applies globally. The `prohibited_claims` array tells the creative agent which claims to avoid in generated copy. ### Example 6: Commerce media with brief and product catalog Generate a sponsored product carousel with campaign context, compliance disclosures, and a synced product catalog: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "e1f2a3b4-c5d6-4789-e012-def012345678", "message": "Create a product carousel highlighting the top 4 sale items", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "sponsored_product_carousel" }, "brand": { "domain": "novabrands.com" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "sponsored_product_carousel" }, "assets": { "brief": { "asset_type": "brief", "name": "Spring Sale 2026", "objective": "conversion", "audience": "Value-conscious shoppers aged 25-45", "messaging": { "headline": "Spring Sale — Up to 40% Off", "cta": "Shop Now" }, "compliance": { "required_disclosures": [ { "text": "Sponsored", "position": "prominent" }, { "text": "Prices may vary by location. See store for details.", "position": "footer" } ] } }, "product_catalog": { "asset_type": "catalog", "type": "product", "catalog_id": "spring_sale_2026" } } } } ``` The brief and product catalog travel together in the manifest's `assets` map. The format declares both a `brief` and `catalog` asset type — the buying agent discovers this via `list_creative_formats` and syncs the required catalog before submitting. ### Example 7: Build with inline preview Build a creative and get preview renders in the same response: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "f2a3b4c5-d6e7-4890-f123-ef0123456789", "message": "Create a banner for our spring campaign", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "brand": { "domain": "novabrands.com" }, "include_preview": true, "preview_inputs": [ { "name": "Default" }, { "name": "Dark mode", "macros": { "COLOR_SCHEME": "dark" } } ], "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "assets": { "offering_catalog": { "asset_type": "catalog", "type": "offering", "items": [ { "offering_id": "spring-promo", "name": "Spring Collection", "description": "30% off new arrivals" } ] } } } } ``` **Response** (when the agent supports inline preview): ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "offering_catalog": { "asset_type": "catalog", "type": "offering", "catalog_id": "spring-promo" }, "banner_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/generated/spring_abc123.png", "width": 300, "height": 250 }, "clickthrough_url": { "asset_type": "url", "url": "https://novabrands.com/spring" } } }, "preview": { "previews": [ { "preview_id": "prev_default", "renders": [ { "render_id": "r1", "output_format": "url", "preview_url": "https://preview.creative-agent.com/abc123/default", "role": "primary", "dimensions": { "width": 300, "height": 250 } } ], "input": { "name": "Default" } }, { "preview_id": "prev_dark", "renders": [ { "render_id": "r2", "output_format": "url", "preview_url": "https://preview.creative-agent.com/abc123/dark", "role": "primary", "dimensions": { "width": 300, "height": 250 } } ], "input": { "name": "Dark mode", "macros": { "COLOR_SCHEME": "dark" } } } ], "expires_at": "2026-03-13T06:00:00Z" }, "expires_at": "2026-03-13T06:00:00Z" } ``` The `preview` object contains the same content fields as a `preview_creative` single response (`previews`, `interactive_url`, `expires_at`). If the agent does not support inline preview, this field is absent — the buyer agent falls back to a separate `preview_creative` call. If preview generation fails, the response includes `preview_error` with a standard error object (`code`, `message`, `recovery`) instead. ### Example 8: Draft generation with item limit Generate a draft-quality creative from a large catalog, capping the number of items: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "a3b4c5d6-e7f8-4901-a234-f01234567890", "message": "Create hero images for our top sale items", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "sponsored_product_carousel" }, "brand": { "domain": "novabrands.com" }, "quality": "draft", "item_limit": 4, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "sponsored_product_carousel" }, "assets": { "product_catalog": { "asset_type": "catalog", "type": "product", "catalog_id": "spring_sale_2026" } } } } ``` The catalog may contain hundreds of products, but `item_limit: 4` ensures only 4 hero images are generated. `quality: "draft"` produces fast, lower-fidelity output for review. **Response**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-response.json", "status": "completed", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "sponsored_product_carousel" }, "assets": { "product_catalog": { "asset_type": "catalog", "type": "product", "catalog_id": "spring_sale_2026" }, "card_1_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/draft/card1_abc123.jpg", "width": 400, "height": 400 }, "card_2_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/draft/card2_def456.jpg", "width": 400, "height": 400 }, "card_3_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/draft/card3_ghi789.jpg", "width": 400, "height": 400 }, "card_4_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/draft/card4_jkl012.jpg", "width": 400, "height": 400 } } }, "expires_at": "2026-03-02T06:00:00Z" } ``` The `expires_at` field signals when the generated CDN URLs expire. Re-build after this time to get fresh URLs. Once the direction is approved, re-submit the output manifest with `quality: "production"` for final renders. ## Key concepts ### Brand vs creative brief | | Brand | Creative Brief | | ------------ | ------------------------------------------ | ------------------------------------------------------------------ | | **Scope** | Brand identity | Campaign context | | **Lifespan** | Stable across campaigns | Specific to a campaign or flight | | **Contains** | Colors, logos, fonts, tone | Audience, territory, messaging, compliance, reference assets | | **Legal** | Brand-level disclaimers (always-on) | Campaign-specific regulatory disclosures (regional, product-based) | | **Source** | Brand registry / `/.well-known/brand.json` | Agency or brand team | | **Lives on** | Resolved via domain lookup | The manifest's assets map (`assets.brief`) | Both are optional. `brand` provides stable brand identity (colors, logos, tone) resolved via the domain's `/.well-known/brand.json`. The brief is an asset on the manifest (`assets.brief`), so it travels with the creative through regeneration, resizing, and auditing. The `message` field provides per-request natural language instructions. **Precedence**: The `brand` parameter is the authoritative source for creative rendering context (colors, logos, tone). **Layering**: The brief asset on the manifest provides structured direction; `message` on the request provides per-request natural language overrides. When both provide conflicting direction, `message` takes precedence as the most specific instruction. ### Transformation model `build_creative` follows a **manifest-in, manifest-out** model: * Input: Creative manifest (can be minimal or complete -- everything lives in assets) * Process: Transform/generate based on `message` and manifest content * Output: Target creative manifest ready for preview or sync (brief carries forward) ### Pure generation vs transformation * **Pure Generation**: Provide minimal `creative_manifest` with just the format\_id, catalog assets (if the format renders catalog items), and any required seed assets. The creative agent generates output assets from scratch using `message` as guidance. * **Transformation**: Provide complete `creative_manifest` with all existing assets. The creative agent adapts existing assets to the target format, optionally following guidance in `message`. ### Integration with other tasks 1. **build\_creative** → Generates manifest (optionally with inline preview via `include_preview`) 2. **preview\_creative** → Renders the manifest separately (see [preview\_creative](/dist/docs/3.1.20/creative/task-reference/preview_creative)) 3. **sync\_creatives** → Traffics the finalized manifest Use `include_preview: true` to combine build and preview into one call. If the agent doesn't support it, the response simply omits the `preview` field — fall back to a separate `preview_creative` call. Either way, the preview content fields (`previews`, `interactive_url`, `expires_at`) are the same. This separation allows you to: * Build once, preview multiple times with different contexts * Iterate on build without re-syncing * Preview before committing to traffic ### Iterative refinement `build_creative` supports multi-turn iteration without a mode flag. The presence and combination of fields determines the operation: * **Generation**: `message` + minimal `creative_manifest` (empty or seed assets) + `target_format_id` * **Transformation**: full `creative_manifest` + `message` + `target_format_id` * **Library retrieval**: `creative_id` + `target_format_id` + optional `macro_values` * **Refinement**: previous output as `creative_manifest` + `message` with changes To refine, pass the previous response's `creative_manifest` back as input with a new `message`. Alternatively, update the brief asset (`assets.brief`) to change the creative direction — the brief is the buyer-owned source of truth for what the creative should be. ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/build-creative-request.json", "idempotency_key": "b4c5d6e7-f8a9-4012-b345-012345678901", "message": "Make the headline bolder and increase the contrast on the CTA button", "target_format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_generative" }, "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "banner_image": { "asset_type": "image", "url": "https://cdn.creative-agent.com/generated/banner_12345.png", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "50% Off Winter Sale" }, "clickthrough_url": { "asset_type": "url", "url": "https://mybrand.example.com/winter-sale?campaign={MEDIA_BUY_ID}" } } } } ``` ## Error codes | Code | Description | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `FORMAT_NOT_SUPPORTED` | The `target_format_id` (or `target_format_ids[N]` in a multi-format request) is not supported by this creative agent. For canonical creative-agent routing, retry with an advertised `creative.supported_formats[].capability_id`; legacy named-format IDs remain valid when the agent still advertises them. | | `VALIDATION_ERROR` | The `creative_manifest` is malformed or missing required assets for the target format | | `CREATIVE_NOT_FOUND` | The `creative_id` does not exist in the agent's library (or in the specified `concept_id`) | | `COMPLIANCE_UNSATISFIED` | A required disclosure from the brief cannot be rendered in the target format (e.g., format does not support the required disclosure position) | # get_creative_delivery Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/task-reference/get_creative_delivery get_creative_delivery retrieves variant-level delivery data with manifests and performance metrics for generative and static creatives in AdCP. Retrieve creative delivery data including variant-level breakdowns with manifests and metrics. This task returns what variants were created from your creatives, what they looked like (via manifests), and how they performed. This is a Creative Protocol task. Call it on any agent that declares `"creative"` in `supported_protocols` and `"delivery"` in its [creative agent capabilities](#capability-declaration) — whether that's a dedicated creative service or a [sales agent implementing the Creative Protocol](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities). **Request Schema**: [`/schemas/3.1.20/creative/get-creative-delivery-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/get-creative-delivery-request.json) **Response Schema**: [`/schemas/3.1.20/creative/get-creative-delivery-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/get-creative-delivery-response.json) ## Request Parameters At least one scoping filter (`media_buy_ids` or `creative_ids`) is required. | Parameter | Type | Required | Description | | --------------- | -------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | No | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Limits results to creatives within this account. | | `media_buy_ids` | string\[] | Yes\* | Filter to specific media buys by publisher ID | | `creative_ids` | string\[] | Yes\* | Filter to specific creatives by ID | | `start_date` | string | No | Start date for delivery period (YYYY-MM-DD, interpreted in the platform's reporting timezone) | | `end_date` | string | No | End date for delivery period (YYYY-MM-DD, interpreted in the platform's reporting timezone) | | `max_variants` | integer | No | Maximum variants to return per creative. Useful for generative creatives with large variant counts. | | `pagination` | object | No | Cursor-based pagination parameters (`max_results`, `cursor`) for the creatives array. When omitted, all matching creatives are returned. | \* At least one of `media_buy_ids` or `creative_ids` must be provided. ## Response | Field | Description | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `account_id` | Account identifier (present when scoped to a specific account) | | `media_buy_id` | Seller's media buy identifier (present when request was scoped to a single buy) | | `currency` | ISO 4217 currency code for monetary values (e.g., 'USD', 'EUR') | | `reporting_period` | Date range with start/end timestamps and `timezone` (IANA identifier — platforms report in their native timezone) | | `creatives` | Array of creative delivery data with variant breakdowns | | `pagination` | (Optional) Pagination info with `max_results`, `cursor`, and `has_more`. Present when the request included pagination parameters. | ### Creative Object | Field | Description | | --------------- | -------------------------------------------------------------------------------------------------------------- | | `creative_id` | Creative identifier | | `media_buy_id` | Seller's media buy identifier (present when the request spanned multiple media buys) | | `format_id` | Format of this creative | | `totals` | Aggregate delivery metrics across all variants — see [Delivery metrics fields](#delivery-metrics-fields) below | | `variant_count` | Total number of variants (may exceed `variants` array length when `max_variants` is used) | | `variants` | Array of variant-level delivery data (empty if creative has no variants yet) | ### Delivery metrics fields Fields available on both `creative.totals` and each `variant` entry. Commonly-used subset — see the [delivery-metrics schema](https://adcontextprotocol.org/schemas/3.1.20/core/delivery-metrics.json) for the full list including incrementality, brand lift, and broadcast metrics. | Field | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `impressions` | Impressions delivered | | `spend` | Amount spent (in `currency`) | | `clicks` | Total clicks | | `ctr` | Click-through rate (clicks/impressions) | | `cpm` | Cost per thousand impressions ((spend/impressions) × 1000) | | `cost_per_click` | Cost per click (spend/clicks) | | `views` | Billable views — threshold varies by format and pricing model | | `completed_views` | Video/audio completions | | `completion_rate` | Completion rate (completed\_views/impressions); `null` when not applicable (e.g. a non-video buy) | | `quartile_data` | Audio/video quartile completion data — object with `q1_views`, `q2_views`, `q3_views`, `q4_views`; `null` when not applicable (e.g. a non-video buy) | | `reach` | Unique reach in units specified by `reach_unit`. Measurement window declared via `reach_window`; without it, do not sum across rows | | `reach_unit` | Unit of measurement for `reach`; required when `reach` is present | | `reach_window` | Window semantics for reach/frequency — object with `kind` (`cumulative` for uniques since campaign start, `period` for non-overlapping snapshot, `rolling` for trailing window) and `period` (Duration, required for `period` and `rolling`). Optional but strongly recommended whenever `reach` is present | | `frequency` | Average frequency per `reach_unit`, measured over `reach_window` | | `grps` | Gross Rating Points delivered (for CPP) | | `conversions` | Total attributed conversions; equals the sum of `by_event_type[].count` when present | | `conversion_value` | Total monetary value of attributed conversions | | `roas` | Return on ad spend (conversion\_value/spend) — see note below | | `cost_per_acquisition` | Cost per conversion (spend/conversions) — see note below | | `new_to_brand_rate` | Fraction of conversions from first-time brand buyers (0–1) | | `leads` | Leads generated (convenience alias for `by_event_type` where `event_type='lead'`) | | `by_event_type` | Conversions by event type — array of `{ event_type, count, value?, event_source_id? }` | | `by_action_source` | Conversions by action source (website, app, in\_store) — array of `{ action_source, count, value?, event_source_id? }` | | `dooh_metrics` | DOOH-specific data — object with `loop_plays`, `screens_used`, `screen_time_seconds`, `sov_achieved`, `venue_breakdown` | | `viewability` | Viewability data — object with `vendor`, `measurable_impressions`, `viewable_impressions`, `viewable_rate`, `viewed_seconds` (average in-view duration per measurable impression — pairs with the `viewed_seconds` optimization goal), and `standard`. `standard` SHOULD be present whenever measured viewability values are reported | | `engagements` | Total ad engagements beyond viewing (platform-specific) | | `follows` | New followers or subscriptions attributed to delivery | | `saves` | Saves, bookmarks, or pins attributed to delivery | | `profile_visits` | In-platform page visits attributed to delivery | | `engagement_rate` | Platform-specific engagement rate (engagements/impressions) | | `vendor_metric_values` | Vendor-attested metrics (viewability, attention, etc.) — one entry per vendor and metric identifier | > **Spend-derived metrics.** Sellers should not populate `roas` and `cost_per_acquisition` on individual `variant` objects — spend cannot be attributed per-variant since it applies to the creative as a whole. These fields are meaningful only on `creative.totals`. `by_event_type` entries carry `count` and `value` per event type, but not spend-derived rates. > **Platform-conditional fields.** `dooh_metrics` is present only for DOOH campaigns. Engagement fields (`engagements`, `follows`, `saves`, `profile_visits`, `engagement_rate`) are platform-specific; not all sellers emit them on standardized fields — some use `variant.ext` for engagement data instead. ### Variant Object Each variant represents a specific execution: a fixed creative (Tier 1), an asset combination the platform selected (Tier 2), or a generated variant (Tier 3). For catalog-driven packages, each catalog item rendered as a distinct ad execution is a variant — the variant's manifest includes the catalog reference with the specific item rendered. | Field | Description | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `variant_id` | Platform-assigned variant identifier | | `manifest` | (Optional) The rendered creative manifest — the actual output that was served, not the input assets. Contains format\_id and resolved assets. Not all platforms can provide manifests for every variant. | | `generation_context` | (Optional, Tier 3) Input signals that triggered generation — e.g., page topic, conversation theme, query category. Platforms provide summarized/anonymized signals, not raw user input. When content context is managed through AdCP content standards, includes an `artifact` reference linking to the specific content artifact. Supports `ext` for vendor-specific context structures. | | `ext` | (Optional) Platform-specific data. Social platforms use this for engagement metrics (upvotes, comments, shares) that vary by platform. | | Standard metrics | All [Delivery metrics fields](#delivery-metrics-fields) — same shape as `creative.totals` | `creative_id` and `variant_id` are separate namespaces. The canonical build-to-delivery join is `build_creative.variants[].build_variant_id` → promoted `creative_id` → delivery `creative_id`. `variant_id` remains the platform's served execution variant id. Derived metrics like `ctr`, `completion_rate`, `roas`, and `cost_per_click` are platform-calculated and may not equal naive division of their component fields due to rounding, attribution windows, or filtered impressions. ## Tier Behavior ### Tier 1: Standard Creatives One creative maps 1:1 to one variant. The variant metrics match the creative totals. ```json theme={null} { "media_buy_id": "mb_12345", "currency": "USD", "reporting_period": { "start": "2026-01-20T00:00:00-05:00", "end": "2026-01-27T23:59:59-05:00", "timezone": "America/New_York" }, "creatives": [ { "creative_id": "hero_video_30s", "totals": { "impressions": 150000, "spend": 7500, "clicks": 4500, "ctr": 0.03, "completion_rate": 0.72 }, "variants": [ { "variant_id": "hero_video_30s", "impressions": 150000, "spend": 7500, "clicks": 4500, "ctr": 0.03, "completion_rate": 0.72 } ] } ] } ``` ### Platform engagement metrics Social and feed-native platforms include engagement data in the `ext` field on each variant, since engagement types vary by platform: ```json theme={null} { "variant_id": "promoted_post_running_community", "impressions": 85000, "spend": 4250, "clicks": 2550, "ctr": 0.03, "ext": { "upvotes": 1240, "comments": 87, "shares": 34, "saves": 156, "comment_sentiment": "positive" } } ``` The `ext` field is not standardized across platforms — each platform defines its own engagement schema. Buyers aggregating across social platforms should map platform-specific fields to a common model. ### Tier 2: Asset Group Optimization Buyer provides multiple asset alternatives using a format with `selection_mode: "optimize"`. Platform tests combinations and returns the manifest for each variant showing which assets were selected. ```json theme={null} { "media_buy_id": "mb_12345", "currency": "USD", "reporting_period": { "start": "2026-01-20T00:00:00-05:00", "end": "2026-01-27T23:59:59-05:00", "timezone": "America/New_York" }, "creatives": [ { "creative_id": "summer_campaign_assets", "totals": { "impressions": 200000, "spend": 10000, "clicks": 8000, "ctr": 0.04 }, "variants": [ { "variant_id": "var_headline_a_image_c", "manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "assets": { "headline_0_text": { "asset_type": "text", "content": "Summer Sale - 50% Off" }, "image_0_url": { "asset_type": "image", "url": "https://cdn.brand.com/beach_hero.jpg", "width": 300, "height": 250 } } }, "impressions": 120000, "spend": 6000, "clicks": 6000, "ctr": 0.05 }, { "variant_id": "var_headline_b_image_d", "manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "assets": { "headline_0_text": { "asset_type": "text", "content": "Shop Summer Styles" }, "image_0_url": { "asset_type": "image", "url": "https://cdn.brand.com/sunset_hero.jpg", "width": 300, "height": 250 } } }, "impressions": 80000, "spend": 4000, "clicks": 2000, "ctr": 0.025 } ] } ] } ``` ### Tier 3: Generative Creative Platform generates variants from brand manifest and input contexts. The `manifest` contains the generated assets — which may differ entirely from what the buyer submitted. When the publisher uses AdCP content standards, `generation_context` can include an `artifact` reference linking the variant to the specific content (article, video, etc.) that triggered generation. Platforms can also use `ext` for vendor-specific context structures. ```json theme={null} { "media_buy_id": "mb_12345", "currency": "USD", "reporting_period": { "start": "2026-01-20T00:00:00-05:00", "end": "2026-01-27T23:59:59-05:00", "timezone": "America/New_York" }, "creatives": [ { "creative_id": "generative_banner", "totals": { "impressions": 300000, "spend": 15000, "clicks": 12000, "ctr": 0.04 }, "variants": [ { "variant_id": "gen_mobile_morning", "generation_context": { "context_type": "web_page", "artifact": { "property_id": { "type": "domain", "value": "techreview.example.com" }, "artifact_id": "article_semiconductor_trends_2026" }, "topic": "technology, semiconductors", "device_class": "mobile" }, "manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250_generative" }, "assets": { "hero_image": { "asset_type": "image", "url": "https://cdn.creative.example.com/generated/mobile_morning_v1.jpg", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "Start Your Summer Right" } } }, "impressions": 180000, "spend": 9000, "clicks": 9000, "ctr": 0.05 }, { "variant_id": "gen_desktop_evening", "generation_context": { "context_type": "web_page", "topic": "lifestyle, evening entertainment", "device_class": "desktop" }, "manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_728x90_generative" }, "assets": { "hero_image": { "asset_type": "image", "url": "https://cdn.creative.example.com/generated/desktop_evening_v1.jpg", "width": 728, "height": 90 }, "headline": { "asset_type": "text", "content": "Evening Deals Await" } } }, "impressions": 120000, "spend": 6000, "clicks": 3000, "ctr": 0.025 } ] } ] } ``` ## Previewing Variants Use `preview_creative` with `request_type: "variant"` to see what a specific variant looked like when served: ```json theme={null} { "request_type": "variant", "variant_id": "gen_mobile_morning" } ``` Since each variant includes its full `manifest`, you can also pass the manifest directly to `preview_creative` as a standard single request to re-render it. ## Relationship to delivery reporting | Task | Protocol | What it provides | | ------------------------ | --------- | ---------------------------------------------------------------------------------------- | | `get_media_buy_delivery` | Media Buy | WHERE and HOW MUCH: impressions, spend, placement data, optional `by_creative` breakdown | | `get_creative_delivery` | Creative | WHAT RAN and HOW: variant manifests and variant-level metrics | Use `media_buy_id` + `creative_id` as join keys to correlate data across both responses. When a sales agent implements both protocols, both tasks are available on the same agent URL. See [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities) for the full pattern. ## Cross-agent aggregation When running campaigns across multiple sellers, call `get_creative_delivery` on each agent separately and correlate results: * **Join key**: Use `creative_id` (buyer-assigned) to correlate the same creative across agents. If you used `concept_id` during upload, filter by concept to group related creatives. * **`variant_id` scope**: Variant IDs are unique within an agent and creative, not globally. Two agents may generate variants with the same `variant_id` value. Prefix with the agent URL when building aggregated dashboards. * **Timezone handling**: Each agent may report in its own timezone via `reporting_period.timezone`. Normalize to a common timezone before aggregating metrics. * **`max_variants` selection**: Agents choose which variants to return when `max_variants` limits the result set. Most agents prioritize by impression volume (most-served first). For representative sampling, make multiple calls with different time ranges rather than relying on a single large `max_variants` value. ## Building a cross-agent dashboard When aggregating delivery data from multiple agents into a unified view, follow this sequence: 1. **Collect**: Call `get_creative_delivery` on each agent in parallel, using the same `creative_ids` filter. 2. **Normalize timezones**: Convert each agent's `reporting_period` to a common timezone before summing. 3. **Merge by `creative_id`**: Group results by `creative_id` across agents. Sum `totals` (impressions, spend, clicks). Do not average derived metrics like `ctr` — recompute them from the summed components. 4. **Prefix `variant_id`**: Create globally unique variant keys by combining `agent_url + variant_id` (e.g., `https://sales.pinnaclemedia-example.com/var_a1b2c3`). This prevents collisions when two agents assign the same variant ID independently. 5. **Group by `concept_id`**: For campaign-level roll-ups, use `concept_id` to group related creatives across sizes and sellers. Pull the concept-to-creative mapping from `list_creatives` on each agent. ```javascript theme={null} // Pseudocode: aggregate delivery from 3 sellers const agents = [pinnacle, novaSports, streamhaus]; const results = await Promise.all( agents.map(agent => agent.getCreativeDelivery({ creative_ids: ['acme_holiday_300x250'], start_date: '2026-11-01', end_date: '2026-11-15', }) ) ); const merged = {}; for (const [i, result] of results.entries()) { if (result.errors) continue; // skip failed agents, retry later for (const creative of result.creatives) { const key = creative.creative_id; if (!merged[key]) merged[key] = { impressions: 0, spend: 0, clicks: 0, variants: [] }; merged[key].impressions += creative.totals.impressions; merged[key].spend += creative.totals.spend; merged[key].clicks += creative.totals.clicks || 0; for (const v of creative.variants || []) { merged[key].variants.push({ ...v, variant_id: `${agents[i].url}/${v.variant_id}`, // globally unique }); } } } // Recompute derived metrics for (const c of Object.values(merged)) { c.ctr = c.impressions > 0 ? c.clicks / c.impressions : 0; } ``` ## Capability declaration Agents that support this task declare `"delivery"` in their `capabilities` array within `list_creative_formats` responses: ```json theme={null} { "creative_agents": [{ "agent_url": "https://ads.seller-example.com", "capabilities": ["validation", "assembly", "preview", "delivery"] }] } ``` Buyers discover this by calling `list_creative_formats` and checking the `creative_agents` array for agents with `"delivery"` in their capabilities. This applies to any agent implementing the Creative Protocol, including sales agents. # list_creative_formats Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/task-reference/list_creative_formats list_creative_formats discovers available ad format specifications from any AdCP agent, including asset requirements and technical constraints. Discover creative formats supported by a creative agent. Returns full format specifications including asset requirements and technical constraints. **Response Time**: \~1 second (database lookup) **Authentication**: None required (public endpoint for format discovery) **Request Schema**: [`/schemas/3.1.20/creative/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creative-formats-request.json) **Response Schema**: [`/schemas/3.1.20/creative/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creative-formats-response.json) ## Behavior by agent type Any agent implementing the Creative Protocol can serve `list_creative_formats`. The response varies based on what the agent does: **Dedicated creative agents** (like `https://creative.adcontextprotocol.org`): * Return **authoritative format definitions** they own * Provide full specifications for building and validating creatives **Sales agents implementing only Media Buy Protocol** (like `https://test-agent.adcontextprotocol.org`): * Return **only formats used by active products** * Reference creative agents for authoritative format specifications * Filter results based on what's actually purchasable **Sales agents implementing both protocols** — return their own self-hosted format definitions alongside referenced formats. See [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities). See [list\_creative\_formats (Sales Agent)](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) for sales agent-specific behavior. ## Request Parameters | Parameter | Type | Required | Description | | ------------------------ | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `format_ids` | FormatID\[] | No | Return only specific format IDs (from `get_products` response) | | `type` | string | No | *(deprecated)* Filter by type: `audio`, `video`, `display`, `dooh`. Prefer `asset_types` filter instead. | | `asset_types` | string\[] | No | Filter to formats accepting these asset types: `image`, `video`, `audio`, `text`, `markdown`, `html`, `css`, `javascript`, `vast`, `daast`, `url`, `webhook`, `brief`, `catalog`, `published_post`. Uses OR logic. **Recommended over `type` filter.** | | `max_width` | integer | No | Maximum width in pixels (inclusive) - matches if ANY render fits | | `max_height` | integer | No | Maximum height in pixels (inclusive) - matches if ANY render fits | | `min_width` | integer | No | Minimum width in pixels (inclusive) | | `min_height` | integer | No | Minimum height in pixels (inclusive) | | `is_responsive` | boolean | No | Filter for responsive formats (adapt to container size) | | `name_search` | string | No | Search formats by name (case-insensitive partial match) | | `wcag_level` | string | No | Filter to formats meeting at least this WCAG level: `A`, `AA`, `AAA`. See [Accessibility](/dist/docs/3.1.20/creative/accessibility). | | `disclosure_positions` | string\[] | No | Filter to formats that support all of these disclosure positions. Matches against `disclosure_capabilities` when present, otherwise falls back to `supported_disclosure_positions`. | | `disclosure_persistence` | string\[] | No | Filter to formats whose `disclosure_capabilities` include all of these persistence modes on at least one position. Values: `continuous`, `initial`, `flexible`. | | `output_format_ids` | FormatID\[] | No | *(deprecated in 3.1)* Filter to formats whose `output_format_ids` includes any of these. Returns formats that can produce these outputs — inspect their `input_format_ids` to see what inputs they accept. Use [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers) for build-capability discovery instead. | | `input_format_ids` | FormatID\[] | No | *(deprecated in 3.1)* Filter to formats whose `input_format_ids` includes any of these. Returns formats that accept these creatives as input — inspect their `output_format_ids` to see what they can produce. Use [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers) for build-capability discovery instead. | | `pagination` | object | No | Pagination: `max_results` (1-100, default 50) and `cursor` (opaque cursor from previous response) | ### Multi-Render Dimension Filtering Formats may produce multiple rendered pieces (e.g., video + companion banner). Dimension filters use **"any render fits"** logic: * `max_width: 300, max_height: 250` - Returns formats where AT LEAST ONE render is ≤ 300×250 * Use case: "Find formats that can render into my 300×250 ad slot" * Example: Format with primary video (1920×1080) + companion banner (300×250) **matches** because companion fits ## Response | Field | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------ | | `formats` | Array of full format definitions (format\_id, name, assets, renders). The `type` field is deprecated and may be omitted. | | `creative_agents` | Optional array of other creative agents providing additional formats | See [Format schema](https://adcontextprotocol.org/schemas/3.1.20/core/format.json) for complete format object structure. ### Recursive Discovery Creative agents may reference other creative agents that provide additional formats: ```json theme={null} { "creative_agents": [{ "agent_url": "https://creative.adcontextprotocol.org", "agent_name": "AdCP Reference Creative Agent", "capabilities": ["validation", "assembly", "preview"] }] } ``` Buyers can recursively query creative\_agents. **Track visited URLs to avoid infinite loops.** ## Catalog requirements Formats declare catalog needs as `catalog` asset types in their `assets` array. This tells buyers which catalogs to sync before submitting creatives for that format. ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "product_carousel_4x" }, "name": "Product Carousel (4 items)", "assets": [ { "item_type": "individual", "asset_id": "product_catalog", "asset_type": "catalog", "required": true, "requirements": { "catalog_type": "product", "min_items": 4, "required_fields": ["name", "price", "image_url"] } } ] } ``` Catalog assets use `asset_type: "catalog"` with a `requirements` object containing: | Field | Type | Description | | ----------------- | --------- | ------------------------------------------------------------------------ | | `catalog_type` | string | Required. The catalog type (e.g., `product`, `store`, `job`) | | `min_items` | integer | Minimum items the catalog must contain | | `max_items` | integer | Maximum items the format can render. Items beyond this limit are ignored | | `required_fields` | string\[] | Fields that must be present on every item | | `feed_formats` | string\[] | Accepted feed formats (e.g., `google_merchant_center`, `linkedin_jobs`) | When catalog assets are present, buyers should sync the required catalogs via [`sync_catalogs`](/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs) before submitting creatives. See [Catalogs](/dist/docs/3.1.20/creative/catalogs) for the full lifecycle. ## Common Scenarios ### Get Specs for Product Format IDs ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // Get full specs for formats returned by get_products const result = await testAgent.listCreativeFormats({ format_ids: [ { agent_url: 'https://creative.adcontextprotocol.org', id: 'video_15s_hosted' }, { agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250' } ] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } // Validate response against schema const validated = ListCreativeFormatsResponseSchema.parse(result.data); validated.formats.forEach(format => { console.log(`${format.name}: ${format.assets.length} assets required`); }); ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest, FormatId async def main(): # Get full specs for formats returned by get_products result = await test_agent.list_creative_formats( ListCreativeFormatsRequest( format_ids=[ FormatId(agent_url='https://creative.adcontextprotocol.org', id='video_15s_hosted'), FormatId(agent_url='https://creative.adcontextprotocol.org', id='display_300x250') ] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") for fmt in result.formats: print(f"{fmt.name}: {len(fmt.assets)} assets required") asyncio.run(main()) ``` ### Find Formats by Asset Types ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // Find formats that accept images and text const result = await testAgent.listCreativeFormats({ asset_types: ['image', 'text'] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListCreativeFormatsResponseSchema.parse(result.data); console.log(`Found ${validated.formats.length} formats`); // Examine asset requirements validated.formats.forEach(format => { console.log(`\n${format.name}:`); format.assets.forEach(asset => { const label = asset.asset_role ?? asset.asset_id; console.log(` - ${label}: ${asset.asset_type}`); }); }); ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest async def main(): # Find formats that accept images and text result = await test_agent.list_creative_formats( ListCreativeFormatsRequest(asset_types=['image', 'text']) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") print(f"Found {len(result.formats)} formats") for fmt in result.formats: print(f"\n{fmt.name}:") for asset in fmt.assets: label = asset.asset_role or asset.asset_id print(f" - {label}: {asset.asset_type}") asyncio.run(main()) ``` ### Find Third-Party Tag Formats ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // Find formats that accept JavaScript or HTML tags const result = await testAgent.listCreativeFormats({ asset_types: ['javascript', 'html'], max_width: 970, max_height: 250 }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListCreativeFormatsResponseSchema.parse(result.data); console.log(`Found ${validated.formats.length} third-party tag formats ≤ 970×250`); validated.formats.forEach(format => { const renders = format.renders || []; if (renders.length > 0) { const dims = renders[0].dimensions; console.log(`${format.name}: ${dims.width}×${dims.height}`); } }); ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest async def main(): # Find formats that accept JavaScript or HTML tags result = await test_agent.list_creative_formats( ListCreativeFormatsRequest( asset_types=['javascript', 'html'], max_width=970, max_height=250 ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") print(f"Found {len(result.formats)} third-party tag formats ≤ 970×250") for fmt in result.formats: if fmt.renders: dims = fmt.renders[0].dimensions print(f"{fmt.name}: {dims.width}×{dims.height}") asyncio.run(main()) ``` ### Filter by Type and Dimensions ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // Find video formats const result = await testAgent.listCreativeFormats({ type: 'video' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListCreativeFormatsResponseSchema.parse(result.data); console.log(`Found ${validated.formats.length} video formats`); validated.formats.forEach(format => { const assetTypes = format.assets.map(a => a.asset_type).join(', '); console.log(`${format.name}: ${assetTypes}`); }); ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest async def main(): # Find video formats result = await test_agent.list_creative_formats( ListCreativeFormatsRequest(type='video') ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") print(f"Found {len(result.formats)} video formats") for fmt in result.formats: asset_types = ', '.join(a.asset_type for a in fmt.assets) print(f"{fmt.name}: {asset_types}") asyncio.run(main()) ``` ### Search by Name ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // Find mobile-optimized formats const result = await testAgent.listCreativeFormats({ name_search: 'mobile' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListCreativeFormatsResponseSchema.parse(result.data); console.log(`Found ${validated.formats.length} mobile formats`); validated.formats.forEach(format => { console.log(`- ${format.name} (${format.type})`); }); ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest async def main(): # Find mobile-optimized formats result = await test_agent.list_creative_formats( ListCreativeFormatsRequest(name_search='mobile') ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") print(f"Found {len(result.formats)} mobile formats") for fmt in result.formats: print(f"- {fmt.name} ({fmt.type})") asyncio.run(main()) ``` ### Responsive Formats ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // Find formats that adapt to container size const result = await testAgent.listCreativeFormats({ is_responsive: true, type: 'display' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListCreativeFormatsResponseSchema.parse(result.data); console.log(`Found ${validated.formats.length} responsive display formats`); validated.formats.forEach(format => { console.log(`${format.name}: Adapts to container`); }); ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest async def main(): # Find formats that adapt to container size result = await test_agent.list_creative_formats( ListCreativeFormatsRequest(is_responsive=True, type='display') ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") print(f"Found {len(result.formats)} responsive display formats") for fmt in result.formats: print(f"{fmt.name}: Adapts to container") asyncio.run(main()) ``` ### Discover Build Capabilities **Deprecated in 3.1.** Build-capability discovery has moved to [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers). A transformer is the account-scoped, agent-offered unit of build capability and carries its own `input_format_ids`, `output_format_ids`, and `pricing_options`. The `input_format_ids` and `output_format_ids` filters on this task — and the matching fields on the [Format object](https://adcontextprotocol.org/schemas/3.1.20/core/format.json) — still work during 3.1–3.x but will be removed in 4.0. Query `list_transformers` to discover what an agent can build and what it costs. Some formats declare the output formats they can produce via `output_format_ids`. A creative builder (like a multi-publisher template tool) may accept one asset group and produce many publisher-specific formats. A format transformer may accept an existing creative and reformat it. The format schema expresses both sides of the relationship: * **`input_format_ids`** — existing creative formats this format accepts as input * **`output_format_ids`** — concrete output formats this format can produce These filters are ANDed — a format must match all specified filters. Within each filter, matching is OR (any ID in the array matches). A bare format ID (no dimension parameters) matches all parameterized variants of that format; a parameterized ID is an exact match. Note: `asset_types` and these filters target different things. A format that takes only creative manifests as input will have no entries in its `assets` array, so combining `asset_types` with `input_format_ids` will typically return no results. Serve-time dynamic creative (DCO platforms that render from data feeds at ad serving time) is not expressed through these fields — those platforms describe their inputs via `assets` and their output via the format itself. #### Given output formats I need, what inputs are accepted? ```javascript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // I need portrait video — what can generate it? const result = await testAgent.listCreativeFormats({ output_format_ids: [ { agent_url: 'https://creative.adcontextprotocol.org', id: 'video_9x16_15s' } ] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListCreativeFormatsResponseSchema.parse(result.data); validated.formats.forEach(format => { const inputs = format.input_format_ids?.map(f => f.id) ?? ['(from brief)']; console.log(`${format.name} accepts: ${inputs.join(', ')}`); }); ``` ```python test=false theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest, FormatId async def main(): result = await test_agent.list_creative_formats( ListCreativeFormatsRequest( output_format_ids=[ FormatId(agent_url='https://creative.adcontextprotocol.org', id='video_9x16_15s') ] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") for fmt in result.formats: inputs = [f.id for f in fmt.input_format_ids] if fmt.input_format_ids else ['(from brief)'] print(f"{fmt.name} accepts: {', '.join(inputs)}") asyncio.run(main()) ``` #### Given an input format I have, what outputs can it produce? ```javascript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { ListCreativeFormatsResponseSchema } from '@adcp/sdk'; // I have a landscape 16:9 video — what can I transform it into? const result = await testAgent.listCreativeFormats({ input_format_ids: [ { agent_url: 'https://creative.adcontextprotocol.org', id: 'video_16x9_30s' } ] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = ListCreativeFormatsResponseSchema.parse(result.data); validated.formats.forEach(format => { const outputs = format.output_format_ids?.map(f => f.id) ?? []; console.log(`${format.name} → ${outputs.join(', ')}`); }); ``` ```python test=false theme={null} import asyncio from adcp.testing import test_agent from adcp.types import ListCreativeFormatsRequest, FormatId async def main(): result = await test_agent.list_creative_formats( ListCreativeFormatsRequest( input_format_ids=[ FormatId(agent_url='https://creative.adcontextprotocol.org', id='video_16x9_30s') ] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed: {result.errors}") for fmt in result.formats: outputs = [f.id for f in fmt.output_format_ids] if fmt.output_format_ids else [] print(f"{fmt.name} → {', '.join(outputs)}") asyncio.run(main()) ``` ## Format Structure Each format includes: | Field | Description | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `format_id` | Structured identifier with agent\_url and id | | `name` | Human-readable format name | | `type` | *(deprecated)* Format type (audio, video, display, dooh). Use `asset_types` filter instead. | | `assets` | Array of all assets with `required` boolean indicating mandatory vs optional | | `renders` | Array of rendered output pieces (dimensions, role) | | `input_format_ids` | *(deprecated in 3.1)* Creative formats this format accepts as input manifests (omitted for formats that work from raw assets). Carried on the transformer instead — see [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers). | | `output_format_ids` | *(deprecated in 3.1)* Output formats this format can produce (omitted for formats that produce a single fixed output). Carried on the transformer instead — see [`list_transformers`](/dist/docs/3.1.20/creative/task-reference/list_transformers). | ### Asset Roles Common asset roles help identify asset purposes: * **`hero_image`** - Primary visual * **`hero_video`** - Primary video content * **`logo`** - Brand logo * **`headline`** - Primary text * **`body_text`** - Secondary text * **`call_to_action`** - CTA button text ## Asset Types Filter Logic The `asset_types` parameter uses **OR logic** - formats matching ANY specified asset type are returned. **Example**: `asset_types: ['html', 'javascript', 'image']` * Returns formats accepting html OR javascript OR image * Use case: "Show me formats I can use with any of my available asset types" For boosted/existing-post formats, use `asset_types: ['published_post']` to find formats that accept a `published_post` reference asset instead of uploaded media bytes. **To find formats requiring specific combinations**, filter results after retrieval: ```javascript test=false theme={null} // Find formats requiring BOTH image AND text const result = await agent.listCreativeFormats(); const imageAndText = result.formats.filter(format => { const assetTypes = format.assets.map(a => a.asset_type); return assetTypes.includes('image') && assetTypes.includes('text'); }); ``` ## Dimension Filtering for Multi-Render Formats Some formats produce multiple rendered pieces: * **Video with companion banner** - Primary video (1920×1080) + banner (300×250) * **Adaptive displays** - Desktop (728×90) + mobile (320×50) * **DOOH installations** - Multiple screens with different dimensions Dimension filters match if **at least one render** fits: ```javascript test=false theme={null} // Find formats with ANY render ≤ 300×250 const result = await agent.listCreativeFormats({ max_width: 300, max_height: 250 }); // Returns formats where at least one render fits 300×250 slot // May also include larger companion pieces ``` ## Implementation Requirements When implementing `list_creative_formats` for a creative agent: 1. **Return authoritative formats** - Include full specifications for formats you define 2. **Reference other agents** - Use `creative_agents` to delegate to other creative agents 3. **Include capabilities** - Indicate what operations you support (validation, assembly, generation, preview) 4. **Support filtering** - Implement filter parameters (type, asset\_types, dimensions, etc.) ## Error Handling | Error Code | Description | Resolution | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | | `REFERENCE_NOT_FOUND` | Requested `format_id` doesn't exist, or referenced creative agent is unavailable / not accessible. `error.field` MUST identify which typed parameter failed to resolve. | Verify `format_id` from `get_products` response; the format may be from a deprecated agent. | | `INVALID_REQUEST` | Invalid filter parameters | Check parameter types and values | ## Best Practices **1. Use format\_ids Parameter** Most efficient way to get specs for formats returned by `get_products`. **2. Cache Format Specifications** Format specs rarely change - cache by format\_id to reduce API calls. **3. Filter by Asset Types for Third-Party Tags** Search for `asset_types: ['html']` or `['javascript']` to find tag-accepting formats. **4. Consider Multi-Render Formats** Check `renders` array length - some formats produce multiple pieces requiring multiple placements. **5. Validate Asset Requirements** Ensure your creative assets match format specifications before building creatives. ## Next Steps After discovering formats: 1. **Build Creatives**: Use [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) to assemble assets into format 2. **Preview**: Use [`preview_creative`](/dist/docs/3.1.20/creative/task-reference/preview_creative) to see visual output 3. **Validate**: Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) with `dry_run: true` 4. **Upload**: Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) to upload to an agent-hosted creative library ## Learn More * [Format Schema](https://adcontextprotocol.org/schemas/3.1.20/core/format.json) - Complete format structure * [Asset Types](/dist/docs/3.1.20/creative/asset-types) - Asset specification details * [Standard Formats](/dist/docs/3.1.20/media-buy/capability-discovery/implementing-standard-formats) - IAB-compatible reference formats # list_creatives Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/task-reference/list_creatives list_creatives browses and filters creatives in an AdCP library by format, status, concept, and tags with cursor-based pagination. Browse and filter creatives in a creative library. Supports filtering by format, status, concept, tags, date range, and dynamic variables, with pagination and optional field enrichment. Implemented by any agent that hosts a creative library — creative agents (ad servers, creative management platforms) and sales agents that manage creatives. **Response time**: \~1 second (simple database lookup) ## Overview **Key features:** * Filter by format, status, tags, dates, assignments, concepts, and variables * Sort by creation date, update date, name, status, or assignment count * Cursor-based pagination for large libraries * Optionally include assignments, delivery snapshots, items, and dynamic creative optimization (DCO) variables * Return only specific fields to reduce response size * Filter by creative concept (groups of related creatives across sizes/formats) * Find DCO creatives and inspect their dynamic content slots ## Request parameters **Schema**: [`creative/list-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-request.json) ### Core parameters | Parameter | Type | Required | Description | | ------------ | ------ | -------- | ------------------------------------------------------------------- | | `filters` | object | No | Filter criteria — see [filtering options](#filtering-options) below | | `sort` | object | No | Sorting parameters | | `pagination` | object | No | Pagination controls | ### Data inclusion options | Parameter | Type | Required | Description | | -------------------------- | ------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `include_assignments` | boolean | No | Include package assignment information (default: true) | | `include_snapshot` | boolean | No | Include a lightweight delivery snapshot — lifetime impressions and last-served date (default: false). For detailed analytics, use [`get_creative_delivery`](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery). | | `include_items` | boolean | No | Include items for multi-asset formats like carousels and native ads (default: false) | | `include_variables` | boolean | No | Include dynamic content variable definitions (default: false) | | `include_pricing` | boolean | No | Include `pricing_options` on each creative (default: false). Requires `account`. | | `include_purged` | boolean | No | Include soft-purged creative tombstones (default: false). See [Purged tombstones](#purged-tombstones). | | `include_webhook_activity` | boolean | No | Include recent webhook fires per creative (default: false). See [Webhook activity](#webhook-activity). | | `webhook_activity_limit` | integer | No | Maximum `webhook_activity[]` records per creative when `include_webhook_activity: true` (default: 50, range 1–200). | | `account` | [AccountRef](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | No | Account reference for pricing. When provided with `include_pricing`, the agent returns `pricing_options` from this account's rate card on each creative. | | `fields` | array | No | Specific fields to return (omit for all fields). Includes `"pricing_options"` for sparse selection. | ## Filtering options The `filters` object supports these optional, composable filters: | Filter | Type | Description | | ---------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `accounts` | [AccountRef](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references)\[] | Filter by owning accounts | | `format_ids` | FormatID\[] | Filter by structured format IDs | | `statuses` | [CreativeStatus](/dist/docs/3.1.20/creative/specification#creative-status-lifecycle)\[] | Filter by approval status | | `tags` | string\[] | Filter by tags (all must match) | | `tags_any` | string\[] | Filter by tags (any must match) | | `name_contains` | string | Case-insensitive name search | | `creative_ids` | string\[] | Filter by specific creative IDs (max 100) | | `concept_ids` | string\[] | Filter by concept groupings | | `has_variables` | boolean | Filter for DCO creatives with dynamic variables | | `created_after` / `created_before` | date-time | Filter by creation date range | | `updated_after` / `updated_before` | date-time | Filter by last-modified date range | | `assigned_to_packages` | string\[] | Filter by package assignments \* | | `media_buy_ids` | string\[] | Filter by media buy assignments \* | | `unassigned` | boolean | Filter for unassigned creatives \* | | `has_served` | boolean | Filter for creatives that have served at least one impression \* | \* Assignment-related filters are specific to sales agents. Standalone creative agents ignore these. **Archived creatives are excluded by default.** To include archived creatives in results, explicitly include `"archived"` in the `statuses` array. Suspended creatives are not archived; include `"suspended"` when you specifically want recoverably offline creatives such as published-post references with expired authorization. For published-post reference products, `list_creatives` is limited to the downstream publisher identities the seller is authorized to inspect. If a product requires a second platform connection such as `publisher_identity` and it is missing, the seller should return `AUTHORIZATION_REQUIRED` with `error.details.missing_connections[]`. It should not present `list_creatives` as global platform post search. ## Sorting options Sort results by various fields with ascending or descending order: ```json theme={null} { "sort": { "field": "created_date", "direction": "desc" } } ``` **Available sort fields:** * `created_date` - When the creative was created (default) * `updated_date` - When creative was last modified * `name` - Creative name (alphabetical) * `status` - Approval status * `assignment_count` - Number of package assignments ## Pagination Control result set size with cursor-based pagination: ```json theme={null} { "pagination": { "max_results": 50, "cursor": "eyJjcmVhdGVkX2RhdGUiOi4uLn0" } } ``` ## Response format **Schema**: [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-response.json) The response provides creative data with optional enrichment: ```json theme={null} { "query_summary": { "total_matching": 1, "returned": 1, "filters_applied": ["status=approved"] }, "pagination": { "has_more": false, "total_count": 1 }, "creatives": [ { "creative_id": "ft_88201", "name": "Holiday Sale - Medium Rectangle", "format_id": { "agent_url": "https://creative.example.com", "id": "display_static", "width": 300, "height": 250 }, "status": "approved", "created_date": "2026-01-15T10:30:00Z", "updated_date": "2026-01-15T14:20:00Z", "concept_id": "concept_holiday_2026", "concept_name": "Holiday 2026 Campaign", "variables": [ { "variable_id": "headline_text", "name": "Headline", "variable_type": "text", "default_value": "Holiday Sale - 50% Off", "required": true } ] } ], "format_summary": { "display_static_300x250": 1 }, "status_summary": { "approved": 1 } } ``` ### Per-creative fields | Field | Type | Description | | ----------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `creative_id` | string | Unique creative identifier | | `name` | string | Human-readable name | | `format_id` | object | Structured format reference | | `status` | string | Approval status | | `created_date` | string | Creation timestamp | | `updated_date` | string | Last modified timestamp | | `assets` | object | Creative assets (images, text, URLs, etc.) | | `tags` | string\[] | Tags for categorization | | `concept_id` | string | Creative concept ID | | `concept_name` | string | Human-readable concept name | | `variables` | array | DCO variable definitions (when `include_variables=true`) | | `assignments` | object | Package assignments (when `include_assignments=true`) | | `snapshot` | object | Delivery snapshot (when `include_snapshot=true`) | | `snapshot_unavailable_reason` | string | Why snapshot is missing — `SNAPSHOT_UNSUPPORTED`, `SNAPSHOT_TEMPORARILY_UNAVAILABLE`, or `SNAPSHOT_PERMISSION_DENIED` | | `items` | array | Items for multi-asset formats (when `include_items=true`) | | `pricing_options` | [VendorPricingOption](/dist/docs/3.1.20/creative/specification#pricing)\[] | Pricing options for this creative (when `include_pricing=true` and `account` provided). Vendors may offer multiple options (volume tiers, context-specific rates, different models per product line). Same pattern as `get_signals` and `list_content_standards`. | ### Pricing When `include_pricing=true` and `account` is provided, each creative includes `pricing_options` from the account's rate card: ```json theme={null} { "pricing_options": [ { "pricing_option_id": "po_video_cpm", "model": "cpm", "cpm": 0.50, "currency": "USD" } ] } ``` The buyer passes the applied `pricing_option_id` (from the `build_creative` response) in `report_usage` for billing verification. Vendors may offer multiple options — volume/commitment tiers, context-specific rates (premium vs. standard placements), or entirely different pricing models for different product lines. This is the same pattern used by [signals](/dist/docs/3.1.20/signals/tasks/get_signals) and [content standards](/dist/docs/3.1.20/governance/content-standards/index). ### Delivery snapshot When `include_snapshot=true`, each creative includes a lightweight delivery snapshot for operational questions like "is this creative active?" or "when did it last serve?" This is not analytics — for detailed performance data, use [`get_creative_delivery`](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery). ```json theme={null} { "snapshot": { "as_of": "2026-03-08T14:30:00Z", "staleness_seconds": 3600, "impressions": 145200, "last_served": "2026-03-07T22:15:00Z" } } ``` | Field | Type | Required | Description | | ------------------- | --------- | -------- | ---------------------------------------------------------------- | | `as_of` | date-time | Yes | When this snapshot was captured | | `staleness_seconds` | integer | Yes | Maximum age of data in seconds | | `impressions` | integer | Yes | Lifetime impressions (not scoped to any date range) | | `last_served` | date-time | No | Last time this creative served. Absent when it has never served. | ### Purged tombstones When a creative is destroyed via [`creative.purged`](https://adcontextprotocol.org/schemas/3.1.20/creative/creative-purged-webhook.json) with `purge_kind: soft`, the seller retains a tombstone for 30 days from the purge timestamp. The tombstone surfaces on `list_creatives` only when the request sets `include_purged: true`: ```json theme={null} { "creative_id": "ft_87100", "name": "Holiday Sale - Leaderboard (purged)", "status": "approved", "purge": { "kind": "soft", "at": "2026-05-18T02:59:48Z", "reason_code": "retention_expired" } } ``` The `status` field on a tombstone is **frozen at its pre-purge value** (in the example above, `"approved"` is what the creative was right before purge — not a current claim). Buyers MUST treat the creative as gone: assignments, serving operations, and delivery reads no longer apply. The presence of the `purge` block is the unambiguous signal. Hard-purged creatives (`purge_kind: hard`, used for legal erasure under GDPR Article 17 / CCPA / equivalent) retain no tombstone; the [`creative.purged`](https://adcontextprotocol.org/schemas/3.1.20/creative/creative-purged-webhook.json) webhook is the only signal. See [snapshot-and-log § Rule 4 carve-out](/dist/docs/3.1.20/protocol/snapshot-and-log#account-level-adopters-3-1) for the rationale. ### Webhook activity When `include_webhook_activity: true`, each returned creative carries a `webhook_activity[]` array of the most recent fires scoped to that creative — `creative.status_changed` and `creative.purged` deliveries. This is the buyer's debug surface for "did the publisher fire? did my endpoint receive it? was the retry trail clean?" — the same shape and contract as `webhook_activity[]` on `get_media_buys`. See [Webhook activity log pattern](/dist/docs/3.1.20/protocol/snapshot-and-log#webhook-activity-log-pattern) for the full normative contract (retention, three-state presence, request-field conventions). **To subscribe** to these fires, register a [`notification_configs[]`](/dist/docs/3.1.20/accounts/tasks/sync_accounts#account-level-webhook-subscriptions) entry on the account via `sync_accounts`. The seller fires per-subscriber against each entry whose `event_types[]` includes the type. `webhook_activity[]` on this read is the buyer-side debug log of what actually fired. Three-state presence applies: * **Field omitted** — seller does not surface webhook activity on this read. * **`[]`** — seller surfaces the field but no fires fall in the retention window for this creative. * **Non-empty** — actual records, most recent first, capped at `webhook_activity_limit` (max 200). Correlate to your endpoint logs by `idempotency_key`. Each record's `notification_type` discriminates the fire kind: ```json theme={null} { "webhook_activity": [ { "idempotency_key": "whk_01HW9D2T3VXQ5M7K9N1P3R5S7U", "subscriber_id": "buyer-primary", "fired_at": "2026-05-18T14:20:00Z", "completed_at": "2026-05-18T14:20:00Z", "notification_type": "creative.status_changed", "attempt": 1, "status": "success", "url": "https://buyer.example/webhooks/adcp/creative", "http_status_code": 200, "response_time_ms": 142, "payload_size_bytes": 612, "error_message": null } ] } ``` Buyers diagnose "no fires arrived" by combining: (a) subscriber registration state on `list_accounts.accounts[].notification_configs[]` — is the right URL active with the right `event_types[]`? — and (b) seller capability declaration via `get_adcp_capabilities` — does the seller support the event types I subscribed to? Field omission on `webhook_activity` alone does not distinguish "seller doesn't surface the log" from "no fires occurred." ### Buyer handler (end-to-end) A buyer's webhook handler for `creative.status_changed` correlates each fire with library state via `creative_id`, dedupes on `idempotency_key`, and re-reads `list_creatives` for the authoritative snapshot (per [snapshot-and-log Rule 3](/dist/docs/3.1.20/protocol/snapshot-and-log#the-five-rules)): ```javascript theme={null} // POST /webhooks/adcp/creative async function handleCreativeWebhook(req, res) { // 1. Verify signature per the registered scheme (RFC 9421 by default). if (!verifyWebhookSignature(req)) return res.status(401).end(); const fire = req.body; // creative-status-changed-webhook or creative-purged-webhook const { notification_type, idempotency_key, creative_id, account_id } = fire; // 2. Dedupe at-least-once delivery. if (await alreadyProcessed(idempotency_key)) return res.status(200).end(); // 3. Re-read snapshot for authoritative state. Push is signal; snapshot is truth. const snapshot = await testAgent.listCreatives({ filters: { creative_ids: [creative_id] }, include_purged: notification_type === "creative.purged" }); // 4. Apply local effects from the snapshot, not the webhook payload. await reconcileCreative(snapshot.creatives[0]); await markProcessed(idempotency_key); return res.status(200).end(); } ``` Two pitfalls this handler avoids: (1) applying state from the webhook payload directly (ordering and re-emission make the payload non-authoritative); (2) skipping dedup (sellers retry on non-2xx and re-emit on missed-events warnings). ## Account requirements Creative agents that host a library should implement the [accounts protocol](/dist/docs/3.1.20/accounts/overview) (`sync_accounts` / `list_accounts`) so buyers can establish access before querying creatives. This is the same accounts protocol used by sales agents for media buys — there is no separate version. Sales agents that already implement the accounts protocol for media buys do not need to do anything additional. ## Examples ### Concept-scoped query with variables List all approved creatives in a specific concept, including DCO variable definitions: ```json theme={null} { "filters": { "concept_ids": ["concept_holiday_2026"], "statuses": ["approved"] }, "include_variables": true, "sort": { "field": "created_date", "direction": "desc" } } ``` ### Format-specific query Find creatives matching specific format IDs across concepts: ```json theme={null} { "filters": { "format_ids": [ { "agent_url": "https://creative.example.com", "id": "display_static", "width": 300, "height": 250 }, { "agent_url": "https://creative.example.com", "id": "display_static", "width": 728, "height": 90 } ], "statuses": ["approved"] } } ``` ### Find DCO creatives Find creatives with dynamic content variables for personalized campaigns: ```json theme={null} { "filters": { "has_variables": true, "statuses": ["approved"] }, "include_variables": true } ``` ### Field-limited query Get minimal creative data for a selection dropdown: ```json theme={null} { "fields": ["creative_id", "name", "format_id", "status"], "include_assignments": false, "filters": { "statuses": ["approved"] }, "sort": { "field": "name", "direction": "asc" } } ``` ### Library health check Find active creatives with delivery snapshots to identify stale or dormant assets: ```json theme={null} { "filters": { "media_buy_ids": ["mb_summer_2026", "mb_spring_2026"], "statuses": ["approved"] }, "include_assignments": true, "include_snapshot": true, "sort": { "field": "updated_date", "direction": "desc" } } ``` ## Related tasks * [`get_creative_delivery`](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery) - Detailed performance analytics with date ranges, variant breakdowns, and full delivery metrics * [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) - Build manifests from library creatives or generate from scratch * [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) - Upload and manage creative assets on any agent hosting a creative library * [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) - Discover supported creative formats * [`preview_creative`](/dist/docs/3.1.20/creative/task-reference/preview_creative) - Generate previews of creative manifests # preview_creative Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/task-reference/preview_creative preview_creative renders an existing creative manifest into viewable output in AdCP, in single or batch mode, returning URL, image, or HTML output. `preview_creative` renders an existing creative manifest into viewable output. It does not generate or modify the input manifest — use [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) for that. Supports both single creative preview and batch preview (5-10x faster for multiple creatives). **Request Schema**: [`/schemas/3.1.20/creative/preview-creative-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/preview-creative-request.json) **Response Schema**: [`/schemas/3.1.20/creative/preview-creative-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/preview-creative-response.json) ## Quick Start ### Single Creative Preview ```json theme={null} { "request_type": "single", "creative_manifest": { /* includes format_id, assets */ } } ``` Response: ```json theme={null} { "response_type": "single", "previews": [ { "preview_id": "prev_001", "renders": [ { "render_id": "render_1", "output_format": "url", "preview_url": "https://creative-agent.example.com/preview/abc123", "role": "primary" } ], "input": { "name": "Default", "macros": {} } } ], "expires_at": "2027-02-15T18:00:00Z" } ``` Embed the primary render in an iframe: ```html theme={null} ``` ### Direct HTML Embedding For faster rendering without iframe overhead, request HTML directly: ```json theme={null} { "request_type": "single", "creative_manifest": { /* includes format_id, assets */ }, "output_format": "html" } ``` Response contains raw HTML: ```json theme={null} { "response_type": "single", "previews": [ { "preview_id": "prev_002", "renders": [ { "render_id": "render_1", "output_format": "html", "preview_html": "
...
", "role": "primary" } ], "input": { "name": "Default", "macros": {} } } ], "expires_at": "2027-02-15T18:00:00Z" } ``` Only use `output_format: "html"` with trusted creative agents. Direct HTML embedding bypasses iframe sandboxing. ### Batch Preview (Multiple Creatives) Preview multiple creatives in one API call (5-10x faster): ```json theme={null} { "request_type": "batch", "requests": [ { "creative_manifest": { /* creative 1 */ } }, { "creative_manifest": { /* creative 2 */ } } ] } ``` Response contains results in order: ```json theme={null} { "response_type": "batch", "results": [ { "success": true, "creative_id": "creative_1", "response": { "previews": [...], "expires_at": "..." } }, { "success": true, "creative_id": "creative_2", "response": { "previews": [...], "expires_at": "..." } } ] } ``` ### Variant Preview (Post-Flight) Preview what a specific variant looked like when served. Use `variant_id` from `get_creative_delivery` response: ```json theme={null} { "request_type": "variant", "variant_id": "gen_mobile_morning" } ``` Response: ```json theme={null} { "response_type": "variant", "variant_id": "gen_mobile_morning", "previews": [ { "preview_id": "prev_gen_morning", "renders": [ { "render_id": "render_1", "output_format": "url", "preview_url": "https://creative-agent.example.com/preview/variant/gen_mobile_morning", "role": "primary", "dimensions": { "width": 300, "height": 250 } } ] } ], "manifest": { "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250_generative" }, "assets": { "hero_image": { "asset_type": "image", "url": "https://cdn.creative.example.com/generated/mobile_morning_v1.jpg", "width": 300, "height": 250 }, "headline": { "asset_type": "text", "content": "Start Your Summer Right" } } }, "expires_at": "2027-02-15T18:00:00Z" } ``` Since each variant from `get_creative_delivery` includes its full `manifest`, you can also pass the manifest directly to `preview_creative` as a standard single request to re-render it. ## Request Parameters All modes use a single flat object with `request_type` as the discriminant. | Parameter | Type | Required | Description | | ------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `request_type` | string | Yes | `"single"`, `"batch"`, or `"variant"` | | `creative_manifest` | object | Single | Complete creative manifest with all required assets for the format. | | `format_id` | FormatID | No | Format identifier (agent\_url + id). Defaults to `creative_manifest.format_id` if omitted. Used in single mode. | | `inputs` | array | No | Array of input sets for multiple preview variants. Used in single mode. | | `quality` | string | No | `"draft"` (fast, lower-fidelity) or `"production"` (full quality). In batch mode, sets the default for all requests. | | `output_format` | string | No | `"url"` (default) or `"html"`. In batch mode, sets the default for all requests. | | `item_limit` | integer | No | Maximum catalog items to render per preview variant. Used in single mode. | | `template_id` | string | No | Specific template ID for custom format rendering. Used in single mode. | | `requests` | array | Batch | Array of 1-50 preview requests. Each item accepts `creative_manifest` (required), `format_id`, `inputs`, `quality`, `output_format`, `item_limit`, and `template_id`. | | `variant_id` | string | Variant | Platform-assigned variant identifier from `get_creative_delivery`. | | `creative_id` | string | No | Creative identifier for context. Used in variant mode. | **Required** column values: *Single* = required when `request_type` is `"single"`, *Batch* = required when `"batch"`, *Variant* = required when `"variant"`. ### Input Sets Generate multiple preview variants by providing different contexts: ```json theme={null} { "inputs": [ { "name": "Desktop", "macros": { "DEVICE_TYPE": "desktop" } }, { "name": "Mobile", "macros": { "DEVICE_TYPE": "mobile" } }, { "name": "Morning Context", "context_description": "User commuting to work" } ] } ``` **Available macros**: `DEVICE_TYPE`, `COUNTRY`, `CITY`, `DMA`, `GDPR`, `US_PRIVACY`, `CONTENT_GENRE`, etc. **Context descriptions**: For AI-generated content like host-read audio ads. ## Response Format ### Single Mode Response ```typescript theme={null} { response_type: "single"; previews: Preview[]; // One per input (or one default) interactive_url?: string; // Optional sandbox for interactive formats expires_at?: string; // Optional ISO 8601 expiration; omitted means no expiration } ``` ### Batch Mode Response ```typescript theme={null} { response_type: "batch"; results: Array<{ success: boolean; creative_id: string; response?: { previews: Preview[]; expires_at?: string; }; errors?: Array<{ code: string; message: string; }>; }>; } ``` ### Preview Structure ```typescript theme={null} { preview_id: string; renders: Array<{ render_id: string; output_format: "url" | "html" | "both"; preview_url?: string; // When output_format is "url" or "both" preview_html?: string; // When output_format is "html" or "both" role: string; // "primary", "companion", etc. dimensions?: { width: number; height: number; }; }>; input: { name: string; macros?: Record; context_description?: string; }; } ``` **Multi-render formats**: Some formats produce multiple pieces (video + companion banner). Each has its own `render_id` and `role`. ## Previewing generative creative For generative formats — contextual display, AI-generated native, conversational ads — the creative doesn't exist until serve time. Preview serves two distinct purposes: ### Pre-flight: representative samples Before the campaign runs, use single or batch mode to preview what the agent *could* generate given different contexts. Pass `inputs` with `context_description` to simulate serve-time conditions: ```json theme={null} { "$schema": "/schemas/3.1.20/creative/preview-creative-request.json", "request_type": "single", "quality": "draft", "creative_manifest": { "format_id": { "agent_url": "https://ads.seller-example.com", "id": "contextual_display_generative" }, "assets": { "brief": { "asset_type": "brief", "name": "Sustainability story", "objective": "awareness", "messaging": { "key_messages": ["Highlight our sustainability story. Match tone to editorial context."] } } } }, "inputs": [ { "name": "Tech article", "context_description": "Article about semiconductor manufacturing" }, { "name": "Lifestyle blog", "context_description": "Blog post about sustainable living" } ] } ``` These previews are *representative*, not definitive. Real serve-time output depends on live signals (actual page content, user device, time of day) that can't be fully simulated. Use draft quality for fast iteration on the brief and creative direction, then production quality for stakeholder review. ### Post-flight: exact replay After the campaign runs, use variant mode to see exactly what was served. Pass a `variant_id` from `get_creative_delivery`: ```json theme={null} { "request_type": "variant", "variant_id": "gen_tech_mobile_001" } ``` The response includes the variant's actual manifest — the specific headline, image, and layout the agent generated for that context. This is a faithful replay, not a re-generation. ### Setting expectations | Aspect | Standard creative | Generative creative | | ----------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------- | | Pre-flight preview | Exact — what you see is what runs | Representative — shows the agent's interpretation of the brief under simulated conditions | | Post-flight preview | Same as pre-flight | Exact — faithful replay of served output via variant mode | | `quality: "draft"` | Fast wireframe-quality render | Fast, lower-fidelity generation for reviewing creative direction | | `quality: "production"` | Full-fidelity render | Full-quality generation for stakeholder sign-off | | Number of variants | Typically 1 (or a few device variants) | Potentially thousands — one per context | For generative formats where every impression produces a different creative (like AI chat or real-time contextual), pre-flight previews are best understood as *samples from a distribution* rather than *the ad*. The brief and brand identity constrain the distribution; previews let you verify the agent interprets those constraints correctly. ### Conversational and interactive formats For formats where the ad is stateful — AI chat, interactive experiences, conversational native — preview takes on additional meaning: * **Pre-flight** renders a representative first interaction or simulated conversation. The `interactive_url` field in the preview response (when present) provides a sandbox where reviewers can interact with the experience directly. Use `context_description` to simulate different conversation entry points. * **Post-flight** variant replay shows the actual exchange that occurred. For multi-turn formats, the variant manifest captures the full content the agent produced (message sequence, responses, media assets shown). The level of detail depends on the agent — some provide full transcripts, others provide summarized content with anonymized user signals. These formats have the widest gap between pre-flight and post-flight: a pre-flight preview can only approximate one possible conversation path, while the live experience adapts to each user. Preview enough scenarios to verify tone, guardrails, and brand consistency. ### Quality mismatch If the requested quality level is not supported, the agent renders at the best quality it can provide. The protocol does not require agents to support both levels — an agent that only generates at one fidelity ignores the parameter. There is no response field echoing back the actual quality used, so if quality accuracy matters for your workflow, verify by visual inspection or ask the agent about its capabilities through `list_creative_formats`. ### Preview expiration and variant retention Preview responses may include an `expires_at` timestamp. When present, consumers should treat preview URLs as invalid after that time and re-generate them before reuse. When `expires_at` is omitted, the preview URLs do not expire. For generative creative, re-generating a pre-flight preview may produce different output — the same brief and context can yield different creative each time. ### Preview URL durability `preview_url` is the protocol resource buyers and MCPUI hosts render. AdCP does not define a separate durable asset pointer for preview renders in 3.x; if a creative agent needs an internal asset key, resource URI, or storage object ID, it remains agent-internal unless the schema adds a future field for it. Creative agents MUST keep each `preview_url` dereferenceable until the response's `expires_at` timestamp. When `expires_at` is omitted, the URL has no protocol-level expiration and must remain dereferenceable until the agent explicitly revokes or purges it out of band. Do not back preview URLs only with pod-local `Map`/LRU state in multi-process or multi-pod deployments, because a browser fetch, later refinement call, or reviewer session may land on a different process than the one that created the preview. Durable storage does not require permanent public CDN hosting. A preview URL can resolve through the creative agent's authenticated preview route as long as the route can recover the render from shared storage, such as a database row, object store key, or shared cache tier, for the advertised lifetime. Variant previews (post-flight) depend on the agent retaining variant data. Agents are not required to retain variant data indefinitely. If you request a variant preview for a variant the agent has purged, expect a standard error response. For long-running campaigns, retrieve and archive variant previews periodically rather than assuming they will remain available. ## Examples ### Device Variants ```json theme={null} { "$schema": "/schemas/3.1.20/creative/preview-creative-request.json", "request_type": "single", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "native_responsive" }, "assets": { "hero_image": { "asset_type": "image", "url": "https://cdn.example.com/hero.jpg", "width": 1200, "height": 627 }, "headline": { "asset_type": "text", "content": "Veterinarian Recommended" } } }, "inputs": [ { "name": "Desktop", "macros": { "DEVICE_TYPE": "desktop" } }, { "name": "Mobile", "macros": { "DEVICE_TYPE": "mobile" } } ] } ``` ### Batch with HTML Output Preview multiple creatives for a grid layout: ```json theme={null} { "request_type": "batch", "output_format": "html", "requests": [ { "creative_manifest": { /* creative 1 */ } }, { "creative_manifest": { /* creative 2 */ } } ] } ``` ### AI-Generated Audio Preview ```json theme={null} { "$schema": "/schemas/3.1.20/creative/preview-creative-request.json", "request_type": "single", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "audio_host_read_30s" }, "assets": { "script_template": { "content": "This episode brought to you by {{BRAND_NAME}}..." }, "brand_voice": { "content": "Friendly, enthusiastic, conversational." } } }, "inputs": [ { "name": "Weather Podcast", "context_description": "Podcast discussing weather patterns" }, { "name": "Fitness Podcast", "context_description": "Podcast about marathon training" } ] } ``` ## HTTP Status Codes **Single mode:** * **200 OK** - Preview generated successfully * **400 Bad Request** - Invalid manifest or format\_id * **404 Not Found** - Format not supported **Batch mode:** * **200 OK** - Batch processed (check individual `success` fields) * **400 Bad Request** - Invalid batch structure ## Key Points * Every render's `preview_url` returns an HTML page for iframe embedding * Use `output_format: "html"` for grids of 10+ previews (no iframe overhead) * Batch mode is 5-10x faster than individual requests * Preview URLs expire only when `expires_at` is present; omitted `expires_at` means no protocol-level expiration * Back preview URLs with storage that survives the URL's advertised lifetime; process-local maps are only appropriate for single-process demos or shorter-than-process-lifetime URLs * Handle partial batch failures by checking each result's `success` field ## Related Documentation * [Advanced Preview Patterns](/dist/docs/3.1.20/creative/task-reference/preview_creative-advanced) - Caching, workflows, implementation notes * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - Manifest structure * [Creative Formats](/dist/docs/3.1.20/creative/formats) - Format specifications # preview_creative (Advanced) Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/task-reference/preview_creative-advanced Advanced AdCP preview patterns including format showcase pages, caching strategies, and batch preview workflows for creative agents. Advanced patterns for creative preview integration including workflows, caching strategies, and implementation notes. For basic usage, see [preview\_creative](/dist/docs/3.1.20/creative/task-reference/preview_creative). ## Common Workflows ### Format Showcase Pages Build a browsable catalog of available formats: ```typescript theme={null} // 1. List all formats from creative agent const formats = await creative_agent.list_creative_formats(); // 2. Generate format card previews (batch + HTML) const formatPreviews = await creative_agent.preview_creative({ request_type: "batch", output_format: "html", requests: formats.formats.map(format => ({ format_id: format.format_id, creative_manifest: format.format_card.manifest })) }); // 3. Render in a grid function FormatCatalog({ formatPreviews }) { return (
{formatPreviews.results.map((result, idx) => ( result.success && (
) ))}
); } ``` ### Campaign Review Grid Review all creatives before launch: ```typescript theme={null} const campaignCreatives = await getCreativesForCampaign(campaignId); const previews = await creative_agent.preview_creative({ request_type: "batch", output_format: "html", requests: campaignCreatives.map(c => ({ format_id: c.format_id, creative_manifest: c.manifest })) }); function CampaignReview({ previews }) { return (
{previews.results.map((result, idx) => (
))}
); } ``` ### Web Component Integration For production applications with lazy loading: ```html theme={null}
``` **Benefits:** * Shadow DOM for CSS isolation * Lazy loading (only loads when visible) * Framework agnostic ## Choosing Output Format **Use `output_format: "url"` (default) when:** * Security is paramount (third-party creatives) * Building interactive preview tools * Need iframe isolation **Use `output_format: "html"` when:** * Building format catalogs (10+ formats) * Creating campaign review grids (20+ creatives) * Server-side rendering * Working with trusted creative agents only ## Caching Strategy Cache individual preview results by format\_id + manifest hash: ```typescript theme={null} function cachePreviewResults(results, formatIds, manifests) { results.forEach((result, idx) => { if (result.success) { const cacheKey = `${formatIds[idx]}:${hashManifest(manifests[idx])}`; cache.set(cacheKey, result.response, result.response.expires_at); } }); } async function getPreviewsWithCache(formatIds, manifests) { const cached = []; const toFetch = []; formatIds.forEach((id, idx) => { const cacheKey = `${id}:${hashManifest(manifests[idx])}`; const cachedResult = cache.get(cacheKey); if (cachedResult && !isExpired(cachedResult.expires_at)) { cached[idx] = cachedResult; } else { toFetch.push({ idx, id, manifest: manifests[idx] }); } }); // Batch fetch only missing previews if (toFetch.length > 0) { const fetched = await client.preview_creative({ request_type: "batch", output_format: "html", requests: toFetch.map(f => ({ format_id: f.id, creative_manifest: f.manifest })) }); fetched.results.forEach((result, i) => { cached[toFetch[i].idx] = result.response; }); } return cached; } ``` **Key points:** * Cache by format\_id + manifest hash (not entire batch) * Request \[A,B,C] → cache each separately * Later request \[B,C,D] → only fetch D * Always check `expires_at` before using cached previews ## Preview URL Storage Preview URLs are review resources, not just transport conveniences. A buyer, browser, or MCPUI host may fetch a `preview_url` after the original `preview_creative` call returns, after a pod restart, or from a different pod than the one that created the render. For production agents, persist enough preview state to resolve every `preview_url` through its advertised lifetime: * If `expires_at` is present, keep the render available until that timestamp. * If `expires_at` is omitted, treat the URL as non-expiring at the protocol layer and keep it available until explicit out-of-band revocation or purge. * Use shared storage for multi-process or multi-pod deployments: database metadata plus object storage, a shared cache tier, or an authenticated preview route that can recover the render from durable session state. * Limit process-local `Map` or LRU storage to single-process demos, local development, or URLs whose advertised lifetime is shorter than the process lifetime you can actually guarantee. ## Error Handling ```typescript theme={null} const response = await client.preview_creative({ request_type: "batch", requests: formatRequests }); const succeeded = response.results.filter(r => r.success); const failed = response.results.filter(r => !r.success); if (failed.length > 0) { console.log(`${failed.length} previews failed`); failed.forEach((result) => { console.error(` - ${result.error.code}: ${result.error.message}`); }); } // Display successful previews, show error states for failures function displayPreviews(results) { return results.map((result, idx) => { if (result.success) { return ; } else { return retryPreview(idx)} />; } }); } ``` ## Migration from Single to Batch **Before (Sequential):** ```python theme={null} previews = [] for format in formats: preview = await client.preview_creative( request_type="single", creative_manifest=format.format_card.manifest ) previews.append(preview) # Total time: N × 250ms = 5000ms for 20 formats ``` **After (Batch):** ```python theme={null} response = await client.preview_creative( request_type="batch", output_format="html", requests=[ {"creative_manifest": fmt.format_card.manifest} for fmt in formats ] ) # Total time: ~500ms for 20 formats ``` ## Use Case Patterns ### Device Variants ```json theme={null} { "inputs": [ { "name": "Desktop", "macros": { "DEVICE_TYPE": "desktop" } }, { "name": "Mobile", "macros": { "DEVICE_TYPE": "mobile" } }, { "name": "CTV", "macros": { "DEVICE_TYPE": "ctv" } } ] } ``` ### Geographic Variants ```json theme={null} { "inputs": [ { "name": "NYC", "macros": { "CITY": "New York", "DMA": "501" } }, { "name": "LA", "macros": { "CITY": "Los Angeles", "DMA": "803" } } ] } ``` ### Privacy Compliance Testing ```json theme={null} { "inputs": [ { "name": "Full consent", "macros": { "GDPR": "1", "GDPR_CONSENT": "CPc7TgP..." } }, { "name": "No consent", "macros": { "GDPR": "1", "GDPR_CONSENT": "" } }, { "name": "LAT enabled", "macros": { "LIMIT_AD_TRACKING": "1" } } ] } ``` ### AI Content Variants ```json theme={null} { "inputs": [ { "name": "Morning commute", "context_description": "User commuting to work" }, { "name": "Evening relaxation", "context_description": "User relaxing at home" } ] } ``` ## Implementation Notes ### For Creative Agents **Required:** 1. Return complete HTML pages from `preview_url` 2. Handle all media types (images, video, audio, interactive) 3. Echo input parameters in response 4. Validate manifest before rendering 5. Apply macro values (or use defaults) 6. Back preview URLs with storage that survives load balancing and restarts for the URL's advertised lifetime 7. Implement security sandboxing 8. Set reasonable expiration (24-48 hours) for previews that should not be retained indefinitely **Optional enhancements:** * Provide `hints` object (media type, dimensions, duration) * Provide `embedding` metadata (sandbox policy, CSP) * Support responsive design * Include accessibility features ### For Buyers 1. Just iframe the `preview_url` - no special rendering needed 2. Use `inputs` array for specific scenarios 3. Check `input` field to confirm macros applied 4. Share preview URLs with clients for approval 5. Use `interactive_url` for advanced testing ### For Publishers 1. Return consistent HTML from preview URLs 2. Implement responsive preview pages 3. Document supported macros via `supported_macros` in formats 4. Clarify preview vs production differences 5. Consider providing `interactive_url` for testing ## Related Documentation * [preview\_creative](/dist/docs/3.1.20/creative/task-reference/preview_creative) - Basic usage and parameters * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - Manifest structure * [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) - Available macro values # sync_creatives Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/task-reference/sync_creatives sync_creatives uploads and manages creative assets in an AdCP library with bulk uploads, upsert semantics, and generative creative support. Upload and manage creative assets in a creative library. Supports bulk uploads, upsert semantics, and generative creatives. Implemented by any agent that hosts a creative library — creative agents (ad servers, creative management platforms) and sales agents that manage creatives. **Response time**: Instant to days (returns `completed`, or `submitted` for review that takes hours/days) **Request Schema**: [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-request.json) **Response Schema**: [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-response.json) ## Quick start Upload creative assets: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncCreativesResponseSchema } from "@adcp/sdk"; import { randomUUID } from "node:crypto"; const result = await testAgent.syncCreatives({ account: { brand: { domain: "acmecorp.com" }, operator: "acmecorp.com", sandbox: true, }, idempotency_key: randomUUID(), creatives: [ { creative_id: "creative_video_001", name: "Summer Sale 30s", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "video_standard_30s", }, assets: { video: { asset_type: "video", url: "https://cdn.example.com/summer-sale-30s.mp4", width: 1920, height: 1080, duration_ms: 30000, }, }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } // Validate response against schema const validated = SyncCreativesResponseSchema.parse(result.data); // Three-shape discriminated union: errors | submitted | creatives if ("errors" in validated && validated.errors && !("creatives" in validated) && !("status" in validated)) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("status" in validated && validated.status === "submitted") { // Whole sync queued asynchronously — poll tasks/get with task_id or await webhook console.log(`Sync queued as task ${validated.task_id}: ${validated.message ?? ""}`); } else if ("creatives" in validated) { console.log(`Synced ${validated.creatives.length} creatives`); for (const c of validated.creatives) { // c.status carries review state: approved, pending_review, rejected, processing, archived if (c.status === "pending_review" || c.status === "processing") { console.log(` ${c.creative_id}: awaiting review (${c.status})`); } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from uuid import uuid4 async def main(): result = await test_agent.simple.sync_creatives( account={ 'brand': {'domain': 'acmecorp.com'}, 'operator': 'acmecorp.com', 'sandbox': True }, idempotency_key=str(uuid4()), creatives=[{ 'creative_id': 'creative_video_001', 'name': 'Summer Sale 30s', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'video_standard_30s' }, 'assets': { 'video': { 'asset_type': 'video', 'url': 'https://cdn.example.com/summer-sale-30s.mp4', 'width': 1920, 'height': 1080, 'duration_ms': 30000 } } }] ) # Three-shape discriminated union: errors | submitted | creatives if getattr(result, 'status', None) == 'submitted': # Whole sync queued asynchronously — poll tasks/get with task_id or await webhook print(f"Sync queued as task {result.task_id}: {getattr(result, 'message', '') or ''}") return if getattr(result, 'errors', None) and not getattr(result, 'creatives', None): raise Exception(f"Operation failed: {result.errors}") print(f"Synced {len(result.creatives)} creatives") for c in result.creatives: # c.status carries review state: approved, pending_review, rejected, processing, archived if getattr(c, 'status', None) in ('pending_review', 'processing'): print(f" {c.creative_id}: awaiting review ({c.status})") asyncio.run(main()) ``` **Note:** Per-creative async review is surfaced via `creatives[].status` (e.g., `pending_review`) on the synchronous success response. When the *whole* operation is queued (batch ingestion, governance review gating the sync), the response is a submitted envelope with top-level `status: "submitted"` and a `task_id`. See [Async approval workflow](#async-approval-workflow). ## Read-after-write visibility Creatives accepted via a synchronous `sync_creatives` success response MUST be committed to the creative library before the response is returned. They MUST be immediately visible to subsequent `list_creatives` calls from the same account and authorized caller, including creatives whose review lifecycle status is `processing` or `pending_review`. Implementations that acknowledge a creative on the synchronous success branch but buffer the library write until a later background commit are not conformant. If the whole sync operation cannot commit before returning, use the submitted task envelope instead; the visibility requirement then applies when the task completes with accepted creatives. ## Request parameters | Parameter | Type | Required | Description | | ----------------- | ----------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | object | Yes | Account reference identifying the advertiser/workspace for this sync ([account-ref](/dist/docs/3.1.20/accounts/overview)) | | `idempotency_key` | string | Yes | Client-generated key that makes retries safe. Use a fresh UUID or other unique value per request. | | `creatives` | Creative\[] | Yes | Creative assets to upload/update (max 100) | | `creative_ids` | string\[] | No | Optional filter to limit sync scope to specific creative IDs. Only these creatives are affected, others remain untouched. Useful for partial updates and error recovery. | | `assignments` | array | No | Array of `{creative_id, package_id}` objects for bulk assignment. Optional `weight` and `placement_ids` per assignment. | | `dry_run` | boolean | No | When true, rehearse this exact sync operation without applying it (default: false). Use this for seller trafficking acceptance checks; use `validate_input` for manifest-only preflight against canonical/product format targets. | | `validation_mode` | string | No | Validation strictness: `"strict"` (default) or `"lenient"` | | `delete_missing` | boolean | No | When true, creatives not in this sync are archived (default: false). Cannot be combined with `creative_ids`. Cannot delete creatives assigned to active, non-paused packages. | ### Creative object | Field | Type | Required | Description | | ------------------- | ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `creative_id` | string | Yes | Unique identifier for this creative | | `name` | string | Yes | Human-readable name | | `format_id` | FormatId | Conditional | Legacy named-format path. Structured object with `agent_url` and `id`. Required when `format_kind` is omitted; mutually exclusive with `format_kind`. | | `format_kind` | CanonicalFormatKind | Conditional | 3.1+ canonical-format path. Required when `format_id` is omitted; mutually exclusive with `format_id`. | | `format_option_ref` | FormatOptionRef | No | Optional structured reference to a product or publisher format option. Required when the target product has multiple options with the same `format_kind`. | | `assets` | object | Yes | Assets keyed by the selected format's slot name (`asset_id` for legacy named formats; `asset_group_id` for canonical formats). Catalogs are included as assets with `asset_type: "catalog"`. See [Catalogs](/dist/docs/3.1.20/creative/catalogs). | | `tags` | string\[] | No | Searchable tags for creative organization | Provide either the legacy `format_id` or the canonical `format_kind` path, never both. New 3.1+ integrations should prefer `format_kind` with `format_option_ref` when routing depends on a product's declared format option. Before uploading, buyers MUST verify each creative manifest against the selected seller-declared format. For a legacy `format_id`, resolve the definition with `list_creative_formats`; the manifest MUST include every required asset keyed by its exact `asset_id`. For a canonical `format_kind`, validate against the target product's `format_options[]` and key required slots by `asset_group_id`. ### Promoting a build\_creative variant When a buyer keeps a produced build leaf, the canonical promotion is to use the kept `build_variant_id` as the new `creative_id`. The seller does not need to hold a separate lineage mapping; delivery reporting can join back to the build leaf through the normal `creative_id`. ```json test=false theme={null} { "idempotency_key": "550e8400-e29b-41d4-a716-446655440000", "account": { "account_id": "acct_acmecorp" }, "creatives": [ { "creative_id": "bv_card01_a", "name": "Summer card - studio take", "format_id": { "agent_url": "https://creative.example.com", "id": "display_300x250" }, "assets": { "headline_0_text": { "asset_type": "text", "content": "Summer Sale - 50% Off" }, "image_0_url": { "asset_type": "image", "url": "https://cdn.example.com/beach-hero.jpg", "width": 300, "height": 250 } } } ] } ``` Later, [`get_creative_delivery`](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery) uses `creative_id` as the join key. A workflow that mints a different library id instead of using the kept `build_variant_id` loses this protocol-visible join unless a future scoped lineage field is adopted. ### Asset structure Assets are keyed by role name. Each role contains the asset details: ```json test=false theme={null} { "assets": { "video": { "url": "https://cdn.example.com/video.mp4", "width": 1920, "height": 1080, "duration_ms": 30000 }, "thumbnail": { "url": "https://cdn.example.com/thumb.jpg", "width": 300, "height": 250 } } } ``` For published-post reference products, the asset role is usually `published_post` and the payload contains a post URL or platform post ID instead of uploaded media bytes. Buyers can submit these assets through the canonical creative path, for example `format_kind: "video_hosted"` plus the product's `format_option_ref`, instead of creating a platform-specific `format_id`. If the seller can resolve the post but lacks a required downstream platform connection, such as the publisher identity that owns the post, the correctable error is `AUTHORIZATION_REQUIRED`. New implementations should include `error.details.missing_connections[]` so the caller can send a human through the correct connections flow and retry after authorization is restored. ### Assignments structure Assignments are at the request level, mapping creative IDs to package IDs. Standalone creative agents that do not manage media buys ignore this field. ```json test=false theme={null} { "assignments": [ { "creative_id": "creative_video_001", "package_id": "pkg_premium" }, { "creative_id": "creative_video_001", "package_id": "pkg_standard" }, { "creative_id": "creative_display_002", "package_id": "pkg_standard" } ] } ``` ## Response Responses use discriminated unions — a response has exactly one of three shapes, never mixed: **1. Synchronous success** — per-creative results: * `creatives` - Results for each creative processed (includes both successful and failed items) * `dry_run` - Boolean indicating if this was a dry run (optional) **2. Terminal error** — no creatives processed: * `errors` - Array of operation-level errors (auth failure, service unavailable) **3. Submitted task envelope** — whole operation queued asynchronously (batch ingestion, governance review gating the sync): * `status` - Always `"submitted"` * `task_id` - Handle for polling via `tasks/get` or receiving a webhook on completion * `message` - Optional human-readable explanation of the queue state The final per-creative `creatives` array lands on the task completion artifact, not on the submitted envelope. Per-item async review (one creative in `pending_review` while the rest of the sync resolves synchronously) belongs on the synchronous success branch with `status: "pending_review"` on that item, not here. **Each creative in the success response includes:** * All request fields * `platform_id` - Platform's internal ID (when `action` is not `failed`) * `action` - Lifecycle operation performed by this sync: `created`, `updated`, `unchanged`, `failed`, `deleted` * `status` - **Advisory** review-lifecycle state ([`CreativeStatus`](https://adcontextprotocol.org/schemas/3.1.20/enums/creative-status.json)): `processing`, `pending_review`, `approved`, `suspended`, `rejected`, `archived`. A UI hint and polling-scheduling signal — **not** a spend-authorization gate. Orthogonal to `action` — `action` describes what the sync did, `status` describes where the creative is in the review lifecycle. Values come from `CreativeStatus` only, never from `CreativeAction` (never put `created`/`updated`/`failed` in `status`). Sellers with async review return `processing` or `pending_review`; sellers with synchronous review MAY return a terminal value (`approved`/`rejected`) or `suspended` when a recoverable dependency/authorization gate prevents serving. **Buyers MUST NOT gate downstream spend or package activation on `status: approved` from this response** — reconcile via `list_creatives` or a signed review webhook before committing spend. Authoritative state is always via `list_creatives`. **MUST be omitted** when `action` is `failed` or `deleted` — failed items have no meaningful review state (see `errors`); deleted items are gone from the library. The schema enforces the omission rule via a conditional constraint. * `errors` - Array of error messages (only when `action: "failed"`) * `warnings` - Array of non-fatal warnings (optional) **See schema for complete field list**: [sync-creatives-response.json](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-response.json) ## Common scenarios ### Bulk upload Upload multiple creatives in one call: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncCreativesResponseSchema } from "@adcp/sdk"; import { randomUUID } from "node:crypto"; const result = await testAgent.syncCreatives({ account: { brand: { domain: "acmecorp.com" }, operator: "acmecorp.com", sandbox: true, }, idempotency_key: randomUUID(), creatives: [ { creative_id: "creative_display_001", name: "Summer Sale Banner 300x250", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "display_300x250", }, assets: { image: { asset_type: "image", url: "https://cdn.example.com/banner-300x250.jpg", width: 300, height: 250, }, }, }, { creative_id: "creative_video_002", name: "Product Demo 15s", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "video_standard_15s", }, assets: { video: { asset_type: "video", url: "https://cdn.example.com/demo-15s.mp4", width: 1920, height: 1080, duration_ms: 15000, }, }, }, { creative_id: "creative_display_002", name: "Summer Sale Banner 728x90", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "display_728x90", }, assets: { image: { asset_type: "image", url: "https://cdn.example.com/banner-728x90.jpg", width: 728, height: 90, }, }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncCreativesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("creatives" in validated) { console.log(`Successfully synced ${validated.creatives.length} creatives`); validated.creatives.forEach((creative) => { console.log(` ${creative.creative_id}: ${creative.action}`); }); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from uuid import uuid4 async def main(): result = await test_agent.simple.sync_creatives( account={ 'brand': {'domain': 'acmecorp.com'}, 'operator': 'acmecorp.com', 'sandbox': True }, idempotency_key=str(uuid4()), creatives=[ { 'creative_id': 'creative_display_001', 'name': 'Summer Sale Banner 300x250', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250' }, 'assets': { 'image': { 'asset_type': 'image', 'url': 'https://cdn.example.com/banner-300x250.jpg', 'width': 300, 'height': 250 } } }, { 'creative_id': 'creative_video_002', 'name': 'Product Demo 15s', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'video_standard_15s' }, 'assets': { 'video': { 'asset_type': 'video', 'url': 'https://cdn.example.com/demo-15s.mp4', 'width': 1920, 'height': 1080, 'duration_ms': 15000 } } }, { 'creative_id': 'creative_display_002', 'name': 'Summer Sale Banner 728x90', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_728x90' }, 'assets': { 'image': { 'asset_type': 'image', 'url': 'https://cdn.example.com/banner-728x90.jpg', 'width': 728, 'height': 90 } } } ] ) # Check for operation-level errors first if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") print(f"Successfully synced {len(result.creatives)} creatives") for creative in result.creatives: print(f" {creative.creative_id}: {creative.action}") asyncio.run(main()) ``` ### Generative creatives Use the creative agent to generate creatives from brand identity data. See the [Generative Creatives guide](/dist/docs/3.1.20/creative/generative-creative) for complete workflow details. ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncCreativesResponseSchema } from "@adcp/sdk"; import { randomUUID } from "node:crypto"; const result = await testAgent.syncCreatives({ account: { brand: { domain: "acmecorp.com" }, operator: "acmecorp.com", sandbox: true, }, idempotency_key: randomUUID(), creatives: [ { creative_id: "creative_gen_001", name: "AI-Generated Summer Banner", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "display_300x250", }, assets: { prompt: { asset_type: "text", content: "Create a summer banner for outdoor enthusiasts. Headline: Summer gear built for every trail. CTA: Shop the collection.", }, }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncCreativesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("creatives" in validated) { console.log( "Generative creative synced:", validated.creatives[0].creative_id ); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from uuid import uuid4 async def main(): result = await test_agent.simple.sync_creatives( account={ 'brand': {'domain': 'acmecorp.com'}, 'operator': 'acmecorp.com', 'sandbox': True }, idempotency_key=str(uuid4()), creatives=[{ 'creative_id': 'creative_gen_001', 'name': 'AI-Generated Summer Banner', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250' }, 'assets': { 'prompt': { 'asset_type': 'text', 'content': 'Create a summer banner for outdoor enthusiasts. Headline: Summer gear built for every trail. CTA: Shop the collection.' } } }] ) # Check for operation-level errors first if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") print(f"Generative creative synced: {result.creatives[0].creative_id}") asyncio.run(main()) ``` ### Dry run validation Validate creative configuration without uploading: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncCreativesResponseSchema } from "@adcp/sdk"; import { randomUUID } from "node:crypto"; const result = await testAgent.syncCreatives({ account: { brand: { domain: "acmecorp.com" }, operator: "acmecorp.com", sandbox: true, }, idempotency_key: randomUUID(), dry_run: true, creatives: [ { creative_id: "creative_test_001", name: "Test Creative", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "video_standard_30s", }, assets: { video: { asset_type: "video", url: "https://cdn.example.com/test-video.mp4", width: 1920, height: 1080, duration_ms: 30000, }, }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncCreativesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors && validated.errors.length > 0) { console.log("Validation errors found:"); validated.errors.forEach((error) => console.log(` - ${error.message}`)); } else if ( "creatives" in validated && validated.creatives?.some((creative) => creative.action === "failed") ) { console.log("Creative validation errors found."); } else if ( "assignments" in validated && validated.assignments?.some((assignment) => assignment.status === "error") ) { console.log("Assignment validation errors found."); } else { console.log("Validation passed! Ready to sync."); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from uuid import uuid4 async def main(): result = await test_agent.simple.sync_creatives( account={ 'brand': {'domain': 'acmecorp.com'}, 'operator': 'acmecorp.com', 'sandbox': True }, idempotency_key=str(uuid4()), dry_run=True, creatives=[{ 'creative_id': 'creative_test_001', 'name': 'Test Creative', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'video_standard_30s' }, 'assets': { 'video': { 'asset_type': 'video', 'url': 'https://cdn.example.com/test-video.mp4', 'width': 1920, 'height': 1080, 'duration_ms': 30000 } } }] ) if hasattr(result, 'errors') and result.errors: error_messages = [error.message for error in result.errors] raise Exception(f"Validation errors: {error_messages}") if any(getattr(creative, 'action', None) == 'failed' for creative in getattr(result, 'creatives', [])): raise Exception('Creative validation errors found') if any(getattr(assignment, 'status', None) == 'error' for assignment in getattr(result, 'assignments', [])): raise Exception('Assignment validation errors found') print('Validation passed! Ready to sync.') asyncio.run(main()) ``` ### Scoped update with creative\_ids filter Update only specific creatives from a large library without affecting others: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncCreativesResponseSchema } from "@adcp/sdk"; import { randomUUID } from "node:crypto"; // Update just 2 creatives out of 100+ in the library const result = await testAgent.syncCreatives({ account: { brand: { domain: "acmecorp.com" }, operator: "acmecorp.com", sandbox: true, }, idempotency_key: randomUUID(), creative_ids: ["creative_video_001", "creative_display_001"], creatives: [ { creative_id: "creative_video_001", name: "Summer Sale 30s - Updated", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "video_standard_30s", }, assets: { video: { asset_type: "video", url: "https://cdn.example.com/updated-video.mp4", width: 1920, height: 1080, duration_ms: 30000, }, }, }, { creative_id: "creative_display_001", name: "Summer Sale Banner - Updated", format_id: { agent_url: "https://creative.adcontextprotocol.org", id: "display_300x250", }, assets: { image: { asset_type: "image", url: "https://cdn.example.com/updated-banner.jpg", width: 300, height: 250, }, }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncCreativesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Update failed: ${JSON.stringify(validated.errors)}`); } if ("creatives" in validated) { console.log( `Updated ${validated.creatives.length} creatives, others untouched` ); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from uuid import uuid4 async def main(): # Update just 2 creatives out of 100+ in the library result = await test_agent.simple.sync_creatives( account={ 'brand': {'domain': 'acmecorp.com'}, 'operator': 'acmecorp.com', 'sandbox': True }, idempotency_key=str(uuid4()), creative_ids=['creative_video_001', 'creative_display_001'], creatives=[ { 'creative_id': 'creative_video_001', 'name': 'Summer Sale 30s - Updated', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'video_standard_30s' }, 'assets': { 'video': { 'asset_type': 'video', 'url': 'https://cdn.example.com/updated-video.mp4', 'width': 1920, 'height': 1080, 'duration_ms': 30000 } } }, { 'creative_id': 'creative_display_001', 'name': 'Summer Sale Banner - Updated', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250' }, 'assets': { 'image': { 'asset_type': 'image', 'url': 'https://cdn.example.com/updated-banner.jpg', 'width': 300, 'height': 250 } } } ] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Update failed: {result.errors}") print(f"Updated {len(result.creatives)} creatives, others untouched") asyncio.run(main()) ``` **Why use creative\_ids filter:** * Scoped updates: Only specified creatives modified, even with 100+ in library * Error recovery: Retry only failed creatives after bulk sync validation failures * Performance: Publisher can optimize processing when scope is known upfront * Safety: Explicit targeting reduces risk of unintended changes ## Async approval workflow Two distinct async patterns — match the right one to the agent's behavior: **Per-creative async review** (common): the sync operation itself resolves synchronously, but one or more creatives require downstream review (brand safety, policy compliance). Items in review come back on the synchronous success response with `status: "pending_review"` (or `processing` during ingestion). The buyer reconciles terminal state via `list_creatives` or a webhook. **Operation-level async** (less common): the whole sync is queued — the seller cannot return any per-item results before responding, because ingestion is batched or governance review gates the entire sync. The response is a submitted envelope: * Top-level `status: "submitted"` with `task_id` * `message` — optional human-readable explanation * No `creatives` array on this envelope Poll `tasks/get` or wait for the webhook. The completion artifact carries the `creatives` array with per-item `action`/`status` results; operation-level failures surface as `status: "failed"` on the task. **See:** [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for webhook configuration. ## Sync modes ### Upsert (default) * Creates new creatives or updates existing by `creative_id` * Merges package assignments (additive) * Updates provided fields, leaves others unchanged * Use `creative_ids` filter to limit scope to specific creatives ### Dry run * Rehearses the same `sync_creatives` acceptance path without committing library, assignment, review, or serving changes * Returns operation-level errors, per-creative failures, assignment errors, and warnings the seller can determine before mutation * Does not create or update creatives, package assignments, review state, or serving state * Use for seller trafficking acceptance checks; use `validate_input` for manifest-only canonical/product preflight ## Error handling | Error Code | Description | Resolution | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `UNSUPPORTED_FEATURE` | Format not supported by this seller or product | Check product's supported formats via `list_creative_formats` | | `VALIDATION_ERROR` | Manifest failed format validation: an asset file is corrupt or invalid, or assets don't match the format's requirements (codec, dimensions, duration). `error.field` identifies the offending asset path | Verify asset types and specifications match the format definition | | `PACKAGE_NOT_FOUND` | Package ID doesn't exist in media buy | Verify `package_id`; for legacy package correlation use `get_media_buys` + package `context.buyer_ref` | | `CREATIVE_REJECTED` | Creative failed content policy or brand safety review | Review content against the seller's advertising policies | | `INVALID_STATE` | Creative is assigned to an active, non-paused package (blocks updates and `delete_missing` deletions) | Pause the package first, or create a new creative version | ## Best practices 1. **Use upsert semantics** - Same `creative_id` updates existing creative rather than creating duplicates. This allows iterative creative development. Note: updates are blocked for creatives in active delivery (see #6). 2. **Rehearse seller acceptance first** - Use `dry_run: true` when you need to catch upload, upsert, assignment, account, policy, or format errors before mutating the seller's creative library. Use `validate_input` earlier in the workflow only for manifest-structure preflight or multi-target product comparison. 3. **Batch assignments** - Include all package assignments in single sync call to avoid race conditions between updates. 4. **CDN-hosted assets** - Use publicly accessible CDN URLs for faster processing. Platforms can fetch assets directly without proxy delays. 5. **Brand identity** - For generative creatives, validate brand identity schema before syncing to avoid processing failures. 6. **Active delivery protection** - Creatives assigned to active, non-paused packages cannot be updated or deleted via `delete_missing`. Pause the package first, unassign the creative via `update_media_buy`, or create a new creative with a different `creative_id`. ## Related tasks * [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) - Check supported formats before upload * [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) - Browse and filter creatives in a library * [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) - Build manifests from library creatives or generate from scratch * [`preview_creative`](/dist/docs/3.1.20/creative/task-reference/preview_creative) - Generate previews of creative manifests * [Creative Asset Types](/dist/docs/3.1.20/creative/asset-types) - Technical requirements for assets # Template Format IDs Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/template-format-ids Template format IDs in AdCP let a single format definition support many dimension variants without creating separate formats for each size. Template formats allow a single format definition to support multiple dimension or duration variants without creating separate format definitions for each variant. This eliminates format explosion when publishers support many similar variants. ## The Problem: Format Explosion Without template formats, each dimension variant requires a separate format definition and format\_id: ```json theme={null} {"format_id": {"agent_url": "...", "id": "display_300x250"}} {"format_id": {"agent_url": "...", "id": "display_300x600"}} {"format_id": {"agent_url": "...", "id": "display_728x90"}} {"format_id": {"agent_url": "...", "id": "display_970x250"}} // ... 50+ more sizes ``` **Publisher with 50 placement sizes** → 50 separate format definitions to create, maintain, and document. ## The Solution: Template Formats with Parameters A single template format definition (`display_static`) accepts dimension fields in the format\_id object, allowing creatives to specify their exact dimensions. ## Format Types and Format IDs There are **two types of format definitions**, which produce **three types of format IDs**: ### Format Definitions 1. **Concrete formats** - Fixed dimensions in format definition * Have `renders` array with explicit dimensions * Example: `display_300x250` always means 300×250px * Cannot accept parameters 2. **Template formats** - Accept parameters in format\_id * Have `accepts_parameters` array listing accepted parameters * Example: `display_static` can be any dimensions * Can be used with or without parameters ### Format IDs 1. **Concrete format\_id** - References a concrete format * Example: `{id: "display_300x250"}` * No parameters (none accepted) 2. **Template format\_id** - References a template format without parameters * Example: `{id: "display_static"}` * Used in placements to accept any dimensions 3. **Parameterized format\_id** - Template format with parameters * Example: `{id: "display_static", width: 300, height: 250}` * Used in creatives to specify exact dimensions (in pixels) ### Template Format Definition Format definition that accepts parameters: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org" "id": "display_static" } "name": "Static Display Banner" "type": "display" "accepts_parameters": ["dimensions"] "renders": [ { "role": "primary" "parameters_from_format_id": true } ] "assets": [ { "item_type": "individual" "asset_id": "banner_image" "asset_type": "image" "required": true "requirements": { "parameters_from_format_id": true } } { "item_type": "individual" "asset_id": "clickthrough_url" "asset_type": "url" "required": true } ] } ``` **Key fields:** * `accepts_parameters: ["dimensions"]` - Format accepts dimensions (width/height in pixels) in format\_id * `renders[].parameters_from_format_id: true` - Render parameters come from format\_id * `requirements.parameters_from_format_id: true` - Asset parameters must match format\_id ### Parameterized Format ID (Creative Manifest) Creative specifies exact dimensions in format\_id to use the template format: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org" "id": "display_static" "width": 300 "height": 250 } "assets": { "banner_image": { "asset_type": "image" "url": "https://cdn.example.com/banner-300x250.png" "width": 300 "height": 250 } "clickthrough_url": { "asset_type": "url" "url": "https://example.com/landing" } } } ``` **This format\_id is parameterized:** Same dimensions always produce the same format\_id object, enabling deduplication and caching. ### Placement Constraints **CRITICAL**: Sales agents MUST always return parameterized format\_ids (with specific dimensions/duration) in placements. Template format\_ids without parameters are ONLY used in format definitions from `list_creative_formats()`. Publishers specify supported dimensions by listing all supported variants: ```json theme={null} { "kind": "seller_inline", "placement_id": "homepage_banner", "name": "Homepage Banner", "mode": "targetable", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 300, "height": 250 }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_static", "width": 728, "height": 90 } ] } ``` **Validation:** Creative's format\_id must exactly match one of the placement's parameterized format\_ids. **Why parameterized only in placements:** * Buyers need to know exactly which dimensions are supported * No ambiguity about what will be accepted * Enables clear validation at creative submission time * Template format\_ids without parameters are only for format discovery via `list_creative_formats()` ## Benefits ✅ **Scalability** - One template format supports unlimited dimension variants ✅ **Predictable** - Same dimensions = same format\_id (enables caching/deduplication) ✅ **Self-contained** - Creatives fully specify their format via format\_id ✅ **Portable** - A 300×250 creative works on any placement accepting 300×250 ✅ **Publisher control** - Placements specify exact dimension constraints ✅ **Type-safe** - Width/height are numbers, not encoded strings ✅ **Backward compatible** - Concrete (non-template) formats work unchanged ## Format ID Fields ### Visual Formats (Display, DOOH, Native) **Fields:** * `width` (integer, minimum: 1) - Width in pixels * `height` (integer, minimum: 1) - Height in pixels **Example:** ```json theme={null} { "agent_url": "https://creative.adcontextprotocol.org" "id": "display_static" "width": 300 "height": 250 } ``` ### Time-Based Formats (Video, Audio) **Fields:** * `duration_ms` (number, minimum: 1) - Duration in milliseconds **Example:** ```json theme={null} { "agent_url": "https://creative.adcontextprotocol.org" "id": "video_hosted" "duration_ms": 30000 } ``` ### Combined (Video with Dimensions) **Fields:** * `width`, `height` (integers) - Video frame dimensions in pixels * `duration_ms` (number) - Video length in milliseconds **Example:** ```json theme={null} { "agent_url": "https://creative.adcontextprotocol.org" "id": "video_hosted" "width": 1920 "height": 1080 "duration_ms": 30000 } ``` ## Format Definition Patterns ### Display Format (Flexible Dimensions) ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org" "id": "display_static" } "name": "Static Display Banner" "type": "display" "accepts_parameters": ["dimensions"] "renders": null "assets": [...] } ``` ### Video Format (Flexible Duration) ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org" "id": "video_hosted" } "name": "Hosted Video" "type": "video" "accepts_parameters": ["duration"] "renders": null "assets": [...] } ``` ### DOOH Format (Pixel Dimensions) ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org" "id": "dooh_static" } "name": "DOOH Static Display" "type": "dooh" "accepts_parameters": ["dimensions"] "renders": null "assets": [...] } ``` **Creative with pixel dimensions:** ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org" "id": "dooh_static" "width": 1920 "height": 560 } "assets": {...} } ``` **Note**: All dimensions are in pixels. Physical screen dimensions (e.g., 48 feet × 14 feet billboard) are placement metadata, not format specifications. ### Generative Formats with Output Formats Generative formats specify which output formats they can produce: **Option 1: Generate specific dimensions** ```json theme={null} { "format_id": {"agent_url": "...", "id": "display_generative"} "output_format_ids": [ {"agent_url": "...", "id": "display_static", "width": 300, "height": 250} {"agent_url": "...", "id": "display_static", "width": 728, "height": 90} ] } ``` **Option 2: Generate any dimension (template output)** ```json theme={null} { "format_id": {"agent_url": "...", "id": "display_generative"} "output_format_ids": [ {"agent_url": "...", "id": "display_static"} ] } ``` Use template outputs when your generation logic can handle arbitrary dimensions. Buyer specifies dimensions when calling the generative format. ## Discovery Pattern ### Format Definitions: `list_creative_formats()` Both creative and sales agents can return template format definitions via `list_creative_formats()`: ```json theme={null} { "formats": [ { "format_id": {"agent_url": "...", "id": "display_static"} "accepts_parameters": ["dimensions"] "assets": [...] } ] } ``` **Purpose**: Buyers discover what format *types* are available and what parameters they accept. ### Placement Constraints: `get_products()` **REQUIREMENT**: Sales agents MUST return parameterized format\_ids (with specific dimensions/duration) in placements. Template format\_ids without parameters are NOT allowed in placement specifications. ```json theme={null} { "products": [{ "placements": [{ "kind": "seller_inline", "placement_id": "display_multi_size", "name": "Display multi-size", "mode": "targetable", "format_ids": [ {"agent_url": "...", "id": "display_static", "width": 300, "height": 250}, {"agent_url": "...", "id": "display_static", "width": 728, "height": 90} ] }] }] } ``` **Purpose**: Buyers discover which *specific dimensions* are supported for each placement. **Why parameterized format\_ids are required in placements:** * Provides explicit list of accepted dimension variants * Eliminates ambiguity about what will be accepted * Enables clear validation at creative submission time * Buyers can match their creative dimensions against specific placement requirements **Discovery Flow**: 1. Buyer calls `list_creative_formats()` on creative or sales agent → learns `display_static` is a template format that accepts dimensions 2. Buyer calls `get_products()` on sales agent → learns which *specific dimensions* are supported (300×250, 728×90) 3. Buyer creates creative with parameterized format\_id matching one of the placement's supported dimensions ## Implementation Guidelines ### For Creative Agents **Format definitions:** * Set `accepts_parameters: ["dimensions"]` for formats with flexible dimensions * Set `accepts_parameters: ["duration"]` for formats with flexible duration * Set `accepts_parameters: ["dimensions", "duration"]` for formats with both (e.g., video with dimensions) * Omit `renders` array when format accepts dimensions (dimensions come from format\_id) * Include `renders` array for concrete formats with fixed dimensions **Validation:** * Validate format\_id dimensions against asset dimensions * Ensure width/height/unit are present together (not partial) * Return clear errors if format\_id doesn't match assets **Format lookup/matching:** * `list_creative_formats()` returns template formats (without dimension parameters) * Format lookup by ID matches on base format (agent\_url + id), ignoring dimension parameters * Example: Request for `{id: "display_static", width: 300, height: 250}` matches the template format `{id: "display_static"}` * Dimension parameters are used for creative validation, not format discovery ### For Sales Agents **Product responses - CRITICAL REQUIREMENT:** * **MUST** always return parameterized format\_ids with specific dimensions/duration in placements * **NEVER** return template format\_ids without parameters in placement `format_ids` arrays * List all supported dimension/duration variants explicitly: ```json theme={null} { "placements": [{ "kind": "seller_inline", "placement_id": "display_multi_size", "name": "Display multi-size", "mode": "targetable", "format_ids": [ {"agent_url": "...", "id": "display_static", "width": 300, "height": 250}, {"agent_url": "...", "id": "display_static", "width": 728, "height": 90}, {"agent_url": "...", "id": "display_static", "width": 160, "height": 600} ] }] } ``` **Why this requirement exists:** * Buyers need explicit lists of supported dimensions * No ambiguity about what will be accepted * Enables validation at creative submission time * Template format\_ids without parameters are ONLY for `list_creative_formats()` responses **Creative validation:** * Ensure creative format\_id exactly matches at least one placement format\_id * Match requires exact equality of all fields: agent\_url, id, width, height, duration\_ms * No partial matches or "close enough" dimensions ### For Buyers **Creative manifest construction:** * Fetch format definitions to check `accepts_parameters` array * Include dimension/duration fields in format\_id when using template formats * Ensure asset dimensions match format\_id dimensions * Validate against placement format\_ids before syncing ## Format ID Equality Rules Two format\_ids are **identical** if and only if: * `agent_url` matches exactly * `id` matches exactly * `width` matches exactly (if present) * `height` matches exactly (if present) * `duration_ms` matches exactly (if present) ### Canonicalization When comparing format\_ids for equality or caching: **Required fields:** * Both `width` and `height` must be present together (cannot specify only one) * All dimensions are in pixels (integers) **Numeric precision:** * Width and height are integers (300 not 300.5) * Duration can be decimal (30000.5ms for fractional seconds) **Field order:** * JSON field order does NOT matter for equality * `{"width": 300, "height": 250}` equals `{"height": 250, "width": 300}` **Example equivalence:** ```json theme={null} // These are IDENTICAL format IDs {"agent_url": "...", "id": "display_static", "width": 300, "height": 250} {"agent_url": "...", "id": "display_static", "width": 300, "height": 250} // These are DIFFERENT format IDs {"agent_url": "...", "id": "display_static", "width": 300, "height": 250} {"agent_url": "...", "id": "display_static", "width": 728, "height": 90} // INVALID - partial dimensions not allowed (schema validation will reject) {"agent_url": "...", "id": "display_static", "width": 300} // ❌ Missing height ``` ## Matching Logic ### Placement Validation **IMPORTANT**: Placements MUST always specify parameterized format\_ids with explicit dimensions/duration. Template format\_ids without parameters are NOT allowed in placements. **Parameterized formats in placement (REQUIRED pattern):** ```json theme={null} // Placement specifies exact supported dimensions {"format_ids": [ {"agent_url": "...", "id": "display_static", "width": 300, "height": 250}, {"agent_url": "...", "id": "display_static", "width": 728, "height": 90} ]} // Creative matches only if exact equality with one of the placement's format_ids {"format_id": {"agent_url": "...", "id": "display_static", "width": 300, "height": 250}} // ✅ Match {"format_id": {"agent_url": "...", "id": "display_static", "width": 728, "height": 90}} // ✅ Match {"format_id": {"agent_url": "...", "id": "display_static", "width": 160, "height": 600}} // ❌ Not in placement list {"format_id": {"agent_url": "...", "id": "display_static"}} // ❌ Missing dimensions ``` ## Migration from Concrete Formats **Before (format explosion):** ```json theme={null} // 50 separate format definitions { "format_id": {"agent_url": "...", "id": "display_300x250"} "renders": [{"dimensions": {"width": 300, "height": 250}}] } { "format_id": {"agent_url": "...", "id": "display_728x90"} "renders": [{"dimensions": {"width": 728, "height": 90}}] } // ... 48 more ``` **After (single template format):** ```json theme={null} { "format_id": {"agent_url": "...", "id": "display_static"} "accepts_parameters": ["dimensions"] "assets": [...] } ``` **Creative manifest change:** ```json theme={null} // Before: Format ID encoded dimensions in string { "format_id": {"agent_url": "...", "id": "display_300x250"} "assets": {...} } // After: Dimensions as structured fields in format_id { "format_id": { "agent_url": "..." "id": "display_static" "width": 300 "height": 250 } "assets": {...} } ``` ## Common Patterns ### IAB Standard Display Sizes Instead of defining 15 separate formats for IAB sizes, use one template: ```json theme={null} { "format_id": {"agent_url": "...", "id": "display_static"} "accepts_parameters": ["dimensions"] } ``` Creatives specify their size via parameters: * 300×250: `{id: "display_static", width: 300, height: 250}` * 728×90: `{id: "display_static", width: 728, height: 90}` * 160×600: `{id: "display_static", width: 160, height: 600}` * etc. ### Video Duration Variants Instead of separate 15s, 30s, 60s format definitions: ```json theme={null} { "format_id": {"agent_url": "...", "id": "video_hosted"} "accepts_parameters": ["duration"] } ``` Creatives specify duration: `{id: "video_hosted", duration_ms: 30000}` ### DOOH Screen Sizes Instead of defining formats for every billboard size: ```json theme={null} { "format_id": {"agent_url": "...", "id": "dooh_static"} "accepts_parameters": ["dimensions"] } ``` Creatives specify pixel dimensions: `{id: "dooh_static", width: 1920, height: 560}` **Note**: All dimensions are in pixels. Physical screen size (e.g., 48 feet × 14 feet) is placement metadata. ## See Also * [Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests) - Complete manifest structure * [Format Discovery](/dist/docs/3.1.20/creative/formats) - How buyers discover formats * [Placement Targeting](/dist/docs/3.1.20/media-buy/creatives) - Assigning creatives to placements * [Format References](/dist/docs/3.1.20/protocol/format-references) - Normative contrast between `format_id` (pointer) and `format` (definition), including named validation errors # Universal Macros Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/creative/universal-macros Universal macros in AdCP insert dynamic tracking data into creatives with platform-agnostic placeholders replaced at impression time. Universal macros enable buyers to include dynamic tracking data in their creatives without needing to know each publisher's ad server implementation details. Macros are placeholders that get replaced with actual values at impression time. ## Overview When you provide creative assets to AdCP, you can include universal macro placeholders in: * Impression tracking URLs * Click tracking URLs * VAST tracking events * Landing page URLs **Example**: ``` https://track.brand.com/imp? campaign={MEDIA_BUY_ID}& creative={CREATIVE_ID}& device={DEVICE_ID}& cb={CACHEBUSTER} ``` At impression time, this becomes: ``` https://track.brand.com/imp? campaign=mb_spring_2025& creative=cr_video_30s& device=ABC-123-DEF& cb=87654321 ``` ## Available Macros by Format Different creative formats support different macros. Use `list_creative_formats` to see which macros are available for each format. ### Common Macros (All Formats) | Macro | Description | Example Value | | ---------------- | -------------------------------- | -------------------------------- | | `{MEDIA_BUY_ID}` | Your AdCP media buy identifier | `mb_spring_2025` | | `{PACKAGE_ID}` | Your AdCP package identifier | `pkg_ctv_prime` | | `{CREATIVE_ID}` | Your AdCP creative identifier | `cr_video_30s` | | `{CACHEBUSTER}` | Random number to prevent caching | `87654321` | | `{TIMESTAMP}` | Unix timestamp in milliseconds | `1704067200000` | | `{CLICK_URL}` | Publisher's click tracking URL | *(auto-inserted by sales agent)* | ### Privacy & Compliance Macros **Critical for regulatory compliance** - Use these to respect user privacy choices in your creative logic. | Macro | Description | Example Value | | --------------------- | ------------------------------------------------ | ----------------------------------------------------- | | `{GDPR}` | GDPR applicability flag | `1` (applies), `0` (doesn't apply) | | `{GDPR_CONSENT}` | IAB TCF 2.0 consent string | `CPc7TgPPc7TgPAGABC...` | | `{US_PRIVACY}` | US Privacy (CCPA) string | `1YNN` | | `{GPP_STRING}` | Global Privacy Platform consent string | `DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA` | | `{GPP_SID}` | GPP Section ID(s) indicating applicable sections | `7`, `7,8` (US National, US National + California) | | `{IP_ADDRESS}` | User IP address (often masked for privacy) | `203.0.113.42`, `""` (when restricted) | | `{LIMIT_AD_TRACKING}` | Limit Ad Tracking enabled | `1` (limited), `0` (allowed) | > **Privacy Warning**: `{IP_ADDRESS}` is considered personal data under GDPR and many privacy regulations. This macro may return an empty string or masked/truncated IP depending on user privacy settings, publisher policies, and regional regulations. Use geo macros (`{COUNTRY}`, `{REGION}`, `{CITY}`) instead when possible. **Example - Privacy-aware tracking**: ```javascript theme={null} // In creative logic if (GDPR == 1 && GDPR_CONSENT == '') { // No consent - don't load tracking pixels } else { // Load tracking } ``` ### Device & Environment Macros | Macro | Description | Example Value | | ---------------- | --------------------------------- | -------------------------------------------- | | `{DEVICE_TYPE}` | Device category | `mobile`, `tablet`, `desktop`, `ctv`, `dooh` | | `{OS}` | Operating system | `iOS`, `Android`, `tvOS`, `Roku` | | `{OS_VERSION}` | OS version | `17.2`, `14.0` | | `{DEVICE_MAKE}` | Device manufacturer | `Apple`, `Samsung`, `Roku` | | `{DEVICE_MODEL}` | Device model | `iPhone15,2`, `Roku Ultra` | | `{USER_AGENT}` | Full user agent string | `Mozilla/5.0 ...` | | `{APP_BUNDLE}` | App bundle ID (domain or numeric) | `com.publisher.app`, `123456789` | | `{APP_NAME}` | Human-readable app name | `Publisher News App` | ### Geographic Macros | Macro | Description | Example Value | | ----------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | `{COUNTRY}` | ISO 3166-1 alpha-2 country code | `US`, `GB`, `CA`, `FR`, `JP`, `AU` | | `{REGION}` | State/province/region code | `NY`, `CA` (US states), `ON` (Canada), `IDF` (France), `NSW` (Australia) | | `{CITY}` | City name | `New York`, `London`, `Tokyo`, `Sydney` | | `{ZIP}` | Postal code | `10001` (US), `SW1A 1AA` (UK), `75001` (France), `100-0001` (Japan) | | `{DMA}` | [Nielsen DMA code](https://help.thetradedesk.com/s/article/Nielsen-DMA-Regions) (US TV markets) | `501` (New York), `803` (Los Angeles) | | `{LAT}` | Latitude | `40.7128`, `51.5074`, `35.6762` | | `{LONG}` | Longitude | `-74.0060`, `-0.1278`, `139.6503` | ### Identity Macros | Macro | Description | Example Value | | ------------------ | --------------------------------- | ----------------- | | `{DEVICE_ID}` | Mobile advertising ID (IDFA/AAID) | `ABC-123-DEF-456` | | `{DEVICE_ID_TYPE}` | Type of device ID | `idfa`, `aaid` | ### Web Context Macros For web-based inventory: | Macro | Description | Example Value | | ------------ | ------------------------------- | ----------------------- | | `{DOMAIN}` | Domain where ad is shown | `nytimes.com` | | `{PAGE_URL}` | Full page URL (encoded) | `https%3A%2F%2F...` | | `{REFERRER}` | HTTP referrer URL | `https://google.com` | | `{KEYWORDS}` | Page keywords (comma-separated) | `business,finance,tech` | ### Placement & Position Macros | Macro | Description | Example Value | | ----------------- | ----------------------------------- | -------------------------- | | `{PLACEMENT_ID}` | Global Placement ID (IAB standard) | `12345678` | | `{FOLD_POSITION}` | Position relative to fold (display) | `above_fold`, `below_fold` | | `{AD_WIDTH}` | Ad slot width | `300`, `728` | | `{AD_HEIGHT}` | Ad slot height | `250`, `90` | ### Video Content Macros For video formats with content context: | Macro | Description | Example Value | | ------------------ | --------------------------- | ----------------------------- | | `{VIDEO_ID}` | Content video identifier | `vid_12345` | | `{VIDEO_TITLE}` | Content video title | `Breaking News Story` | | `{VIDEO_DURATION}` | Content duration in seconds | `600` | | `{VIDEO_CATEGORY}` | IAB content category | `IAB1` (Arts & Entertainment) | | `{CONTENT_GENRE}` | Content genre | `news`, `sports`, `comedy` | | `{CONTENT_RATING}` | Content rating | `G`, `PG`, `TV-14` | | `{PLAYER_WIDTH}` | Video player width | `1920` | | `{PLAYER_HEIGHT}` | Video player height | `1080` | ### Video Ad Pod Macros For video ads in commercial breaks: | Macro | Description | Example Value | | ---------------- | -------------------------- | ------------- | | `{POD_POSITION}` | Position within ad break | `1`, `2`, `3` | | `{POD_SIZE}` | Total ads in this break | `3` | | `{AD_BREAK_ID}` | Unique ad break identifier | `break_mid_1` | **Note**: Video formats also support all [IAB VAST 4.x macros](http://interactiveadvertisingbureau.github.io/vast/vast4macros/vast4-macros-latest.html) like `[CACHEBUSTING]`, `[TIMESTAMP]`, `[DOMAIN]`, `[IFA]`, etc. These work natively in VAST XML. ### Audio Content Macros For audio formats with content context: | Macro | Description | Example Value | | ------------------- | ----------------------------------- | ---------------------------------- | | `{STATION_ID}` | Radio station or podcast identifier | `WXYZ-FM`, `pod_12345` | | `{COLLECTION_NAME}` | Program or collection name | `Morning Drive`, `Tech Talk Daily` | | `{INSTALLMENT_ID}` | Podcast episode identifier | `ep_2025_01_15` | | `{AUDIO_DURATION}` | Content duration in seconds | `3600` | ### Impression Identification | Macro | Description | Example Value | | ----------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `{IMPRESSION_ID}` | Upstream-minted unique identifier per impression (publisher or ad-decision layer) | `8c9e2f3a-7b1c-4d5e-9f6a-1a2b3c4d5e6f` (UUID), `01ARZ3NDEKTSV4RRFFQ69G5FAV` (ULID), or equivalent | The `{IMPRESSION_ID}` macro carries a unique identifier for one impression opportunity. It is a general-purpose impression key — buyers, measurement vendors, attribution providers, and verification services all use this kind of identifier to deduplicate impression events, reconcile pixel firings across vendors, join impressions to clicks, and detect retries. Whichever upstream layer mints the value (see hierarchy below) substitutes it into creative tracking URLs, and any downstream consumer that needs to identify the impression uses the same value. **Common use cases:** * **Per-impression deduplication.** A single impression often fires many pixels (impression, viewability, video quartiles, completion, third-party verification). All of them sharing one `{IMPRESSION_ID}` lets downstream consumers reconcile "is this the same impression?" without time- or URL-based heuristics. * **TMP cross-identity dedup.** When a TMP impression resolves to multiple user identities, the buyer's impression tracker writes the same `{IMPRESSION_ID}` to every identity's log so distinct-impression counts dedup correctly. See [Impression Tracker Implementation](/dist/docs/3.1.20/trusted-match/impression-tracker-implementation). * **Cross-vendor reconciliation.** Advertisers comparing delivery between an ad server, a verification vendor, and a measurement vendor join on `{IMPRESSION_ID}` rather than building cross-walks. * **Pixel retry dedup.** A pixel firing twice (network retry, page refresh) carries the same `{IMPRESSION_ID}`, so a server-side dedup pass on the id catches retries without over-counting. **When to include it:** * **Recommended on every impression tracking URL** — the macro is small, cheap, and the use cases above all benefit when it is consistently present. * **Required for TMP context-only impressions** — when Identity Match returned no eligibility (or wasn't called) and no `{TMPX}` is present on the pixel, `{IMPRESSION_ID}` is the only available cross-identity dedup key for the buyer's impression tracker. **Format is implementation choice.** The protocol requires uniqueness with sufficient entropy to avoid cross-seller / cross-time collisions; it does not pin a wire format. UUID (any version), ULID, snowflake-style IDs, or any other collision-resistant identifier scheme are all acceptable. Buyers MUST treat the value as an opaque string for dedup purposes — no parsing, no format assumptions. **Three valid sources for the value, in priority order:** 1. **Publisher-side mint** (highest priority): the publisher's own first-party code mints a fresh identifier per impression opportunity — e.g., server-side, before the ad request reaches the decision layer — and passes it forward so the decision layer can substitute it via `{IMPRESSION_ID}`. Works for both context-only and identity-bearing impressions. 2. **Decision-layer mint** (used when the publisher hasn't minted): the ad-decision layer (Prebid TMP module, ad server, SSP, or equivalent client) mints the identifier at the context↔identity join and substitutes it via `{IMPRESSION_ID}`. Also works for both context-only and identity-bearing impressions. 3. **Buyer-side mint at TMPX decode** (fallback, identity-bearing only): when neither layer above minted, the buyer's impression tracker mints the id locally at TMPX decode time per [`Impression Tracker Implementation`](/dist/docs/3.1.20/trusted-match/impression-tracker-implementation). This works only when `{TMPX}` is present — it cannot cover context-only impressions. Each layer MUST defer to whatever value an upstream layer already produced — minting a fresh id at a lower layer when a higher layer already supplied one would split logs for the same impression across two ids. In practice this means: if the publisher minted, the decision layer passes it through; if either of those minted, the buyer uses the value from the pixel rather than minting at decode. **Relationship to `{CACHEBUSTER}`.** The two are not interchangeable. `{CACHEBUSTER}` is a low-entropy anti-cache value sufficient to defeat HTTP intermediaries; it is not a globally-unique impression key. Both can appear in the same tracking URL serving different purposes. ### TMP Exposure Tracking | Macro | Description | Example Value | | -------- | ----------------------------------- | ------------------------ | | `{TMPX}` | TMP exposure token (HPKE-encrypted) | `k1.dG1weC1leGFtcGxl...` | The `{TMPX}` macro carries an encrypted exposure token from the [Identity Match](/dist/docs/3.1.20/trusted-match/specification) response. It contains the user's resolved identity tokens encrypted via HPKE, enabling the buyer's impression pixel to log per-user exposures for real-time frequency capping. Publishers substitute `{TMPX}` into tracking URLs exactly like other macros. The token is opaque — publishers MUST NOT parse, log, or make decisions based on its value. See [TMPX Exposure Tokens](/dist/docs/3.1.20/trusted-match/specification#tmpx-exposure-tokens) for the encryption format and key management. ### AXE Integration (Legacy) | Macro | Description | Example Value | | -------- | -------------------------------------- | -------------------------- | | `{AXEM}` | AXE contextual metadata (encoded blob) | `eyJjb250ZXh0IjoiLi4uIn0=` | The `{AXEM}` macro is from the legacy AXE integration. In [TMP](/dist/docs/3.1.20/trusted-match), this is replaced by: * **Structured creative assets** move to the `creative_manifest` field on the [Offer](/dist/docs/3.1.20/trusted-match/specification#offer). * **Per-user exposure tracking** uses the [`{TMPX}`](#tmp-exposure-tracking) macro from Identity Match. ### Catalog Item Macros For catalog-driven creatives (carousels, dynamic product ads, job boards, store locators). These macros resolve to the identifier of the specific catalog item being rendered at serve time — the same identifiers used in conversion event `content_ids` via the [`content_id_type`](/dist/docs/3.1.20/creative/catalogs#conversion-events) field. | Macro | Description | Example Value | | ------------------ | -------------------------------- | --------------------------- | | `{CATALOG_ID}` | Buyer-defined catalog identifier | `gmc-primary`, `job-feed` | | `{SKU}` | Product SKU identifier | `SKU-12345` | | `{GTIN}` | Global Trade Item Number | `00013000006040` | | `{OFFERING_ID}` | AdCP offering identifier | `summer-sale` | | `{JOB_ID}` | Job posting identifier | `vacancy-amsterdam-chef-42` | | `{HOTEL_ID}` | Hotel property identifier | `grand-amsterdam` | | `{FLIGHT_ID}` | Flight route identifier | `AMS-BCN-2025-06` | | `{VEHICLE_ID}` | Vehicle listing identifier | `VIN-1234` | | `{LISTING_ID}` | Real estate listing identifier | `prop-amsterdam-01` | | `{STORE_ID}` | Store location identifier | `amsterdam-flagship` | | `{PROGRAM_ID}` | Education program identifier | `mba-2025` | | `{DESTINATION_ID}` | Travel destination identifier | `barcelona` | Use the macro that matches your catalog's `content_id_type`. For example, a product catalog with `content_id_type: "gtin"` uses `{GTIN}` in tracker URLs; a job catalog uses `{JOB_ID}`. #### Catalog content macros The macros above resolve to the catalog item's **identifier**. Catalog **content** macros resolve to a catalog item's scalar **field values**, for catalog-driven creative templates (`sponsored_placement` / DPA — Meta DPA, Snap Collection, TikTok Shopping). They are the substitute-the-value analog of the ID macros above: same single-brace family, same substitution-safety rules below, just more tokens. | Macro | Description | `catalog_field` | Example Value | | ----------------------- | ---------------------- | ---------------- | --------------------------------- | | `{ITEM_NAME}` | Item name/title | `name` | `Summer Sale` | | `{ITEM_DESCRIPTION}` | Item description | `description` | `Up to 50% off summer collection` | | `{ITEM_TAGLINE}` | Promotional tagline | `tagline` | `Start measuring today` | | `{ITEM_PRICE}` | Price amount | `price.amount` | `29.99` | | `{ITEM_PRICE_CURRENCY}` | ISO 4217 currency code | `price.currency` | `USD` | Each token maps 1:1 to a documented catalog item field via the existing `catalog_field` dot-notation vocabulary used by [`field_bindings`](/dist/docs/3.1.20/creative/catalogs#field-bindings) — content macros do **not** introduce a parallel field vocabulary. The five tokens are deliberately small and cross-vertical; vertical-specific deep fields (e.g. `star_rating`, `salary.min`) are served by `field_bindings` scalars, not universal content macros. URL-valued and image-valued fields (e.g. `landing_url`, image pools) bind via `field_bindings` to `url`/asset slots and are **not** content macros — substituting a whole URL as an entire `href` would also break the `encodeURIComponent`-equivalent percent-encoding contract below. **Single-brace `{MACRO}` only.** `{{double-brace}}` is **not** an AdCP content-macro syntax and MUST NOT be used. Double-brace is reserved as one of the downstream ad-server macro syntaxes (`%%...%%`, `${...}`, `[...]`, `{{...}}`) that the sales agent MUST neutralize/percent-encode (see the nested-expansion rule below); adopting it for AdCP content macros would relax that guarantee and collide with downstream ad servers that interpret `{{...}}` natively. Which catalog items render is **seller-declared** via the `fanout_mode` enum on the [`sponsored_placement`](/dist/docs/3.1.20/creative/canonical-formats) format (`single_item` / `per_item` / `multi_item_in_creative`) — there is no buyer-side selection field. On ML-optimized DPA surfaces (Meta Advantage+, TikTok Shopping) the platform commonly overrides buyer-authored overlay text, so content macros are a buyer-declared **hint** the seller MAY honor, not a guaranteed substitution. #### Substitution safety (catalog-item macros) Catalog-item macros are the one macro class where the value originates in **buyer-controlled data** (the catalog feed) and expands at impression time into **publisher-controlled contexts** (impression tracker URLs, click tracker URLs, VAST tracking-event URLs, AND landing / clickthrough URLs — the full set of [URL substitution targets](#overview) above). That flow is attacker-adjacent: a catalog value containing `&`, `#`, `?`, CR/LF, a stray URL fragment, or a Unicode bidi override can break out of the URL context, inject a Host-header via CRLF, or spoof audit-log rendering if substituted raw. The following rules apply to all catalog-item macros listed above — both the ID macros (`{CATALOG_ID}`, `{SKU}`, `{GTIN}`, `{OFFERING_ID}`, `{JOB_ID}`, `{HOTEL_ID}`, `{FLIGHT_ID}`, `{VEHICLE_ID}`, `{LISTING_ID}`, `{STORE_ID}`, `{PROGRAM_ID}`, `{DESTINATION_ID}`) and the content macros (`{ITEM_NAME}`, `{ITEM_DESCRIPTION}`, `{ITEM_TAGLINE}`, `{ITEM_PRICE}`, `{ITEM_PRICE_CURRENCY}`): * **Normalize to Unicode NFC before encoding.** Prior to percent-encoding, catalog-item values that are not already in Unicode Normalization Form C (NFC) MUST be normalized to NFC per Unicode Standard Annex #15. Sellers and buyers MAY send catalog values in any normalization form at `sync_catalogs` ingest (the catalog is stored as-supplied); the normalization to NFC is a step in the substitution pipeline immediately before percent-encoding, not a catalog-ingest requirement. Without this step, two implementations that both satisfy the unreserved-whitelist rule below produce different bytes for the same visual string — `café` (NFC: U+00E9) and `cafe\u0301` (NFD: U+0065 + combining U+0301) encode to `caf%C3%A9` vs `e%CC%81` respectively. NFC matches web-platform convention (WHATWG URL, HTML5 DOM, W3C Character Model). NFKC / NFKD are **not** acceptable substitutes — their compatibility folding silently mutates fullwidth/halfwidth variants and other visually-distinct glyphs that legitimately appear in Japanese/Korean retailer catalogs. * **Percent-encode every octet that is not in the RFC 3986 `unreserved` set.** Sales agents MUST percent-encode the NFC-normalized catalog-item value such that only RFC 3986 `unreserved` characters (`ALPHA / DIGIT / "-" / "." / "_" / "~"`) remain unescaped before substituting it into a URL context (query string, path segment, or fragment). Non-ASCII octets MUST be percent-encoded after UTF-8 encoding per RFC 3986 §2.5. This is the `encodeURIComponent`-equivalent contract: reserved characters (`: / ? # [ ] @ ! $ & ' ( ) * + , ; =`) are escaped as one would expect, but so are CR (`%0D`), LF (`%0A`), space (`%20`), C0/C1 control characters, and Unicode bidi overrides — the broader enumeration closes CRLF-injection and bidi-spoofing vectors that a reserved-only rule would leave open. Encoding is applied exactly once at substitution time; downstream VAST players and ad servers firing the URL verbatim is the expected contract — they do not and MUST NOT re-decode before firing. * **Nested macro expansion is prohibited.** A catalog-item value that itself contains text matching AdCP's `{MACRO_NAME}` syntax MUST NOT be re-expanded. Sales agents perform AdCP macro substitution in one pass: source placeholders are replaced with literal values, and those literal values are not re-scanned. A `{JOB_ID}` value of `vacancy-{DEVICE_ID}-42` produces the literal string `vacancy-%7BDEVICE_ID%7D-42` (after percent-encoding of the braces) in the emitted URL, not a second-round expansion. This rule binds AdCP's `{...}` syntax only; catalog-item values containing downstream ad-server macro syntaxes (`%%...%%`, `${...}`, `[...]`, `{{...}}`) remain the sales agent's responsibility to neutralize when targeting an ad server that would interpret them — percent-encoding per the rule above typically suffices, since `%`, `$`, `[`, `]`, and `{` all land outside the `unreserved` set. * **Scope is URL contexts only.** These rules apply when a catalog-item macro is substituted into a URL context. When a catalog-item macro is substituted into an HTML-attribute context (for example, a banner template's `href` or `data-*` attribute rendered server-side), percent-encoding per this section does not by itself prevent attribute-context breakout; the renderer MUST additionally apply HTML-attribute escaping — the two encodings are layered, not alternatives, because the value must survive both the URL parser and the HTML attribute parser. AdCP's normative contract bounds to the URL-context case; publisher-side HTML-attribute handling is out of scope for this spec. Non-catalog macros (`{MEDIA_BUY_ID}`, `{PACKAGE_ID}`, `{CREATIVE_ID}`, `{GEO}`, `{COUNTRY}`, `{DEVICE_TYPE}`, etc.) are populated from publisher- or ad-server-mediated state, not from buyer-supplied feed data. Their encoding contract is governed by the ad-server integration (OpenRTB, VAST), which percent-encodes by convention. Some macros in this class derive from attacker-spoofable inputs (`{USER_AGENT}`, `{REFERRER}`, `{PAGE_URL}`, `{DOMAIN}`, `{APP_BUNDLE}` come from request headers or page metadata); the OpenRTB / ad-server encoding convention is the control today. This spec's normative MUST deliberately scopes to the buyer-controlled catalog-item class — a narrower, verifiable contract than a universal canonicalization rule. **Conformance fixture.** Reference test vectors pinning the encoding behavior — reserved-character breakout, nested-expansion literal preservation, CRLF injection, non-ASCII — are versioned at [`static/compliance/source/test-vectors/catalog-macro-substitution.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/catalog-macro-substitution.json). Sales agents SHOULD validate their substitution code against these vectors before shipping. ### Creative Variant Macros | Macro | Description | Example Value | | ----------------------- | ------------------------------------------- | ------------------------ | | `{CREATIVE_VARIANT_ID}` | Seller-assigned creative variant identifier | `variant_a`, `v2_mobile` | > **Note**: Publisher-specific custom macros may be defined in individual creative format specifications as `extra supported macros`. ## Usage Examples ### Video Creative with Tracking ```json theme={null} { "creative_id": "cr_video_30s", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_vast" }, "assets": { "vast_xml": { "asset_type": "vast", "delivery_type": "inline", "content": "\n\n \n \n \n \n \n \n 00:00:30\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n", "vast_version": "4.2" } } } ``` **Key Points**: * Mix AdCP macros (`{MEDIA_BUY_ID}`) with VAST macros (`[CACHEBUSTING]`) * AdCP macros use `{CURLY_BRACES}` * VAST macros use `[SQUARE_BRACKETS]` * Both work together seamlessly ### Display Creative with Tracking ```json theme={null} { "creative_id": "cr_banner_300x250", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_banner_300x250" }, "assets": { "banner_image": { "url": "https://cdn.brand.com/banners/spring_300x250.jpg", "width": 300, "height": 250 }, "impression_pixel": { "url": "https://track.brand.com/imp?buy={MEDIA_BUY_ID}&pkg={PACKAGE_ID}&cre={CREATIVE_ID}&device={DEVICE_ID}&domain={DOMAIN}&cb={CACHEBUSTER}" }, "landing_url": { "url": "https://brand.com/spring?campaign={MEDIA_BUY_ID}" } } } ``` ### Audio Creative with Tracking ```json theme={null} { "creative_id": "cr_audio_30s", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "audio_streaming_30s" }, "assets": { "audio_file": { "url": "https://cdn.brand.com/audio/spring_30s.mp3", "duration_ms": 30000 }, "impression_tracker": { "url": "https://track.brand.com/imp?buy={MEDIA_BUY_ID}&pkg={PACKAGE_ID}&station={STATION_ID}&show={COLLECTION_NAME}&cb={CACHEBUSTER}" } } } ``` ### Catalog-Driven Creative with Item Tracking ```json theme={null} { "creative_id": "cr_product_carousel", "format_id": { "agent_url": "https://creative.retailer.com/adcp", "id": "product_carousel" }, "catalogs": [{ "catalog_id": "gmc-primary", "type": "product", "content_id_type": "gtin", "tags": ["summer"] }], "assets": { "impression_pixel": { "url": "https://track.brand.com/imp?buy={MEDIA_BUY_ID}&catalog={CATALOG_ID}&item={GTIN}&cb={CACHEBUSTER}", "url_type": "tracker_pixel" }, "click_tracker": { "url": "https://track.brand.com/click?buy={MEDIA_BUY_ID}&catalog={CATALOG_ID}&item={GTIN}", "url_type": "tracker_pixel" } } } ``` **Key point**: `{GTIN}` resolves to the specific product's GTIN at serve time. For a carousel showing 5 products, each product impression/click fires with that product's identifier — enabling per-item attribution. ## Macro Availability by Inventory Type Not all macros are available in all inventory types. Check format specifications to see which macros are supported. **Important**: The columns below represent format types (Display, Video, etc.) which can run in different environments (app vs web). For example: * Display ads in mobile apps have `DEVICE_ID` (✅\*), but display ads on web do not * The ✅\* notation means "available in-app contexts only" * Format type + inventory environment determine actual macro availability | Macro Category | Display | Video | Audio | Native | CTV/OTT | DOOH | Mobile App | Mobile Web | Desktop Web | | --------------------- | ------- | ----- | ----- | ------ | ------- | ---- | ---------- | ---------- | ----------- | | **Common** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{MEDIA_BUY_ID}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{PACKAGE_ID}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{CREATIVE_ID}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{CACHEBUSTER}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{TIMESTAMP}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **Privacy** | | | | | | | | | | | `{GDPR}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{GDPR_CONSENT}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{US_PRIVACY}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{GPP_STRING}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{GPP_SID}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{IP_ADDRESS}` | ✅‡ | ✅‡ | ✅‡ | ✅‡ | ✅‡ | ❌ | ✅‡ | ✅‡ | ✅‡ | | `{LIMIT_AD_TRACKING}` | ✅\* | ✅\* | ✅\* | ✅\* | ✅ | ❌ | ✅ | ❌ | ❌ | | **Identity** | | | | | | | | | | | `{DEVICE_ID}` | ✅\* | ✅\* | ✅\* | ✅\* | ✅ | ❌ | ✅ | ❌ | ❌ | | `{DEVICE_ID_TYPE}` | ✅\* | ✅\* | ✅\* | ✅\* | ✅ | ❌ | ✅ | ❌ | ❌ | | **Geographic** | | | | | | | | | | | `{COUNTRY}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{REGION}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{CITY}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{ZIP}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{DMA}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{LAT}/{LONG}` | ✅† | ❌ | ❌ | ✅† | ❌ | ✅ | ✅† | ❌ | ❌ | | **Device** | | | | | | | | | | | `{DEVICE_TYPE}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{OS}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{OS_VERSION}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{APP_BUNDLE}` | ✅\* | ✅\* | ✅\* | ✅\* | ✅ | ❌ | ✅ | ❌ | ❌ | | `{USER_AGENT}` | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | | **Web Context** | | | | | | | | | | | `{DOMAIN}` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | | `{PAGE_URL}` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | | `{REFERRER}` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | | `{KEYWORDS}` | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | | **Placement** | | | | | | | | | | | `{PLACEMENT_ID}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `{FOLD_POSITION}` | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | | **Video Content** | | | | | | | | | | | `{VIDEO_ID}` | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | `{VIDEO_CATEGORY}` | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | `{CONTENT_GENRE}` | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Video Ad Pods** | | | | | | | | | | | `{POD_POSITION}` | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | `{POD_SIZE}` | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | `{AD_BREAK_ID}` | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Audio Content** | | | | | | | | | | | `{STATION_ID}` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | `{COLLECTION_NAME}` | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **TMP Exposure** | | | | | | | | | | | `{TMPX}` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅§ | ✅ | ✅ | ✅ | **Legend**: * ✅ = Available * ❌ = Not available * ✅\* = In-app only (not mobile web) * ✅† = When location permission granted * ✅‡ = Often restricted due to privacy regulations (may return empty or masked value) * ✅§ = DOOH uses play-log-based reporting rather than pixel URLs **Important Notes**: * Privacy macros (`{LIMIT_AD_TRACKING}`, `{DEVICE_ID}`) may return empty values based on user privacy settings * Geographic macros accuracy varies by publisher's data capabilities * `{PLACEMENT_ID}` refers to the IAB Global Placement ID standard ## How Macros Work ### 1. Discovery Query `list_creative_formats` to see which macros each format supports: ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s_vast" }, "type": "video", "supported_macros": [ { "macro": "{MEDIA_BUY_ID}", "category": "identity", "description": "AdCP media buy identifier", "required": false, "privacy_sensitive": false, "example_value": "mb_spring_2025" }, { "macro": "{DEVICE_ID}", "category": "identity", "description": "Mobile advertising ID (IDFA/AAID)", "required": false, "privacy_sensitive": true, "example_value": "ABC-123-DEF-456" }, { "macro": "{GDPR}", "category": "privacy", "description": "GDPR applicability flag", "required": true, "privacy_sensitive": false, "example_value": "1" } ], "vast_macros_supported": true } ``` ### 2. Include Macros in Creatives Add macro placeholders in your tracking URLs using `{MACRO_NAME}` syntax: ``` https://track.brand.com/imp?campaign={MEDIA_BUY_ID}&device={DEVICE_ID} ``` ### 3. Sales Agent Processing When you create a media buy via `create_media_buy`, the sales agent: 1. **Replaces AdCP ID macros** with your actual IDs: * `{MEDIA_BUY_ID}` → `mb_spring_2025` * `{PACKAGE_ID}` → `pkg_ctv_prime` * `{CREATIVE_ID}` → `cr_video_30s` 2. **Translates platform macros** to their ad server's syntax: * `{CACHEBUSTER}` → `%%CACHEBUSTER%%` (GAM) or `{{timestamp}}` (Kevel) * `{DEVICE_ID}` → `%%ADVERTISING_IDENTIFIER_PLAIN%%` (GAM) * `{DOMAIN}` → `%%SITE%%` (GAM) 3. **Inserts click trackers** automatically into clickable elements 4. **Leaves VAST macros unchanged** (for video formats) #### Implementing translation with the SDK Sales agents don't have to hand-roll the rewrite. The `@adcp/sdk` package ships a `translateUniversalMacros` helper that applies a per-macro mapping to a tracking URL's query-parameter values. Each macro maps to either a **native** ad-server token (left raw, filled by the ad server at impression time) or a concrete **value** the agent already knows (substituted now, percent-encoded per RFC 3986): ```typescript theme={null} import { translateUniversalMacros } from '@adcp/sdk'; const mapping = { // AdCP identifiers the agent knows at media-buy creation → concrete values '{MEDIA_BUY_ID}': { value: 'mb_spring_2025' }, '{PACKAGE_ID}': { value: 'pkg_ctv_prime' }, // Runtime macros → the ad server's native syntax (Google Ad Manager shown) '{CACHEBUSTER}': { native: '%%CACHEBUSTER%%' }, '{DEVICE_ID}': { native: '%%ADVERTISING_IDENTIFIER_PLAIN%%' }, }; const { url, dropped_params, unmapped_macros, dropped_consent_macros, suspect_native_values } = translateUniversalMacros( 'https://track.brand.com/imp?buy={MEDIA_BUY_ID}&pkg={PACKAGE_ID}&cb={CACHEBUSTER}&device={DEVICE_ID}', mapping, ); // url: // https://track.brand.com/imp?buy=mb_spring_2025&pkg=pkg_ctv_prime&cb=%%CACHEBUSTER%%&device=%%ADVERTISING_IDENTIFIER_PLAIN%% // dropped_params: [] (every macro was mapped) // unmapped_macros: [] // dropped_consent_macros: [] (no consent macro was dropped) // suspect_native_values: [] (no value entry looks like a native token) ``` Behavior: * **`native` entries are inserted verbatim** — `%%CACHEBUSTER%%` is not percent-encoded, so the ad server still recognizes it. * **`value` entries are RFC-3986 percent-encoded**, so a value containing reserved characters can't break or inject into the URL. * **A parameter whose universal macro the agent does not support is dropped entirely** (its key is reported in `dropped_params`, the macro in `unmapped_macros`) — better to omit a tracker the agent can't fill than to emit a broken one. A dropped consent/privacy macro (`{GDPR_CONSENT}`, `{US_PRIVACY}`, …) is also surfaced in **`dropped_consent_macros`** — **inspect it** so a forgotten mapping doesn't silently ship a consent-degraded pixel. * **Already-minted parameters pass through untouched** — `pkg_id=123456` (no macro) is left exactly as-is. * **`suspect_native_values`** flags any `value` entry shaped like a native token (`%%…%%`, `{{…}}`, `${…}`, `[UPPER_SNAKE]`) — almost always a mapping that should have used the `native` arm. * Only query-parameter **values** are translated; a macro in a key position is left untouched. ### 4. Impression Time The publisher's ad server replaces remaining macros with actual values: ``` https://track.brand.com/imp? campaign=mb_spring_2025& device=ABC-123-DEF-456& cb=87654321 ``` ## Best Practices ### Use Macros Consistently Include the same core set of macros across all your creatives: ``` ?buy={MEDIA_BUY_ID}&pkg={PACKAGE_ID}&cre={CREATIVE_ID}&cb={CACHEBUSTER} ``` This makes your tracking data consistent and easier to analyze. ### Check Format Support Always query `list_creative_formats` to see which macros are available. Not all formats support all macros. ### Combine VAST and AdCP Macros For video, use both systems together: * **VAST macros** `[CACHEBUSTING]`, `[TIMESTAMP]` - for standard video tracking * **AdCP macros** `{MEDIA_BUY_ID}`, `{DEVICE_ID}` - for your campaign tracking ### Privacy Compliance **Critical**: Always respect user privacy choices in your creative logic. #### GDPR Compliance (EU Traffic) For campaigns serving in the EU: ```javascript theme={null} // Check consent before loading tracking if (GDPR == 1) { if (GDPR_CONSENT && GDPR_CONSENT != '') { // User has consented - load tracking pixels loadTracking(); } else { // No consent - skip tracking console.log('Tracking skipped - no GDPR consent'); } } else { // GDPR doesn't apply - load tracking loadTracking(); } ``` #### US Privacy / CCPA Compliance For US traffic: ```javascript theme={null} // Check US Privacy string if (US_PRIVACY == '1YYN') { // User has opted out - don't sell personal info skipPersonalizedTracking(); } else { // Load normal tracking loadTracking(); } ``` #### Device-Level Privacy Respect Limit Ad Tracking settings: ```javascript theme={null} // Check if device ID is available if (LIMIT_AD_TRACKING == 1 || DEVICE_ID == '' || DEVICE_ID == '00000000-0000-0000-0000-000000000000') { // User has limited tracking - use contextual attribution useContextualTracking(); } else { // Device ID available useDeviceTracking(DEVICE_ID); } ``` #### Privacy Macro Behavior **Empty Values**: Privacy-restricted macros return empty strings or zeros: * `{DEVICE_ID}` → `""` or `00000000-0000-0000-0000-000000000000` when LAT enabled * `{GDPR_CONSENT}` → `""` when no consent provided * `{IP_ADDRESS}` → `""` or masked/truncated IP when privacy restricted **Always test for empty values** before using privacy-sensitive macros. ### URL Encoding No need to URL-encode macro placeholders. The ad server handles encoding of actual values automatically. **Example**: ``` ❌ WRONG: https://track.com/imp?device=%7BDEVICE_ID%7D ✅ CORRECT: https://track.com/imp?device={DEVICE_ID} ``` The ad server will URL-encode the actual value when replacing the macro. ### Template Syntax AdCP macro-bearing URLs validate as [RFC 6570 URI templates (Level 1)](https://datatracker.ietf.org/doc/html/rfc6570#section-1.2) — simple `{var}` substitution only. Level 2–4 operators (`{+SKU}`, `{#SKU}`, `{.SKU}`, `{/SKU}`, `{;SKU}`, `{?SKU}`, `{&SKU}`) are **not** used by AdCP and must not appear in manifests. The ad server performs literal string replacement, not RFC 6570 expansion. ## Implementation Notes for Sales Agents *This section is for AdCP implementers, not buyers.* ### Macro Translation Approach Sales agents must translate universal macros to their ad server's native syntax. The recommended approach: **Option 1: Hard-Code During Trafficking (MVP)** * When creating ad server creatives, replace AdCP ID macros with actual values * Translate platform macros to ad server syntax * Creates one creative per line item but is simple and reliable **Option 2: Dynamic Wrapper (Future)** * Intercept ad calls and inject values dynamically * More complex but avoids creative duplication ### Translation Examples **Google Ad Manager**: ```javascript theme={null} { '{CACHEBUSTER}': '%%CACHEBUSTER%%', '{DEVICE_ID}': '%%ADVERTISING_IDENTIFIER_PLAIN%%', '{DEVICE_ID_TYPE}': '%%ADVERTISING_IDENTIFIER_TYPE%%', '{DOMAIN}': '%%SITE%%', '{VIDEO_ID}': '%%VIDEO_ID%%' } ``` **Kevel**: ```javascript theme={null} { '{CACHEBUSTER}': '{{timestamp}}', '{DEVICE_ID}': '{{device.ifa}}', '{DEVICE_ID_TYPE}': '{{device.ifaType}}', '{DOMAIN}': '{{request.domain}}' } ``` **Xandr Monetize**: ```javascript theme={null} { '{CACHEBUSTER}': '${CACHEBUSTER}', '{DEVICE_ID}': '${DEVICE_APPLE_IDA}', // or ${DEVICE_AAID} '{DOMAIN}': '${DOMAIN}' } ``` ### Click Tracker Insertion A click on a creative produces two distinct signals, and AdCP models them as **separate asset slots**: | Slot | Asset | Role | OpenRTB Native parallel | | ------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------- | | `landing_page_url` | `url` | The single terminal destination the user navigates to. | `link.url` | | `click_tracker` | `pixel_tracker` (`event: click`) | A measurement hop that fires on click **without** navigating the user there. Any number may be present. | `link.clicktrackers[]` | This separation is inherited from IAB OpenRTB Native: a click has **exactly one destination** and **N fire-and-forget click trackers** (the `link.fallback` deeplink is an alternative form of that one destination, not a second one). At serve time the sales agent fires every `click_tracker` as a count beacon and navigates the user to the one `landing_page_url`. Both slots are defined in [Asset Types](/dist/docs/3.1.20/creative/asset-types). **Macro insertion.** Sales agents insert their ad server's click-tracking macro ahead of the destination so the platform records the click and forwards the user. The destination encoding is ad-server-specific (GAM uses append-style `%%CLICK_URL_UNESC%%`; other servers ship a self-contained `?...&rurl=` form): **Original creative**: ```html theme={null} Click here ``` **After insertion (GAM)**: ```html theme={null} Click here ``` **Preserving the buyer's clickthrough parameters.** Inserting a click macro can strip query parameters already present on the buyer-supplied clickthrough URL — including ones the agent does not recognize, such as a buyer-side vendor's click identifier — which silently breaks downstream attribution. An agent that forwards these parameters applies the encoding discipline [Substitution safety](#substitution-safety-catalog-item-macros) defines for catalog-item values (Unicode NFC normalization, then percent-encoding to the RFC 3986 `unreserved` set) to any value derived from buyer- or third-party-supplied data, so preservation does not reopen URL-context breakout, CRLF injection, or bidi spoofing. Whether to make parameter preservation a normative requirement is part of the click-tracking proposal under working-group review ([#5693](https://github.com/adcontextprotocol/adcp/issues/5693)). **Getting a buyer's click identifier onto the landing.** A `click_tracker` is a count beacon: it records the click but does not place its identifier into the user's landing session, because only `landing_page_url` is navigated. When a buyer needs its click identifier to reach the landing (for click-level conversion attribution), the reliable, redirect-free pattern is to put that identifier directly on the `landing_page_url` it supplies — either as a literal value (`&buyer_click_id=abc123`) or composed from an AdCP macro the buyer controls (`&buyer_click_id={CREATIVE_ID}`, expanded by the sales agent at serve time). `{buyer_click_id}` is not itself an AdCP macro — the macro set is a closed registry (see [Available Macros](#available-macros-by-format)), so the buyer supplies the value, not a new token. Because the identifier rides on the one navigated URL, no party's redirect has to be the terminal hop. This works for any identifier the buyer can supply or derive ahead of serve time, and avoids multi-party redirect chaining entirely. Identifiers that can only be minted at click time (inside a vendor's own redirect) cannot survive on a count beacon, and routing the user *through* the vendor's redirect so its identifier lands requires a chaining contract AdCP does not define. A buyer-declarable "this tracker must be in the navigation path" slot semantic is under working-group discussion ([#5693](https://github.com/adcontextprotocol/adcp/issues/5693)); until it lands, the count beacon is the default and in-path identifier survival is out of scope. ### Mapping Storage Store the mapping between AdCP IDs and ad server IDs for reconciliation: ```javascript theme={null} { media_buy_id: "mb_spring_2025", ad_server_order_id: "1234567", packages: [ { package_id: "pkg_ctv_prime", ad_server_line_item_id: "8901234" } ] } ``` Return this in `create_media_buy` responses and make it queryable for reconciliation. ## Related Documentation * [Creative Formats](/dist/docs/3.1.20/creative/formats) - Understanding format specifications and discovery * [Creative Protocol](/dist/docs/3.1.20/creative) - How creatives work in AdCP * [sync\_creatives](/dist/docs/3.1.20/creative/task-reference/sync_creatives) - Creative management API # Annex III & Article 22 obligations Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/annex-iii-obligations How AdCP's policy framework supports deployer obligations under GDPR Article 22 and EU AI Act Annex III for regulated verticals — credit, insurance, employment, housing. AdCP is used to run advertising campaigns. Some of those campaigns make automated decisions about who sees an ad for a credit card, a life insurance quote, a job listing, or an apartment. Under EU law, those are not ordinary marketing decisions — they are **solely automated decisions producing legal or similarly significant effects** (GDPR Article 22) and, when made by AI systems, they fall within **Annex III high-risk categories** of the EU AI Act (Regulation (EU) 2024/1689). This page explains what AdCP provides, what it does not, and what the deployer is responsible for. ## What the law requires **GDPR Art. 22(1)** prohibits decisions based solely on automated processing — including profiling — that produce legal effects or similarly significantly affect a natural person, unless a narrow exception applies (explicit consent, contract, or EU/Member State law). Ad-targeting decisions in regulated verticals routinely engage Art. 22: the *SCHUFA* case (CJEU C-634/21, 2023) extended "similarly significant effects" broadly. **EU AI Act Annex III** lists high-risk use cases that intersect AdCP: | Annex III reference | Vertical | | ------------------- | -------------------------------------------------------- | | §1(b) | Recruitment / selection (targeting job ads) | | §5(b) | Evaluation of creditworthiness (credit / lending ads) | | §5(c) | Risk assessment and pricing of life and health insurance | US parallels are the **Fair Housing Act** (HUD v. Facebook, 2019 settlement), **ECOA** for credit, and **EEOC / ADEA** for employment. AdCP treats housing allocation as equivalent-risk under the `fair_housing` category even though Annex III does not name it directly. For any of the above, the deployer MUST: 1. **Ensure human oversight** (AI Act Art. 14) — a qualified person reviews the decision. 2. **Maintain automatic logs** (AI Act Art. 12) — timestamped records of each decision. 3. **Provide transparency** (AI Act Art. 13, GDPR Art. 13–14) — the data subject can understand what's happening. 4. **Govern input data** (AI Act Art. 10) — signals used for targeting are documented and restricted-attribute-aware. 5. **Honor contestation rights** (GDPR Art. 22(3)) — the data subject can request human intervention, express their view, and contest the outcome. The monetary value of the decision is irrelevant. A €20 autonomous targeting decision in a regulated vertical engages Art. 22 identically to a €2M one. ## What AdCP provides **AdCP is a building block, not a compliance shortcut.** The protocol exposes structured fields that let a deployer discharge Annex III obligations — it does not itself perform conformity assessment, DPIA, human-oversight workflow, or contestation handling. Those remain the deployer's responsibility under the AI Act and GDPR. The mechanisms below are the seams AdCP provides; the obligations stay with you. AdCP's role is **Article 25 data-governance provider**: the protocol exposes structured fields that let the deployer discharge its Annex III obligations. | Obligation | AdCP mechanism | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Human oversight (Art. 14, Art. 22(1)) | `plan.human_review_required` — governance agent escalates every action for human approval | | Input-data governance (Art. 10) | `policy_categories`, `restricted_attributes`, `min_audience_size`, signal `restricted_attributes` declarations | | Automatic logging (Art. 12) | `get_plan_audit_logs` — immutable record of `sync_plans`, `check_governance`, `report_plan_outcome` | | Transparency (Art. 13) | Policy registry entries exposing policy text and exemplars used in decisions | | Contestation discovery (Art. 22(3)) | `brand.data_subject_contestation` — a **discoverable contact point** pointing to the deployer's contestation process. AdCP does not operate the process. | | Policy vocabulary | `eu_ai_act_annex_iii` registry policy + `fair_housing`, `fair_lending`, `fair_employment`, `pharmaceutical_advertising` categories | ### Automatic triggering When a campaign plan declares any of the following, the governance agent MUST set `plan.human_review_required = true`: * `policy_categories` includes `fair_housing`, `fair_lending`, `fair_employment`, or `pharmaceutical_advertising` * Any resolved policy (registry or custom) has `requires_human_review: true` * The resolved registry policy `eu_ai_act_annex_iii` applies via jurisdiction matching * `brand.industries` intersects a regulated sector (consumer\_finance, banking, mortgage, life\_insurance, health\_insurance, recruitment, staffing, real\_estate, property\_management, housing) If any Annex III category resolves but `brand.data_subject_contestation` is missing, the governance agent MUST emit a critical finding — Art 22(3) cannot be discharged without a contact point. This is enforced by the policy framework, not by the buyer. A buyer cannot opt out of human review by omitting the flag — if the resolved policies require it, the governance agent sets it. A buyer who previously declared `human_review_required: true` cannot downgrade it on re-sync without an explicit `human_override` artifact (reason + approver). ### `reallocation_threshold` vs. `human_review_required` These are **different axes** and often confused: | Field | Scope | Covers | | ------------------------------- | ----------- | ------------------------------------------------------------------------- | | `budget.reallocation_threshold` | Operational | Budget reallocation across sellers, channels, purchase types | | `plan.human_review_required` | Regulatory | Decisions affecting individuals — targeting, creative selection, delivery | A plan can set `reallocation_threshold` equal to `budget.total` (agent reallocates budget freely) **and** `human_review_required: true` (every targeting decision gets human review). These govern different things. Annex III / Art. 22 obligations flow through `human_review_required`, not through `reallocation_threshold`. Restricting budget autonomy does not address Art. 22; it just adds friction to reallocation. ## Contestation endpoint AdCP provides a **discovery mechanism** for the contestation process, not the process itself. Art. 22(3) gives the data subject three substantive rights — human intervention, expression of view, and contestation of the outcome — which are workflow rights the deployer must operate. `brand.data_subject_contestation` tells downstream agents and data subjects where to find that process; it does not substitute for it. `brand.data_subject_contestation` surfaces a **contact reference** — a URL, an email, or both. It is intentionally not a machine-callable API. Art. 22(3) rights are workflow rights exercised by humans; the deployer runs the workflow behind the contact point. ```json theme={null} { "data_subject_contestation": { "url": "https://acmecorp.com/privacy/contest", "email": "privacy@acmecorp.com", "languages": ["en", "de", "fr"] } } ``` Any AdCP agent exposing an automated decision under Annex III SHOULD make this pointer available to downstream consumers (e.g., in disclosure text or creative metadata). Deployers MUST monitor and respond within applicable legal timelines — one month under GDPR Art. 12(3). ## What AdCP does not do * **AdCP does not perform conformity assessment.** The AI Act Art. 43 conformity assessment is the provider's obligation. * **AdCP does not run the human-oversight workflow.** Setting `human_review_required: true` means the governance agent escalates — the human reviewer and review tooling are outside the protocol. * **AdCP does not define the contestation process.** `data_subject_contestation` points to the deployer's process. * **AdCP does not determine which vertical a campaign belongs to.** The buyer declares `policy_categories`; governance agents may flag mismatches but cannot guess intent. ### Jurisdictional scoping is mechanical AdCP resolves policy applicability by matching `plan.countries` against policy `jurisdictions`. This is a useful first pass but incomplete at the edges: * A US-based deployer targeting only US audiences may still reach EU residents via cross-border signals, which engages GDPR / AI Act obligations that `plan.countries: ["US"]` would miss. * An EU-established entity running a non-EU campaign remains subject to the AI Act under establishment-based jurisdiction (Art. 2 of Regulation (EU) 2024/1689). * Housing / lending / employment regulations have their own extraterritorial doctrines distinct from privacy law. Deployers MUST evaluate applicable law based on establishment, target audience location, and processing location — not solely on the plan's country list. Governance agents SHOULD apply regulated-vertical policies conservatively: if the deployer's `brand.industries` or campaign `objectives` suggest an Annex III vertical, the policy fires regardless of whether `plan.countries` matches the policy's declared jurisdictions. ## See also * [`eu_ai_act_annex_iii`](/dist/docs/3.1.20/governance/policy-registry#seeded-policies) — the seeded registry policy * [Policy registry](/dist/docs/3.1.20/governance/policy-registry) — `requires_human_review` on policies and categories * [Campaign governance specification](/dist/docs/3.1.20/governance/campaign/specification) — `human_review_required` plan field * [Embedded human judgment](/dist/docs/3.1.20/governance/embedded-human-judgment) — the architectural principle behind mandatory oversight # Audit trail: internal vs shareable views Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/audit-trail How AdCP's campaign governance audit trail splits into a full internal view and scoped shareable views, and what each counterparty can safely see. The protocol does not mandate what a buyer must share with a counterparty. It does give you the primitives to produce two distinct views of the same audit trail: * An **internal view** — the buyer's full record of every check, finding, budget movement, and human review. Used for self-protection, regulator audits, and internal compliance review. * A **shareable view** — a scoped subset, addressed to one counterparty (a seller, an agency, a third-party auditor). Reveals only what that party needs to verify their own action. Both are produced from [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs). The split is a matter of filtering and field selection, not a separate protocol. ## Why split at all Three reasons buyers maintain distinct views: 1. **Strategy leakage.** Total authorized budget, channel allocation, and remaining headroom across all sellers are competitive signals. Note that derived ratios are not safer than raw amounts — `budget.utilization_pct` is a one-step inverse problem if a seller knows their own committed share. A single seller does not need to know the buyer's full plan to verify their own buy. 2. **Cross-counterparty isolation.** Seller A has no business seeing the findings, governance contexts, or outcomes for Seller B. A shareable view is always scoped to the requesting party's `governance_context` values. 3. **Internal review fields.** Human review reasons, drift metrics, and oversight thresholds are the buyer's own governance posture. They protect the buyer in a regulator audit; they are not addressed to a counterparty. ## Field tagging The table below classifies every field in the [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs#response) response by who can safely see it. | Field | Internal | Shareable with the specific seller | Shareable with regulator/auditor | | ------------------------------------------------------- | :------: | :--------------------------------: | :--------------------------------------------------------------------------: | | `plans[].plan_id`, `plan_version` | yes | yes (when scoped) | yes | | `plans[].status` | yes | yes (when scoped) | yes | | `plans[].budget.authorized` | yes | no | yes | | `plans[].budget.committed` | yes | no | yes | | `plans[].budget.remaining` | yes | no | yes | | `plans[].budget.utilization_pct` | yes | no | yes | | `plans[].channel_allocation.*` | yes | no | yes | | `plans[].governed_actions[].governance_context` | yes | yes (own context only) | yes | | `plans[].governed_actions[].purchase_type` | yes | yes (own context only) | yes | | `plans[].governed_actions[].status` | yes | yes (own context only) | yes | | `plans[].governed_actions[].committed` | yes | yes (own context only) | yes | | `plans[].governed_actions[].check_count` | yes | yes (own context only) | yes | | `plans[].summary.checks_performed`, `outcomes_reported` | yes | no | yes | | `plans[].summary.statuses` | yes | no | yes | | `plans[].summary.findings_count` | yes | no | yes | | `plans[].summary.human_reviews[]` | yes | no | redact reason; share resolution + timestamp on request | | `plans[].summary.drift_metrics.*` | yes | no | available if the buyer's counterparty rules require disclosure | | `plans[].entries[].id`, `type`, `timestamp` | yes | yes (own context only) | yes | | `plans[].entries[].caller` | yes | yes (own context only) | yes | | `plans[].entries[].tool` | yes | yes (own context only) | yes | | `plans[].entries[].check_type`, `verdict` | yes | yes (own context only) | yes | | `plans[].entries[].mode` | yes | yes (own context only) | yes — distinguishes a `denied` from an `approved`-with-finding under `audit` | | `plans[].entries[].explanation` | yes | yes (own context only) | yes | | `plans[].entries[].categories_evaluated` | yes | yes (own context only) | yes | | `plans[].entries[].policies_evaluated` | yes | yes (own context only) | yes | | `plans[].entries[].findings[]` | yes | yes (own context only) | yes | | `plans[].entries[].plan_hash` | yes | yes (own context only) | yes — auditors verify this | | `plans[].entries[].governance_context` | yes | yes (own context only) | yes | | `plans[].entries[].purchase_type` | yes | yes (own context only) | yes | | `plans[].entries[].outcome`, `outcome_status` | yes | yes (own context only) | yes | | `plans[].entries[].committed_budget` | yes | yes (own context only) | yes | ## Producing a shareable view To answer "what should I share with Seller X?", filter the audit query to the contexts that belong to that seller and drop plan-level aggregates. **Request scoped to one seller's actions:** ```json theme={null} { "tool": "get_plan_audit_logs", "arguments": { "plan_ids": ["plan_q1_2026_launch"], "governance_contexts": ["gc_mb_seller_456"], "include_entries": true } } ``` This narrows `governed_actions` and `entries` to the requested contexts. The buyer is still responsible for stripping `budget.*`, `channel_allocation.*`, `summary.findings_count`, `summary.statuses`, and `summary.drift_metrics` before forwarding — those summaries cover the entire plan and reveal totals across other sellers. **Minimum compliance attestation (no entries):** For counterparties who only need proof that a buy was governed — not the full trail — share a four-field attestation derived from the audit response: ```json theme={null} { "governance_context": "gc_mb_seller_456", "verdict": "approved", "plan_hash": "oR0jFDEtzcwgPbNf-Ofd_fZHYfAyD1TRbzGOFBVCG-c", "policies_evaluated": ["us_coppa", "alcohol_advertising"] } ``` The seller can verify the `plan_hash` against the plan revision they signed under, and the `policies_evaluated` list confirms which registry policies were applied. Nothing else about the buyer's portfolio is disclosed. ## Regulator and auditor views A regulator typically asks for one of three things: 1. **Did human oversight occur where required?** Share `summary.human_reviews[]` and the `requires_human_review` flag from the resolved policies. Reasons may be redacted or summarized; resolutions and timestamps should be intact. 2. **Did the buy comply with a specific regulation?** Share entries scoped by `policies_evaluated` containing the regulation's `policy_id` (e.g., `us_coppa`). Include `plan_hash` so the regulator can verify against the plan revision. 3. **Are oversight metrics within thresholds?** Share `summary.drift_metrics` and the policy-derived thresholds. A `human_review_rate` below `human_review_rate_min` is a signal worth explaining. The protocol does not define a regulator API. Counterparty rules govern the disclosure; the audit trail makes the disclosure possible. ## Worked example: a clean buy A plan with $500K authorized for an OLV/display campaign. The orchestrator runs an intent check on `get_products`, then an execution check on `create_media_buy` for $150K, then reports the outcome. The full internal audit response: ```json theme={null} { "$schema": "/schemas/3.1.20/governance/get-plan-audit-logs-response.json", "status": "completed", "plans": [ { "plan_id": "plan_q1_2027_acme", "plan_version": 1, "status": "active", "budget": { "authorized": 500000, "committed": 150000, "remaining": 350000, "utilization_pct": 30 }, "governed_actions": [ { "governance_context": "11ab64d0-2e20-4b62-8964-b024cfc98d36", "purchase_type": "media_buy", "status": "active", "committed": 150000, "check_count": 1 } ], "summary": { "checks_performed": 2, "outcomes_reported": 1, "statuses": { "approved": 2, "denied": 0, "conditions": 0 }, "findings_count": 0 }, "entries": [ { "id": "chk_378be2f1", "type": "check", "timestamp": "2027-01-15T14:32:41.008Z", "caller": "https://ads.seller-a.example", "tool": "create_media_buy", "check_type": "execution", "mode": "enforce", "purchase_type": "media_buy", "governance_context": "11ab64d0-2e20-4b62-8964-b024cfc98d36", "verdict": "approved", "explanation": "All governance checks passed.", "policies_evaluated": [], "categories_evaluated": ["delegation_authority"], "findings": [] }, { "id": "out_9b2c1f04", "type": "outcome", "timestamp": "2027-01-15T14:32:41.105Z", "outcome": "completed", "committed_budget": 150000, "purchase_type": "media_buy", "governance_context": "11ab64d0-2e20-4b62-8964-b024cfc98d36" } ] } ] } ``` The seller's shareable view of the same trail is the `governed_actions[]` entry and the `entries[]` filtered to `governance_context = 11ab64d0...`. Plan-level `budget.authorized`, `budget.remaining`, and the `summary` aggregate stay buyer-side. ## What violations look like Findings are how the audit trail communicates risk. Two patterns worth knowing: ### Security-shaped finding: an unauthorized seller The plan declares `approved_sellers: ["https://ads.seller-approved.example"]`. A different seller calls `check_governance` and gets denied: ```json theme={null} { "$schema": "/schemas/3.1.20/governance/check-governance-response.json", "check_id": "chk_0ac8d7de", "plan_id": "plan_2027_apex_athletic", "verdict": "denied", "explanation": "Denied: Caller https://ads.seller-rogue.example is not in the plan's approved sellers list.", "categories_evaluated": ["delegation_authority", "seller_compliance"], "policies_evaluated": [], "findings": [ { "category_id": "seller_compliance", "severity": "critical", "explanation": "Caller https://ads.seller-rogue.example is not in the plan's approved sellers list." } ] } ``` The corresponding plan summary records `statuses.denied: 1` and `findings_count: 1`. The unauthorized seller's `governance_context` is still recorded — the trail captures the attempt, not just the success. ### Coaching-shaped finding: an Annex III prerequisite is missing A plan with `policy_categories: ["fair_lending"]` (mortgage, consumer credit, etc.) auto-flips `human_review_required: true`. If the brand profile doesn't expose a contestation contact, the check fails closed with an actionable explanation: ```json theme={null} { "$schema": "/schemas/3.1.20/governance/check-governance-response.json", "check_id": "chk_8360a36d", "plan_id": "plan_2027_nova_mortgage", "verdict": "denied", "explanation": "Denied: Plan requires human review (Annex III / Art 22) but brand does not expose data_subject_contestation. Art 22(3) requires a discoverable contact point for the data subject to request human intervention, express their view, and contest the decision. Set brand.data_subject_contestation in brand.json.", "categories_evaluated": ["data_subject_contestation", "delegation_authority"], "policies_evaluated": [], "findings": [ { "category_id": "data_subject_contestation", "severity": "critical", "explanation": "Plan requires human review (Annex III / Art 22) but brand does not expose data_subject_contestation. Set brand.data_subject_contestation in brand.json." } ] } ``` The denial is also a coaching moment — the operator sees exactly what to fix. This is the shape good governance findings should take: a category, a severity, and a path forward. ## The operator's dial: enforce / advisory / audit `mode` is set on the plan via [`sync_plans`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans) and is the operator's primary lever. The same caller, same payload, same finding produces three different outcomes depending on mode. **`mode: "enforce"`** — the buy is blocked at the governance layer: ```json theme={null} { "$schema": "/schemas/3.1.20/governance/check-governance-response.json", "check_id": "chk_enforce_001", "plan_id": "plan_mode_enforce", "verdict": "denied", "explanation": "Denied: Caller https://ads.seller-rogue.example is not in the plan's approved sellers list.", "categories_evaluated": ["delegation_authority", "seller_compliance"], "policies_evaluated": [], "findings": [ { "category_id": "seller_compliance", "severity": "critical", "explanation": "Caller https://ads.seller-rogue.example is not in the plan's approved sellers list." } ] } ``` **`mode: "advisory"`** — the buy proceeds; the operator gets a critical finding to act on post-hoc: ```json theme={null} { "$schema": "/schemas/3.1.20/governance/check-governance-response.json", "check_id": "chk_advisory_001", "plan_id": "plan_mode_advisory", "verdict": "approved", "explanation": "Approved with 1 advisory finding(s).", "expires_at": "2027-01-15T15:32:41Z", "categories_evaluated": ["delegation_authority", "seller_compliance"], "policies_evaluated": [], "findings": [ { "category_id": "seller_compliance", "severity": "critical", "explanation": "Caller https://ads.seller-rogue.example is not in the plan's approved sellers list." } ] } ``` **`mode: "audit"`** — the buy proceeds silently; the finding is in the log for retrospective analysis: ```json theme={null} { "$schema": "/schemas/3.1.20/governance/check-governance-response.json", "check_id": "chk_audit_001", "plan_id": "plan_mode_audit", "verdict": "approved", "explanation": "All governance checks passed.", "expires_at": "2027-01-15T15:32:41Z", "categories_evaluated": ["delegation_authority", "seller_compliance"], "policies_evaluated": [], "findings": [ { "category_id": "seller_compliance", "severity": "critical", "explanation": "Caller https://ads.seller-rogue.example is not in the plan's approved sellers list." } ] } ``` The trade-off: | Mode | Velocity | Safety | When to use | | ---------- | --------------------------- | -------------------------------- | -------------------------------------------------------------- | | `enforce` | Slowest — checks block buys | Highest — violations cannot ship | Production for regulated brands; default for new operators | | `advisory` | Full velocity | Findings surface but don't block | Rolling out a new policy; observing behavior before tightening | | `audit` | Full velocity | Findings logged silently | Backstop telemetry; never the only governance posture | A plan can shift modes between revisions — the governance decisions in the audit trail tell the truth about how each historical buy was governed. The operating mode should be visible on every audit entry; today it appears on the per-`check_governance` response but not consistently on `get_plan_audit_logs` responses ([#3156](https://github.com/adcontextprotocol/adcp/issues/3156)). ## What the protocol guarantees Two invariants you can rely on when designing your shareable view: * **`plan_hash` is verifiable.** It is `base64url_no_pad(SHA-256(JCS(plan_payload)))` over the plan revision the check was evaluated against. Any party with the plan revision can recompute and byte-compare the digest. See [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit). * **Inline policies cannot relax registry policies.** A buyer's bespoke `policy` entries can only add restrictions on top of registry-sourced policies. So when a shareable view shows `policies_evaluated: ["us_coppa"]`, the counterparty can trust that the registry version of `us_coppa` was applied at its declared `enforcement` level — the buyer did not silently downgrade it. See [Policy Registry](/dist/docs/3.1.20/governance/policy-registry#policy-categories). ## Related * [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs) — request and response schema * [Campaign governance specification](/dist/docs/3.1.20/governance/campaign/specification) — plan binding, drift detection, governance context lifecycle * [Policy Registry](/dist/docs/3.1.20/governance/policy-registry) — policy versioning, `effective_date`, registry vs inline policies * [Annex III & Art 22 obligations](/dist/docs/3.1.20/governance/annex-iii-obligations) — when human review is required # Campaign Governance Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/index AdCP Campaign Governance validates autonomous media buys against authorized plans, budget limits, and compliance policies using three-party trust. # Campaign Governance Protocol Three-party governance: buyer orchestrator sends plan to governance agent, governance agent validates and returns approval, orchestrator sends buy to seller, seller verifies with governance agent. No party grades its own homework. Campaign Governance provides automated validation for buy-side advertising transactions. When AI agents buy media autonomously, Campaign Governance acts as an independent review layer -- validating every action against authorized plans, brand policies, budget limits, and compliance requirements. ## The problem An AI agent interprets a brief, picks a seller, negotiates a media buy, and takes it live -- all without a human watching. This is the "no eyes" problem: autonomous agents making real financial commitments with no independent check that the purchase matches what was authorized. The existing AdCP governance domains solve *where* ads run (Property Governance), *what content is adjacent* (Content Standards), *what creatives are safe* (Creative Governance), and *who the brand is* (Brand Protocol). None of them govern **what gets bought and why**. Without a governance layer on the buy side: * An agent could exceed authorized budgets or reallocate spend outside approved parameters * The agent could misinterpret a brief -- buying in New Mexico when the brief said Mexico * Targeting could drift in ways that violate brand policy or create discriminatory patterns * Seller responses could differ from what was requested, with no automated verification * Compliance policies are fragmented -- copy-pasted as natural language strings into every campaign plan, with no standard library and no separation between who defines policies and who executes campaigns Campaign Governance fills this gap with three mechanisms: **plans** that define what's allowed (independent of the agent), **seller-side governance checks** that verify purchases independently of the buyer, and a **policy registry** that standardizes compliance rules across governance agents. Governance supports incremental adoption — organizations configure their governance agent to start in audit mode (log everything, approve everything), move to advisory (flag violations without blocking), and eventually enforce (block on violations). See the [safety model](/dist/docs/3.1.20/governance/campaign/safety-model) for the adoption path. Mode is internal to the governance agent, not a protocol field. ### Industry precedent Campaign Governance formalizes patterns that exist in ad tech today as manual processes: | Manual process | Campaign Governance equivalent | | ----------------------------- | ------------------------------------------------ | | Agency trading desk QA | Automated validation against the IO | | DSP pre-bid rules | Budget authority and targeting compliance checks | | Advertiser approval workflows | Human escalation for high-risk actions | | Post-campaign audit | Seller verification and delivery validation | | Compliance review | Regulatory checks by jurisdiction | ## How it works The orchestrator pushes campaign plans via [`sync_plans`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans), then calls [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) with `tool` + `payload` before every action it sends to a seller. The seller independently calls `check_governance` with `governance_context` + `planned_delivery` before executing. After receiving the seller's response, the orchestrator calls [`report_plan_outcome`](/dist/docs/3.1.20/governance/campaign/tasks/report_plan_outcome) to close the loop. The governance agent tracks budget from confirmed outcomes -- not just attempted actions. ```mermaid theme={null} flowchart LR subgraph brand["Brand organization"] policy["Policy team"] buying["Buying team / Agency"] orchestrator["Orchestrator"] gov["Authorization /
Governance Agent"] policy -->|"configure compliance"| gov buying -->|"operate"| orchestrator orchestrator -->|"1. sync plan"| gov orchestrator -->|"2. check_governance
(tool + payload)"| gov orchestrator -->|"5. report outcome"| gov gov -->|"status / findings"| orchestrator end orchestrator -->|"3. create_media_buy
(plan_id)"| seller["Seller Agent"] seller -->|"4. check_governance
(governance_context + planned_delivery)"| gov seller -->|"confirmed + planned_delivery"| orchestrator ``` Three parties participate in campaign governance: * The **orchestrator** validates actions against the plan before sending them to sellers (buyer-side governance loop). * The **seller** independently checks purchases by calling the buyer's governance agent with its planned delivery parameters (seller-side governance check). * The **governance agent** validates both the orchestrator's intended actions and the seller's planned delivery against the same campaign plan. This creates a trust model where neither the buyer nor the seller can unilaterally misrepresent what was approved. The orchestrator cannot skip governance (the seller checks independently), and the seller cannot deliver something different from what was approved (the governance agent has a record of the planned delivery). ## Separation of duties Campaign Governance enforces an automated separation between **who defines policies** and **who executes campaigns**: * The **policy team** selects applicable compliance policies from the [policy registry](#policy-resolution), configures brand-specific rules, and maintains the brand's compliance profile. This configuration lives at the brand level (in brand.json), not in individual campaign plans. * The **buying team** (or agency) operates the orchestrator, creates campaign plans, and executes media buys. Plans specify campaign context -- budget, channels, flight dates, authorized markets -- and can reference additional registry policies or campaign-specific rules when needed. * The **governance agent** resolves applicable policies from the brand's compliance configuration and validates every orchestrator action against them. The orchestrator cannot bypass or modify the brand's compliance policies -- those are resolved from the brand configuration by the governance agent. Plans can layer on additional policies via `policy_ids` and `custom_policies`, but the brand's baseline always applies. When a regulation changes, the policy team updates the brand configuration once and all active campaigns pick up the change automatically. ## Policy resolution The governance agent resolves applicable policies through the brand reference in the plan: 1. The plan includes `brand.domain` (required) and optionally `countries`/`regions` (authorized markets for this campaign) 2. The governance agent resolves the brand via the [Brand Protocol](/dist/docs/3.1.20/brand-protocol/index) and retrieves its compliance configuration 3. The brand configuration references standardized policies from the **policy registry** by ID and includes any custom brand-specific policies 4. The governance agent intersects these policies with the plan's authorized markets -- only policies applicable to those markets are active for this plan 5. The resolved policy set is what gets evaluated during `check_governance` 6. Media buys targeting outside the authorized markets are denied regardless of policy compliance The **[policy registry](/dist/docs/3.1.20/governance/policy-registry)** is a community-maintained library of standardized, machine-readable advertising compliance policies -- covering jurisdictions (UK HFSS restrictions, US COPPA, EU GDPR), policy categories (children directed, pharmaceutical, fair housing, fair employment), and brand safety baselines. Brands select applicable policies from the registry rather than writing their own. ## The governance loop Every seller interaction follows a before/after pattern: 1. **Before**: The orchestrator calls `check_governance` with `tool` and `payload` — the action it intends to send. The governance agent returns a status. 2. **Execute**: If approved, the orchestrator sends the action to the seller, including `plan_id` and `governance_context`. 3. **Check**: If the account has `governance_agents`, the seller calls `check_governance` with `governance_context` and `planned_delivery` — what it will actually deliver. The governance agent approves or denies. 4. **After**: The orchestrator calls `report_plan_outcome` with the seller's response. The governance agent updates its state and flags any discrepancies. This pattern applies to discovery (`get_products`), purchase (`create_media_buy`, `update_media_buy`), and periodic delivery reporting. ## Planned delivery When a seller confirms a media buy, it returns a `planned_delivery` object describing what it will actually deliver -- the geographic targeting, channels, flight dates, frequency caps, and budget it will use. This may differ from what the buyer requested (e.g., the seller may apply additional frequency caps or adjust geo to match available inventory). `planned_delivery` serves two purposes: 1. **Governance check**: The seller sends `planned_delivery` to the buyer's governance agent, which confirms it matches the campaign plan. This prevents the seller from delivering something the buyer didn't approve. 2. **Discrepancy detection**: The buyer can compare `planned_delivery` against the original request and flag differences via `report_plan_outcome`, catching configuration drift before delivery begins. ## Seller-side governance checks Buyer-side governance has a trust limitation: the orchestrator attests to its own compliance. An LLM agent could hallucinate governance approval, skip validation, or misrepresent what was validated. You can't trust the agent that's spending money to also be the one that checks whether spending that money is OK. Seller-side governance checks solve this. The buyer syncs governance agents (URLs with credentials) via [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance). When the seller receives a `create_media_buy`, it calls the governance agent with what it plans to deliver. The governance agent checks against the plan and returns `approved`, `denied`, or `conditions`. This also catches misinterpretation. If the brief says "Mexico" and the seller interprets it as "New Mexico," the governance agent sees the geo mismatch against the plan and denies the buy -- before it goes live. The webhook covers the full media buy lifecycle: * **Purchase**: POST before confirming `create_media_buy` -- is this buy approved? * **Modification**: POST before confirming `update_media_buy` -- is this change OK? * **Delivery**: POST periodically during delivery -- is delivery still on track? The governance agent maintains all state. The seller just posts what's happening -- no chaining, no conversation history, no state to track across servers. Governance checks are optional at every phase. Sellers can start with purchase-only (one POST per buy) and add modification and delivery checks incrementally. See the [specification](/dist/docs/3.1.20/governance/campaign/specification#governance-checks) for the full protocol. Sellers that implement governance checks gain a competitive advantage: they can prove to buyers that purchases were independently verified before execution. This reduces dispute risk, automates compliance verification, and signals trust to buyers with strict oversight requirements. ## Adoption path Governance agents support incremental adoption through their internal mode configuration. The protocol surface is the same regardless of mode — callers always receive `approved`, `denied`, or `conditions` and act on the status. How the governance agent arrives at that decision is its own concern. In practice, organizations configure their governance agent to progress through three stages: 1. **Audit** — Log everything, approve everything. The governance agent evaluates fully but always returns `approved` with findings attached. The organization reviews findings to assess false positive rates and calibrate policies. 2. **Advisory** — Return real statuses (`denied`, `conditions`) but the organization treats denials as non-blocking. Humans review findings post-hoc. 3. **Enforce** — Block on violations. The default for production governance. This works the same way for a single brand buying direct and for a holding company with 35 brands and multiple agency partners. ### For small brands A brand buying direct (no agency, no policy team) still gets: * Automated budget limits and geo enforcement from the campaign plan * Compliance coverage from the [policy registry](/dist/docs/3.1.20/governance/policy-registry) -- registry policies are community-maintained, no per-brand configuration required * Seller-side verification via governance checks * Full audit trail via `get_plan_audit_logs` Set a `budget.reallocation_threshold` to define guardrails on how much the agent may reallocate without human approval. The governance agent handles the rest. ### For multi-agency and holding company setups Plans support [delegations](/dist/docs/3.1.20/governance/campaign/specification#delegations) that scope which agents can execute against a plan -- by authority level, budget limit, market, and expiration. A brand can delegate `full` authority to one agency for European markets and `execute_only` authority to another for North America. For holding companies managing multiple brands, [portfolio governance](/dist/docs/3.1.20/governance/campaign/specification#portfolio-governance) defines cross-brand constraints: total portfolio spend caps, shared policy enforcement, and corporate-level exclusions that no individual brand plan can override. ### Finding confidence Governance findings include optional [confidence scores](/dist/docs/3.1.20/governance/campaign/specification#finding-confidence) (0-1) that distinguish "this definitely violates GDPR" (0.95) from "this might violate depending on how audience segments resolve" (0.6). This helps brands respond appropriately -- high-confidence findings can be auto-resolved, medium-confidence findings get flagged for human review. ### Drift detection The [audit log](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs) includes aggregate metrics -- human review rate, auto-approval rate, human override rate -- with trend indicators. A declining human review rate may mean the system is well-calibrated or that oversight is eroding. Surfacing the trend lets the organization make that call. ## Lifecycle phases Campaign Governance is stateful across three phases: | Phase | When | What gets validated | | ------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | | **Discovery** | Before `get_products` | Search intent matches plan, products from authorized sellers, prices reasonable | | **Purchase** | Before `create_media_buy` / `update_media_buy` | Budget within limits, targeting compliant, flight dates match, creative assignments appropriate | | **Delivery** | Periodic reporting | Pacing within parameters, spend rate consistent, delivery metrics on track | Each phase builds on context from earlier phases. During **purchase**, the governance agent knows which products were discovered (and approved) during **discovery**. During **delivery**, it monitors execution against what was purchased. ## Validation categories | Category | What it checks | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `budget_authority` | Spend within authorized limits, per-seller concentration, reallocation magnitude | | `strategic_alignment` | Channel mix, audience match, publisher quality tier, brief consistency | | `bias_fairness` | Protected category targeting, audience composition, disparate impact. Matches audience selectors against `restricted_attributes` defined by the plan's `policy_categories` | | `regulatory_compliance` | Jurisdiction-specific regulations resolved from the brand's compliance configuration and the plan's authorized countries/regions | | `seller_verification` | Configuration accuracy, undisclosed changes, delivery plausibility | | `brand_policy` | Brand-level compliance policies resolved from the brand configuration and policy registry -- competitor separation, category adjacency, custom brand rules | Governance agents declare which categories they evaluate via `get_adcp_capabilities`. ## Statuses Every governance check returns a structured status: | Status | Meaning | Caller action | | ------------ | -------------------------------- | -------------------------------------------- | | `approved` | Passes all checks | Proceed | | `denied` | Violates a hard policy | Do not proceed; report to user | | `conditions` | Could pass with specific changes | Apply conditions, re-call `check_governance` | Finding severity levels indicate urgency: * **`info`** -- Logged for audit. Used on `approved` responses for informational findings. * **`warning`** -- Human should review. Used on `approved` responses when action is allowed but attention is needed. * **`critical`** -- Action is blocked. Used on `denied` responses. When the governance agent determines that human review is required (e.g., a reallocation exceeds the plan's `reallocation_threshold`, or the plan has `human_review_required: true`), the task goes async — it returns standard async task lifecycle statuses and eventually resolves to `approved` or `denied` once the human acts. This integrates with the existing AdCP async task mechanism. The caller does not need special handling beyond supporting async tasks (see [task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle)). ## Relationship to other governance domains Campaign Governance composes with the existing domains -- it does not duplicate them: | Domain | Relationship | | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **[Property Governance](/dist/docs/3.1.20/governance/property/index)** | Campaign Governance validates that the orchestrator *uses* property lists correctly (e.g., not bypassing them). Property Governance provides the lists. | | **[Content Standards](/dist/docs/3.1.20/governance/content-standards/index)** | Campaign Governance validates that content standards references are included in media buys. Content Standards handles the actual content evaluation. | | **[Creative Governance](/dist/docs/3.1.20/governance/creative/index)** | Campaign Governance validates that creative assignments match format requirements. Creative Governance scans the creatives themselves. | | **[Brand Protocol](/dist/docs/3.1.20/brand-protocol/index)** | Campaign Governance resolves the brand's compliance configuration via the Brand Protocol. The brand's policy team configures compliance policies in brand.json; the governance agent applies them automatically. | ## Next steps What buyer-side orchestrators, sellers, and governance agents each check and carry. How the three-party trust model, separation of duties, and structural controls make agentic advertising safe. Data models, validation logic, capability declaration, and orchestrator integration patterns. Task reference: `sync_plans`, `check_governance`, `report_plan_outcome`, and `get_plan_audit_logs`. # Safety Model Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/safety-model AdCP three-party trust prevents any single AI agent from making unilateral media buying decisions. Orchestrator, governance, and seller validate independently. # Why agentic advertising is safe Autonomous AI agents buying media raises a legitimate question: how do you trust software to spend real money on your behalf? Campaign Governance answers this with structural controls -- not by trusting any single agent, but by making it impossible for any single party to act unilaterally. ## Three-party trust model Campaign Governance distributes validation across three independent parties: ```mermaid theme={null} flowchart LR O[Orchestrator] -->|"1. intent check"| G[Governance Agent] G -->|"approved/denied"| O O -->|"2. create_media_buy"| S[Seller] S -->|"3. execution check"| G G -->|"approved/denied"| S S -->|"4. confirmed"| O O -->|"5. report outcome"| G ``` 1. The **orchestrator** checks its intended action against the plan before sending it to any seller (intent check: `tool` + `payload`) 2. The **seller** independently checks its planned delivery against the same plan before executing (execution check: `governance_context` + `planned_delivery`) 3. The **governance agent** validates both sides against the campaign plan, maintaining state across the full lifecycle No party grades its own homework. The orchestrator cannot skip governance because the seller checks independently. The seller cannot deliver something different from what was approved because the governance agent has a record of the planned delivery. ## Verifiable approvals Every governance approval is cryptographically signed by the governance agent. The approval token rides along with the purchase request; sellers check the signature before committing, and **regulators or auditors can verify the same token independently, without cooperating with the governance vendor that issued it**. That independence is the whole point. A design where the only way to reconstruct the approval trail is to subpoena the vendor that issued it is not an audit trail — it's a vendor dependency. Signed tokens turn the audit trail into something a data subject, a regulator, or an opposing counsel can examine on their own. The signed token also binds the approval to a specific plan, a specific seller, a specific phase of the media buy, and a unique transaction identifier. A token approving a $500K flight for Seller A cannot be silently reused to authorize a $500K flight at Seller B, nor can an approval for Q1 be replayed in Q3. See [Signed Governance Context](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context) for the implementer-facing profile (claim set, key discovery, revocation, verification rules). ## Separation of duties Three roles with non-overlapping responsibilities: | Role | Responsibility | Cannot do | | -------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------- | | **Policy team** | Configure compliance policies, select registry policies, define brand rules | Execute campaigns or spend budget | | **Buying team** | Create plans, operate orchestrator, execute media buys | Modify compliance policies or bypass governance | | **Governance agent** | Validate actions against plans and policies, track budget, escalate violations | Initiate spending or modify plans | The orchestrator cannot bypass compliance because it does not carry the policies -- they are resolved from the brand's configuration by the governance agent. When a regulation changes, the policy team updates the configuration once and all active campaigns pick up the change automatically. ## Crawl-walk-run adoption Governance agents support three internal operating modes so organizations can build confidence incrementally. Mode is configured on the governance agent itself — it is not a protocol field, and callers act on the status they receive regardless of mode. | Mode | What happens | Risk | | ------------ | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | **Audit** | Log everything, never block. Always returns `approved` with findings attached. | Zero. See what governance would flag without affecting live campaigns. | | **Advisory** | Return real statuses (`denied`, `conditions`) but the organization treats denials as non-blocking. | Minimal. Humans review findings post-hoc and act on them. | | **Enforce** | Block on violations. Require resolution before proceeding. | Production governance with full protection. | Start in audit mode to evaluate false positive rates and calibrate policies. Move to advisory to test findings with real campaigns. Switch to enforce when confidence is established. The governance agent's audit logs record which mode was active for each check, so post-hoc analysis can distinguish audit-mode approvals from enforce-mode approvals. ## Budget protection Budget is committed based on confirmed outcomes, not intended actions: 1. `check_governance` with `tool` + `payload` (intent check) checks whether the spend fits the plan. No budget is committed. 2. The orchestrator sends the action to the seller. 3. `report_plan_outcome` reports the seller's confirmed amount. Only then is budget committed. If a seller reduces the amount, the governance agent commits the actual amount and flags the discrepancy. If the action fails, the governance agent commits zero. Budget state reflects reality, not intent. Concurrent media buys are handled through optimistic concurrency control or budget reservation, preventing concurrent approvals that together exceed the plan budget. ## Two dimensions of autonomy Campaign Governance separates agent autonomy into two independent dimensions. Both are evaluated on every action; neither overrides the other. | Field | Dimension | What it controls | | ------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `budget.reallocation_threshold` | Operational | Maximum reallocation the agent can execute without escalation. `0` requires approval for every reallocation; a value at or above `budget.total` is effectively unlimited. | | `plan.human_review_required` | Regulatory | Whether decisions affecting individuals require human review before execution | `reallocation_threshold` is about money movement: how much can the agent shift between sellers or channels without asking? `human_review_required` is about the nature of the decision: does the decision fall under a regime (GDPR Art 22 automated decision-making, EU AI Act Annex III use cases, fair housing/lending/employment) that legally requires a human in the loop? The governance agent sets `human_review_required: true` automatically when resolved policies or policy\_categories carry `requires_human_review: true`. Annex III use cases and Art 22-triggering verticals flip the flag regardless of how permissive `reallocation_threshold` is. A plan with unlimited reallocation can still require human review on every action if the underlying vertical demands it. This separation means an organization can grant broad reallocation autonomy for operational efficiency while preserving non-negotiable human review on the categories of decisions where human judgment is the regulatory requirement. ## Confidence and explainability Governance findings include confidence scores (0 to 1) and explanations that distinguish certain violations from ambiguous ones: * **High confidence (0.9+)**: Definitive violation. A GDPR breach on a campaign explicitly targeting EU users. * **Medium confidence (0.6-0.9)**: Depends on context the governance agent cannot fully resolve. Audience segments that may include minors, geo targeting that partially overlaps regulated jurisdictions. * **Low confidence (below 0.6)**: Speculative. Flagged for human review rather than acted on autonomously. Every finding includes a human-readable `explanation` and structured `details` for programmatic consumption. When human review is triggered internally, the governance agent records the reason, severity, and resolution in its audit logs. Nothing is a black box. ## Drift detection The audit log surfaces aggregate metrics that detect oversight erosion over time: * **Human review rate** -- fraction of checks that required internal human review, with trend direction * **Auto-approval rate** -- fraction of checks approved without human intervention * **Human override rate** -- fraction of human reviews where the human disagreed with the governance agent Organizations set thresholds on these metrics. When a threshold is breached, the governance agent includes a finding on the next check. A declining human review rate may mean well-calibrated governance or eroding oversight -- the threshold breach surfaces the question so the organization can decide. ## Multi-brand and agency governance For holding companies with multiple brands and agency partners: * **Delegations** scope which agents can act on a plan, by authority level, budget limit, market, and expiration. A brand can grant `full` authority to one agency for Europe and `execute_only` to another for North America. * **Portfolio governance** defines cross-brand constraints: total portfolio spend caps, shared policy enforcement, and corporate-level exclusions that no individual brand plan can override. ## For small brands A brand buying direct with no agency and no policy team still gets: * Automated budget limits and geo enforcement from the campaign plan * Compliance coverage from the [policy registry](/dist/docs/3.1.20/governance/policy-registry) -- community-maintained, no per-brand configuration required * Seller-side verification via governance checks * Full audit trail via `get_plan_audit_logs` Set a [`reallocation_threshold`](/dist/docs/3.1.20/governance/campaign/specification#budget-reallocation) on the budget to define guardrails. The governance agent handles the rest. ## Comparison to manual processes | Manual process | Campaign Governance equivalent | | ----------------------------- | ------------------------------------------------ | | Agency trading desk QA | Automated validation against the plan | | DSP pre-bid rules | Budget authority and targeting compliance checks | | Advertiser approval workflows | Human review for high-risk actions | | Post-campaign audit | `get_plan_audit_logs` with drift metrics | | Compliance review | Policy registry + jurisdiction-scoped validation | The difference is that Campaign Governance applies these controls to every transaction, not just the ones that happen to get reviewed. Manual processes are sampling-based and retrospective. Campaign Governance is exhaustive and real-time. # Specification Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/specification Formal specification for AdCP campaign governance — plan schemas, budget authority models, validation logic, and integration patterns. # Campaign Governance specification **Experimental.** Campaign governance is part of AdCP 3.0 as an experimental surface — it may change between 3.x releases with at least 6 weeks' notice. Sellers implementing it MUST declare `governance.campaign` in `experimental_features`. See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full contract. **Status**: Request for Comments **Last Updated**: March 2026 The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). This document defines the data models, validation logic, and integration patterns for Campaign Governance. ## Campaign plan The campaign plan is the source of truth for all validation. Plans are pushed to the governance agent via [`sync_plans`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans) and define the plan parameters for a campaign -- budget limits, channels, flight dates, and plan markets. The governance agent resolves applicable policies from the brand's compliance configuration. Plans can also reference registry policies directly via `policy_ids` and include campaign-specific rules via `custom_policies`. ```json theme={null} { "plan_id": "plan_q1_2026_launch", "brand": { "domain": "acmecorp.com" }, "objectives": "Drive awareness for spring product launch among 25-54 adults in the US, focusing on premium video and high-impact display.", "budget": { "total": 500000, "currency": "USD", "reallocation_threshold": 25000, "per_seller_max_pct": 40 }, "channels": { "required": ["olv"], "allowed": ["olv", "display", "ctv", "audio"], "mix_targets": { "olv": { "min_pct": 40, "max_pct": 70 }, "display": { "min_pct": 10, "max_pct": 30 }, "ctv": { "min_pct": 0, "max_pct": 20 }, "audio": { "min_pct": 0, "max_pct": 10 } } }, "flight": { "start": "2026-03-15T00:00:00Z", "end": "2026-06-15T00:00:00Z" }, "countries": ["US"], "policy_ids": ["us_coppa", "alcohol_advertising"], "custom_policies": [ { "policy_id": "no_competitor_adjacency", "enforcement": "must", "policy": "No advertising adjacent to competitor brand content." } ], "approved_sellers": null, "ext": {} } ``` ### Purchase types Governance plans govern all financial commitments, not just media buys. The `purchase_type` field on `check_governance` identifies which kind of commitment is being validated: | Purchase type | Tool | What's governed | | --------------------- | -------------------------------------- | --------------------------- | | `media_buy` (default) | `create_media_buy`, `update_media_buy` | Media inventory purchases | | `rights_license` | `acquire_rights`, `update_rights` | Brand rights licensing fees | | `signal_activation` | `activate_signal` | Data signal activation fees | | `creative_services` | `build_creative` | Creative generation fees | All purchase types share the same governance loop: `sync_plans` → `check_governance` → execute → `report_plan_outcome`. The governance agent validates budget authority, geo compliance, and flight compliance across all types. Media-buy-specific validations (channel compliance, seller concentration, delivery pacing) apply only when `purchase_type` is `media_buy` or when the payload contains the relevant fields. When `purchase_type` is omitted, the governance agent assumes `media_buy`. **Future purchase types**: Content standards, property list curation, and measurement/verification services (brand lift studies, viewability, fraud detection) all carry `pricing_options` in their schemas and bill through `report_usage`. These services currently lack an explicit activation tool where the buyer commits to the service — the billing relationship is implicit. When the protocol adds activation surfaces for these services, corresponding purchase types will be added to enable governance checks at the point of commitment. ### Budget reallocation `budget.reallocation_threshold` (required number) governs budget reallocation autonomy. It does not cover mandatory human review of decisions that affect data subjects — for that, see the plan-level `human_review_required` field. | Value | Meaning | | ------------------------------------ | ------------------------------------------------------------------------------------------------- | | `0` | Every reallocation requires human approval | | positive number below `budget.total` | Agent may reallocate up to this amount without escalation; larger moves escalate for human review | | equal to `budget.total` (or greater) | Agent may reallocate freely within the plan's total budget | ### Budget allocations Plans can optionally partition the total budget across purchase types using `allocations`: ```json theme={null} { "budget": { "total": 500000, "currency": "USD", "reallocation_threshold": 25000, "allocations": { "media_buy": { "amount": 400000 }, "rights_license": { "amount": 75000 }, "signal_activation": { "amount": 25000 } } } } ``` When `allocations` is present, the governance agent validates spend against both the per-type allocation and the overall total. When absent, all spend counts against the single total regardless of purchase type. Allocations are guardrails, not hard partitions — the sum of allocations MAY differ from the total. When `allocations` is present but a purchase type is not listed (e.g., `signal_activation` is attempted against a plan that only allocates for `media_buy` and `rights_license`), the governance agent validates the action against the plan's total budget only. Unlisted types are not denied — they draw from the shared pool. To restrict spending to listed types only, set `custom_policies` with explicit constraints. ### Human review required `human_review_required` is a plan-level boolean (default `false`) that mandates human oversight of every action on the plan, independent of budget reallocation autonomy. The governance agent sets `human_review_required: true` automatically when any resolved policy or policy\_category on the plan carries `requires_human_review: true`. This includes regulated verticals such as `fair_housing`, `fair_lending`, `fair_employment`, and `pharmaceutical_advertising`, and the `eu_ai_act_annex_iii` policy covering decisions in Annex III use cases. When `human_review_required` is true, the governance agent MUST escalate any action on the plan for human review before execution — regardless of the plan's `reallocation_threshold`. A permissive reallocation threshold does not bypass human review when the plan carries `human_review_required: true`; the two dimensions compose. This field is distinct from `budget.reallocation_threshold`: | Field | Scope | Purpose | | ------------------------------- | ----------- | ------------------------------------------------------------------------------------------------- | | `budget.reallocation_threshold` | Operational | Controls agent autonomy for budget reallocation within the plan | | `human_review_required` | Regulatory | Mandates human review of decisions affecting individuals (e.g., GDPR Art 22, EU AI Act Annex III) | Callers MAY set `human_review_required: true` explicitly on a plan even when no triggering policy is present. Callers MUST NOT set it to `false` to override a policy that requires human review — the governance agent re-evaluates the flag from resolved policies on every sync and overrides a caller-supplied `false` when a triggering policy is present. ### Spend-commit invocation Buyer-side governance invocation is enforceable, not advisory. When a governance agent is configured on the plan, the buyer agent MUST invoke [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) before sending any spend-commit request to a seller — full stop — producing an **intent-phase** `governance_context` token to attach to that request. The governance agent decides internally whether to auto-approve, apply conditions, deny, or escalate to human review per the plan's `budget.reallocation_threshold` and `human_review_required` fields. No dollar figures, no baseline arithmetic, no operator-declared floors appear in the invocation rule — those auto-approve fast-paths belong inside the governance agent's own policy, not in the buyer's decision about whether to call at all. #### Spend-commit tasks The invocation MUST applies to every AdCP task that attaches financial obligation at the moment of the request: * [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) — committed budget across packages * [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) — incremental commit delta (new budget − previously committed) * [`acquire_rights`](/dist/docs/3.1.20/brand-protocol/tasks/acquire_rights) — rights pricing * [`update_rights`](/dist/docs/3.1.20/brand-protocol/tasks/update_rights) — incremental commit delta * [`activate_signal`](/dist/docs/3.1.20/signals/tasks/activate_signal) — activation fees * [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative) — creative generation fees * Any future spend-commit task Invocation is NOT required for discovery tasks (e.g., `get_products`, `get_signals`), reporting tasks (e.g., `get_media_buy_delivery`), or operational-status tasks. The MUST fires specifically at the moment of financial obligation. When no governance agent is configured on the plan, `check_governance` invocation is neither required nor meaningful — there is nothing to call. Sellers MAY refuse to transact on plans lacking a configured governance agent as a matter of their own commercial policy (enterprise sellers typically will); the protocol does not mandate one, and the brand's published configuration is how a seller discovers whether one exists. Orchestrators fanning out the same plan to multiple sellers produce one intent token per seller, because `aud` is bound byte-for-byte to the target seller. This is the correct protocol shape, but it means the governance agent sees the buyer's full shopping list on a plan. Operators who treat shopping intent as commercially sensitive SHOULD choose governance agents whose data-handling posture they trust. #### Seller enforcement A seller receiving a spend-commit request for a plan with a configured governance agent MUST require a valid, in-date **intent-phase** `governance_context` token on the request, verified per the [seller verification checklist](/dist/docs/3.1.20/building/by-layer/L1/security#seller-verification-checklist). The token MUST carry `phase: "intent"`, match the request's `plan_id` (via `sub`), and be addressed (`aud`) to this seller. A request without a token, with a token that fails verification, or with a token issued for a different plan, a different seller, or a non-intent phase MUST be rejected with `PERMISSION_DENIED`. The seller then performs its own execution check by calling `check_governance` with `planned_delivery` and the received `governance_context`; that call produces the `purchase`-phase token (bound to the seller-assigned `media_buy_id`) used for the remainder of the media buy lifecycle. This two-step flow is what makes the buyer-side MUST real: a buyer that skips `check_governance` cannot produce a valid intent token, and the spend-commit is rejected before the seller ever reaches its execution check. Sellers MUST persist the accepted intent token and any lifecycle tokens they subsequently hold, keyed at minimum by `jti` with `iss`, `aud`, `sub` (plan\_id), `phase`, decision outcome, and the timestamp of acceptance. Retention follows the seller's regulatory retention period. Without seller-side retention, the audit log is single-sourced from the governance agent; independent reconciliation between seller records and `get_plan_audit_logs` is the cross-check that catches a compromised or misbehaving governance agent. Seller-side governance (if the seller itself has configured a governance agent on the account) is an **independent layer**. A buyer's successful `check_governance` does not obligate the seller to accept the request; the seller's own compliance policies MAY still reject the action via `PERMISSION_DENIED`. An approved token carries an `exp` that is authoritative at verification time; a policy change inside the token's validity window is accepted residual risk of any signed-decision system. Tight `exp` values (intent tokens SHOULD expire within 15 minutes per the JWS profile) bound the window rather than closing it. Operators who cannot tolerate the window MUST set `reallocation_threshold` to `0` or `human_review_required: true` so every action goes through an internal human review regardless of caching. #### Audit logging Every `check_governance` invocation MUST produce an audit log entry — retrievable via [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs) — capturing: 1. Invocation timestamp as a timezone-offset ISO 8601 string 2. Tool being validated (`create_media_buy`, `acquire_rights`, etc.) and commit amount in the plan's currency 3. Outcome (`approved`, `denied`, or `conditions`; and whether human review was invoked internally) 4. Human actor identity and authority, when a human signal was recorded 5. `check_id` for cross-reference from the downstream spend-commit task's audit entry and from `report_plan_outcome` The buyer-side intent check and the seller-side execution check each produce a distinct `check_id`; `report_plan_outcome` correlates via `plan_id` rather than a single check identifier. Auditors reconstructing a spend-commit reconcile the buyer-side entry, the seller-side entry, and the seller's persisted token record. #### Interaction with idempotency * **Retry of an identical payload carrying the prior intent-phase `governance_context`:** no re-invocation. The cached governance response is reused; the token signature and freshness are re-validated. Seller-side replay-dedup keys (see the [verification checklist](/dist/docs/3.1.20/building/by-layer/L1/security#seller-verification-checklist)) MUST treat a repeated `jti` carrying the same `idempotency_key` as a legitimate retry rather than a replay attack — this is the one narrow carve-out. * **Re-plan with a different payload (new `idempotency_key` per [Idempotency](/dist/docs/3.1.20/building/by-layer/L1/security#idempotency)):** a fresh `check_governance` invocation is required. A new `governance_context` token is issued. A retry after a human approval does not re-invoke governance; the existing token remains the authorization until it expires. Post-execution lifecycle retries operate against the seller's `purchase`-phase token, which is a distinct artifact governed by the seller's execution check, not the buyer's intent check. ### Channel mix targets The `mix_targets` field defines acceptable allocation ranges. The governance agent validates that aggregate spend across all media buys stays within these ranges. A `create_media_buy` that would push video spend above 70% of total budget triggers a `conditions` or `denied` status. ### Delegations Plans can include a `delegations` array that specifies which agents are authorized to execute against the plan and with what constraints. This makes the brand–agency delegation relationship explicit in the protocol. ```json theme={null} { "plan_id": "plan_q1_2026_launch", "brand": { "domain": "acmecorp.com" }, "delegations": [ { "agent_url": "https://buying.pinnacle-media.com", "authority": "full", "budget_limit": { "amount": 300000, "currency": "USD" }, "markets": ["FR", "DE", "GB"], "expires_at": "2026-06-30T00:00:00Z" }, { "agent_url": "https://buying.nova-agency.com", "authority": "execute_only", "markets": ["US"], "expires_at": "2026-06-30T00:00:00Z" } ] } ``` Authority levels: | Level | Meaning | | -------------- | --------------------------------------------------------------------------------------- | | `full` | Can execute any action within the delegation's budget and market constraints | | `execute_only` | Can execute pre-approved actions but cannot initiate new campaigns or reallocate budget | | `propose_only` | Can propose actions for governance review but cannot execute without explicit approval | When delegations are present, the governance agent validates that the `caller` URL in `check_governance` matches a delegation's `agent_url` before approving actions. Matching is by exact URI comparison (case-sensitive, after normalization per RFC 3986). An agent requesting a media buy in France must have a delegation that includes France in its `markets`. An agent with `execute_only` authority cannot reallocate budget between channels. When delegations are absent, the governance agent does not restrict which agents can act on the plan. `delegations.authority` governs what a delegated executor-agent can do on behalf of the plan. It is unrelated to the plan's budget autonomy (`budget.reallocation_threshold` / `budget.reallocation_unlimited`) and unrelated to `plan.human_review_required`. Three separate concerns: per-agent scope, budget ops, and per-decision review. ### Portfolio governance For holding companies and multi-brand organizations, a plan can include a `portfolio` object that defines cross-brand constraints. Portfolio plans govern member plans -- any action validated against a member plan is also validated against the portfolio plan's constraints. ```json theme={null} { "plan_id": "portfolio_q1_2026_global", "brand": { "domain": "acmecorp.com" }, "objectives": "Global Q1 media governance across all Acme brands", "budget": { "total": 50000000, "currency": "USD", "reallocation_threshold": 2000000 }, "flight": { "start": "2026-01-01T00:00:00Z", "end": "2026-06-30T00:00:00Z" }, "countries": ["US", "GB", "FR", "DE", "JP"], "portfolio": { "member_plan_ids": ["plan_sparkle_q1", "plan_glow_q1", "plan_nova_q1"], "total_budget_cap": { "amount": 50000000, "currency": "USD" }, "shared_policy_ids": ["eu_gdpr_advertising", "eu_ai_act_article_50"], "shared_exclusions": [ { "policy_id": "no_competitor_properties", "enforcement": "must", "policy": "No advertising on properties owned by competitor holding companies." } ] } } ``` Portfolio constraints: * **`total_budget_cap`**: Maximum aggregate spend across all member plans. The governance agent tracks committed budget across all member plans and denies actions that would exceed the cap. * **`shared_policy_ids`**: Registry policies enforced across all member plans, regardless of individual brand compliance configuration. Corporate-level regulations that no brand team can override. * **`shared_exclusions`**: Bespoke exclusion policies applied to all member plans, using the `PolicyEntry` shape. Additive only — same constraint as plan-level `custom_policies`. The governance agent validates member plan actions against both the member plan's own constraints and the portfolio plan's constraints. A denial from either level blocks the action. When a portfolio plan references a `member_plan_id` that the governance agent does not yet recognize, the governance agent SHOULD accept the portfolio plan and begin enforcing portfolio constraints as member plans are synced. This allows portfolio plans to be synced before their member plans without requiring a specific ordering. **Concurrency**: An orchestrator may send `create_media_buy` requests to multiple sellers simultaneously, each triggering a `committed` check. Budget checks are point-in-time and do not reserve budget, so concurrent approvals may together exceed the plan budget. The governance agent detects overspend at outcome reporting time. To prevent concurrent overspend, use [delegations](#delegations) with per-agent `budget_limit` to partition the budget across executing agents. ### Aggregated-spend evaluation (fragmentation defense) Governance thresholds (`reallocation_threshold`, `human_review_required` trigger points, registry-policy dollar floors) MUST be evaluated against **aggregated committed spend** over a trailing window, not per-plan or per-media-buy in isolation. A buyer that fragments a \$999,900 intended spend into 100 × \$9,999 buys — each individually below an operator's \$10,000 human-review threshold — would otherwise bypass review entirely. This is a fragmentation attack on governance, not a legitimate usage pattern, and the governance agent MUST close it. Governance agents MUST aggregate committed spend across all of the following when evaluating any threshold: * All plans attributable to the same `(buyer_agent, seller_agent, account_id)` tuple — fragmentation across plans on the same account does not reset the aggregate. Delegated sub-agents (see [Delegations](#delegations)) share the delegating buyer's aggregate: the `buyer_agent` element of the key is the delegating principal, not the sub-agent's `agent_url`. A delegation does NOT mint a fresh per-agent aggregation window, because otherwise the delegation surface itself reopens the fragmentation hole (\$999,900 split across 100 sub-agents, each getting their own \$9,999 budget). * Every [spend-commit task](#spend-commit-tasks) — fragmentation across task surfaces does not reset the aggregate. The spend-commit task inventory is the single authoritative list; new spend-commit tasks added there join the aggregate automatically, without a separate edit in this section. * The trailing window declared via the `governance.aggregation_window_days` capability (see [get\_adcp\_capabilities](#governance-aggregation-capability) below). The window slides with wall-clock time, not plan boundaries. **Evaluation-time semantics (testable).** At the moment of a spend-commit, the governance agent computes ``` aggregate = sum(c.amount for c in commit_history where c.key == this.key and c.ts > now - aggregation_window_days × 86400s) + this.amount ``` then evaluates `aggregate` against each applicable threshold. The current incoming commit is included in the sum. Commits that were denied outright do not contribute; commits approved or approved-with-conditions do. `now` is the governance agent's wall-clock time at evaluation; sliding-window boundaries are not snapped to plan or calendar boundaries. **Commitments are sticky within the window.** `c.amount` is the amount committed at approval time, not the delivered amount. Under-delivery, cancellation, makegoods, and post-approval budget reductions MUST NOT decrement a commit's contribution to the aggregate before the trailing window rolls it off. Otherwise a buyer could free fragmentation headroom by cancelling an approved commit and immediately re-committing sub-threshold — full spend moves across the round trip, each leg passes in isolation. An `update_media_buy` that *increases* committed budget enters as a delta (new committed budget − prior committed); a decrease does not decrement. When an individual commit would be below a threshold in isolation but pushes the trailing-window aggregate above the threshold, the governance agent MUST apply the threshold's consequence (human review escalation, denial, or conditions) to that commit. Governance agents MAY expose an `aggregate_committed` field on `get_plan_audit_logs` responses so auditors can reconstruct the fragmentation-defense decision without re-deriving it from the full outcome stream. The field's shape (units, currency, window-boundary reporting) is governance-agent-specific in 3.x and will be standardized in a later revision; implementers exposing it SHOULD document their shape alongside their `get_plan_audit_logs` response. Governance agents MAY evaluate narrower aggregation scopes additionally (per-brand, per-campaign) but MUST NOT evaluate any scope *broader* than the declared window without operator sign-off. "Broader" covers **both** dimensions: a longer trailing window (time) and a wider key tuple (e.g., collapsing across `account_id` so two accounts share an aggregate). A silently-widened scope in either dimension is as surprising to the operator as a silently-narrowed one. #### Composition with `reallocation_threshold` A reallocation is itself a spend-commit: `update_media_buy` carries an incremental commit delta (new committed budget − previously committed), and that delta enters the aggregate and counts toward `reallocation_threshold` evaluation. A buyer cannot sidestep a \$25,000 reallocation threshold by fragmenting one \$30,000 reallocation into six \$4,999 updates — each update's delta accumulates into the trailing-window aggregate and trips the threshold once the cumulative delta crosses it. #### Conformance example Agent declares `aggregation_window_days: 30`. Plan sets a `human_review_required` trigger at \$10,000 committed spend (keyed on `(buyer_agent, seller_agent, account_id)`). | # | Prior 30-day aggregate | Incoming commit | Post-aggregate | Expected outcome | | - | ---------------------- | -------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------- | | 1 | \$4,000 | \$2,500 `create_media_buy` | \$6,500 | Auto-approve — below threshold | | 2 | \$8,000 | \$2,500 `create_media_buy` | \$10,500 | Escalate to human review — aggregate crosses threshold even though the incoming commit is \$2,500 in isolation | A governance agent that approves row 2 without escalation is non-conformant: it has either failed to aggregate across the 30-day window, failed to key on the correct tuple, or failed to include the incoming commit in the sum. #### Governance aggregation capability Sellers and governance agents declare their aggregation window via `governance.aggregation_window_days` in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities). Buyers that depend on a specific window for compliance (e.g., a brand-level weekly cadence review) MUST check this capability before relying on aggregation semantics — a governance agent declaring `aggregation_window_days: 7` does not defend against fragmentation spread across a 30-day quarter-end push. Absent declaration means the agent has not committed to any aggregation window and buyers MUST assume per-commit evaluation only (the fragmentation attack surface is open). There is no schema default: omission is not equivalent to a declared 30-day window. ## Brand compliance configuration Compliance policies live at the brand level, not in individual campaign plans. The brand's policy team configures the brand's compliance profile, and the governance agent resolves it when processing plans for that brand. The schema and hosting mechanism for brand compliance configuration are under development by the AgenticAdvertising.org Governance Working Group. The following describes the conceptual model; implementations may vary. A brand's compliance configuration contains two kinds of policies: * **Registry policies**: References to standardized policies in the [AdCP policy registry](/dist/docs/3.1.20/governance/policy-registry), identified by ID. Each reference MAY include configuration parameters that customize the policy for the brand. * **Custom policies**: Brand-specific rules expressed as natural language strings, evaluated by the governance agent using the same approach as [prompt-based policies](/dist/docs/3.1.20/governance/overview#prompt-based-policies). The policy team selects registry policies that apply to the brand, configures parameters where needed, and adds any custom policies specific to the brand. The buying team never interacts with this configuration -- they create campaign plans that reference the brand, and the governance agent resolves applicable policies automatically. The brand's industries inform automatic policy matching -- for example, a brand in the beverage industry would receive any registry policies tagged for that industry. ## Policy registry The policy registry is a community-maintained library of standardized, machine-readable advertising compliance policies. Brands reference policies by ID rather than writing their own. The registry covers three categories: | Category | Examples | | ---------------- | --------------------------------------------------------------------------------------------------------- | | **Jurisdiction** | UK HFSS restrictions, US COPPA, EU GDPR age-gating, California AI disclosure (SB 942) | | **Vertical** | Alcohol age verification, pharma fair balance, gambling self-exclusion, financial services APR disclosure | | **Brand safety** | Brand safety baselines, content suitability tiers | Each policy in the registry has an ID, applicable jurisdictions, a description, and machine-readable rules that governance agents can evaluate programmatically. Policies are versioned as regulations change; brand references MAY pin a specific version, and unversioned references resolve to the current version. The registry format and hosting mechanism are under development by the AgenticAdvertising.org Governance Working Group. This model follows the pattern established by [IEEE 7012](https://standards.ieee.org/ieee/7012/7192/) (Machine Readable Personal Privacy Terms), which maintains a neutral roster of standardized agreements that parties reference rather than draft individually. ## Policy resolution Policies are declared directly on the plan via `policy_ids` and `custom_policies`. When a plan is synced, the governance agent resolves the active policy set: 1. Load registry policies referenced by `policy_ids` 2. Intersect with the plan's `countries` and `regions` -- only policies applicable to the plan's markets are active 3. Include all `custom_policies` (these apply regardless of geography) **`custom_policies` are additive only.** Governance agents MUST pin registry-sourced policy text as system-level instructions and MUST NOT permit `custom_policies` (or the plan's `objectives` field) to relax, override, or disable registry-sourced policies. Custom policies may add tighter restrictions — they cannot lower enforcement levels or exempt categories. A `custom_policies` entry that contradicts a registry policy is evaluated alongside it, not instead of it; the stricter constraint governs. The plan's `countries` and `regions` fields also serve as **geo enforcement**: the governance agent MUST reject governed actions targeting markets outside the plan's allowed geography. A plan with `regions: ["US-MA"]` rejects actions not explicitly targeting Massachusetts, even if they are otherwise compliant. These fields use the same ISO codes and semantics as `product-filters`, `offerings`, and `create_media_buy`. The resolved policy set is what the governance agent evaluates during [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance). For the `brand_policy` and `regulatory_compliance` categories, the governance agent validates against this resolved set. If the plan has no `policy_ids` or `custom_policies`, the governance agent operates with an empty policy set for policy-based categories. Other categories (`budget_authority`, `strategic_alignment`, etc.) still apply based on the plan's parameters. ## Audience governance Campaign plans declare audience targeting constraints, restricted attributes, and policy categories. The governance agent uses these to validate that seller targeting complies with regulatory requirements and campaign intent. ### Three-layer model Audience governance separates three concerns: | Layer | Field | Purpose | Example | | --------------------- | ---------------------------- | ---------------------------------------------- | --------------------------------------------------- | | **Identity** | `brand.industries` | What the company does | `["pharmaceuticals", "consumer_packaged_goods"]` | | **Regulatory regime** | `plan.policy_categories` | What regulations apply to this campaign | `["pharmaceutical_advertising", "health_wellness"]` | | **Data restrictions** | `plan.restricted_attributes` | What personal data is off-limits for targeting | `["health_data"]` | A pharmaceutical company is always pharma (identity), but a general awareness campaign might not trigger pharmaceutical advertising regulations (regime), and only campaigns in EU jurisdictions might restrict health data targeting (restrictions). ### Audience constraints Plans can declare `audience.include` and `audience.exclude` arrays using audience selectors. Each selector is either a `signal_ref` or a natural language description. The governance agent evaluates these constraints against seller targeting in `check_governance`: 1. Compare `planned_delivery.audience_targeting` against the plan's `audience.include`/`exclude` 2. Compare `planned_delivery.audience_targeting` against the same constraints (for committed checks) 3. Detect divergence between what the orchestrator requested and what the seller will activate ### Structural governance matching Signal definitions can self-declare `restricted_attributes` and `policy_categories`. When they do, the governance agent performs **structural matching** — a set intersection between the plan's restrictions and the signal's declarations. This is deterministic and requires no LLM inference. For signals without declared governance metadata, the governance agent falls back to **semantic matching** — inferring sensitivity from the signal name and description. Structural matching produces higher-confidence findings than semantic matching. Restricted attributes apply to both `include` and `exclude` targeting. Using restricted data to exclude an audience (e.g., excluding people with health conditions from pharmaceutical ads) is as prohibited as using it for inclusion — both constitute use of restricted personal data for targeting decisions. ### Audience distribution drift During delivery, sellers report `audience_distribution` in `delivery_metrics`. Index values indicate demographic composition relative to a declared baseline (census, platform, or custom). A value of 1.0 means parity; values significantly above or below indicate skew. The governance agent tracks both per-period indices and cumulative indices across all reporting periods. This enables detection of systematic bias that might not be visible in any single reporting period. ## State tracking The governance agent tracks state at two levels: * **Plan level**: Total budget committed, channel allocation percentages, plan status * **Campaign level**: Per-`governance_context` committed budget, active media buy references, validation history A single plan can span multiple campaigns. When [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) checks budget authority, it considers all campaigns tied to the plan. When [`report_plan_outcome`](/dist/docs/3.1.20/governance/campaign/tasks/report_plan_outcome) reports a seller confirmation, the governance agent commits the budget from the seller's actual amount -- not the requested amount. ### Plan status | Status | Meaning | | ----------- | ---------------------------------------------------- | | `active` | Accepting validation requests and outcome reports | | `suspended` | Paused pending human review of a critical escalation | | `completed` | Plan finished; read-only | When status is `suspended`, the governance agent MUST reject all `check_governance` and `report_plan_outcome` requests with a `CAMPAIGN_SUSPENDED` error until the escalation is resolved. ### Budget tracking Budget is committed based on **confirmed outcomes**, not validated actions. The flow: 1. `check_governance` with `tool` + `payload` (intent check) checks whether the proposed spend fits within the plan. No budget is committed yet. 2. The orchestrator executes the action with the seller. 3. `report_plan_outcome` reports the seller's confirmed amount. The governance agent commits this amount to the plan budget. This ensures budget tracking reflects reality. If a seller reduces the budget from \$150K to \$120K, the governance agent commits \$120K and returns findings about the discrepancy. If the action fails entirely, the governance agent commits \$0. An execution check approval validates the seller's planned delivery against the plan but does not commit budget. Budget is only committed when the orchestrator calls `report_plan_outcome` with the seller's confirmed response. Budget checks are point-in-time: `check_governance` validates against the current committed total but does not reserve budget. If multiple agents execute concurrently against the same plan, two checks could both pass and the combined outcomes could exceed the authorized budget. The governance agent detects overspend at outcome reporting time and returns a `budget_authority` finding. To prevent concurrent overspend, use [delegations](#delegations) with per-agent `budget_limit` to partition the budget across executing agents. ### Drift detection The audit log includes `drift_metrics` that surface aggregate governance trends over the plan's lifetime: ```json theme={null} { "summary": { "checks_performed": 847, "drift_metrics": { "human_review_rate": 0.03, "human_review_rate_trend": "declining", "auto_approval_rate": 0.91, "human_override_rate": 0.02, "mean_confidence": 0.88 } } } ``` These metrics detect oversight drift -- the gradual migration of control away from humans. A declining human review rate may indicate the governance agent is well-calibrated, or it may indicate that oversight is eroding. Surfacing the trend lets the organization make that judgment. | Metric | What it measures | | ------------------------- | ------------------------------------------------------------------------ | | `human_review_rate` | Fraction of checks that required internal human review | | `human_review_rate_trend` | Direction over the plan's lifetime (`increasing`, `stable`, `declining`) | | `auto_approval_rate` | Fraction of checks approved without human intervention | | `human_override_rate` | Fraction of human reviews where the human overrode the governance agent | | `mean_confidence` | Average confidence score across findings (when confidence is reported) | Organizations can set thresholds on drift metrics. When a metric crosses its threshold, the governance agent SHOULD include a finding (severity `warning`) on the next governance check: ```json theme={null} { "drift_metrics": { "human_review_rate": 0.01, "human_review_rate_trend": "declining", "auto_approval_rate": 0.97, "thresholds": { "human_review_rate_min": 0.02, "auto_approval_rate_max": 0.95 } } } ``` In this example, both thresholds are breached -- the human review rate (0.01) is below the minimum (0.02) and the auto-approval rate (0.97) exceeds the maximum (0.95). This could indicate that the governance agent is approving too broadly, or that policies are well-calibrated for a low-risk campaign. The threshold breach surfaces the question; the organization decides the answer. Organizations set only the thresholds relevant to their concern. A `human_review_rate_min` catches oversight erosion; a `human_review_rate_max` catches policy miscalibration. A `human_override_rate_max` catches a governance agent whose recommendations are consistently wrong. All threshold fields are optional. ### Plan amendments Calling `sync_plans` with an existing `plan_id` updates the plan (upsert). The governance agent increments `plan_version` and applies the new parameters immediately. Active media buys that were approved under the previous plan version are not automatically re-validated -- the governance agent evaluates them against the updated plan on the next `check_governance` call (e.g., the next delivery check). If an amendment reduces the budget below the currently committed amount, the governance agent flags this as a finding on the next governance check. ## Validation logic The governance agent evaluates each [validation category](/dist/docs/3.1.20/governance/campaign/index#validation-categories) independently: * If **any** category has status `failed` and the failure is correctable, the status is `conditions` with suggested fixes * If **any** category has status `failed` and the failure is not correctable by the caller, the status is `denied` * If all categories pass but the overall risk profile warrants human review, the governance agent handles the review internally (the task goes async) and eventually resolves to `approved` or `denied` * If all categories pass, the status is `approved` The `conditions` array is only present when the status is `conditions`. Each condition identifies a specific field, its current value, a suggested value, and the reason for the change. ### Finding confidence Governance findings include an optional `confidence` score (0-1) and `uncertainty_reason` that distinguish certain violations from ambiguous ones: ```json theme={null} { "category_id": "regulatory_compliance", "severity": "critical", "confidence": 0.85, "uncertainty_reason": "Targeting includes 'New Mexico' which partially overlaps LATAM HFSS jurisdiction boundaries", "explanation": "Potential HFSS jurisdiction violation based on targeting geography." } ``` Confidence informs the appropriate response: * **High confidence (0.9+)**: The finding is definitive. A GDPR violation on a campaign explicitly targeting EU users. * **Medium confidence (0.6-0.9)**: The finding depends on context the governance agent cannot fully resolve. Audience segments that may include minors, geo targeting that partially overlaps regulated jurisdictions. * **Low confidence (below 0.6)**: The finding is speculative. The governance agent flags it for human review rather than acting on it autonomously. Without confidence, every finding is presented as equally certain, which either over-blocks (if treated as certain) or trains people to ignore findings (if many are false positives). Governance agents SHOULD include confidence when the evaluation involves natural language interpretation or probabilistic matching. ### Phase inference The governance agent infers the validation phase from the `tool` parameter in `check_governance`: | tool | Phase | | ------------------ | ----------------------------------------------------------------------------- | | `get_products` | Discovery -- validates search intent, seller eligibility, product suitability | | `create_media_buy` | Purchase -- validates budget authority, targeting compliance, flight dates | | `update_media_buy` | Purchase -- validates change magnitude, reallocation thresholds | | `acquire_rights` | Purchase -- validates budget authority, geo compliance, flight dates | | `update_rights` | Purchase -- validates change magnitude, reallocation thresholds | | `activate_signal` | Purchase -- validates budget authority, geo compliance, flight dates | | `build_creative` | Purchase -- validates budget authority, geo compliance | Phase context is cumulative. During **purchase**, the governance agent considers what was discovered during **discovery**. The `check_id` returned by `check_governance` is used by `report_plan_outcome` to link the seller's response back to the validated action. ## Capability declaration Governance agents declare their Campaign Governance support in `get_adcp_capabilities`: ```json theme={null} { "governance": { "campaign_governance": { "categories": [ { "category_id": "budget_authority", "description": "Validates spend against plan budget limits and allocation rules." }, { "category_id": "strategic_alignment", "description": "Validates that purchases match campaign brief and channel mix targets." }, { "category_id": "bias_fairness", "description": "Checks targeting for discriminatory patterns and protected category compliance.", "jurisdictions": ["US", "EU", "UK"] }, { "category_id": "regulatory_compliance", "description": "Validates jurisdiction-specific advertising regulations.", "jurisdictions": ["US", "EU", "UK"] }, { "category_id": "seller_verification", "description": "Compares seller setup against original requests to detect discrepancies." }, { "category_id": "brand_policy", "description": "Enforces brand-level compliance policies resolved from the brand configuration and policy registry." } ] } } } ``` ## Integration with `create_media_buy` The buyer includes `plan_id` on the `create_media_buy` request and `governance_context` on the protocol envelope. These fields tell the seller which governance plan applies, enabling seller-side governance checks. ```json theme={null} { "tool": "create_media_buy", "arguments": { "plan_id": "plan_q1_2026_launch", "account": { "agent_url": "https://seller.example.com", "id": "acc_123" }, "brand": { "domain": "acmecorp.com" }, "start_time": "2026-03-15T00:00:00Z", "end_time": "2026-06-15T00:00:00Z", "packages": ["..."] } } ``` The seller's response includes `planned_delivery` -- what the seller will actually run: ```json theme={null} { "seller_reference": "mb_seller_456", "packages": ["..."], "planned_delivery": { "geo": { "countries": ["US"] }, "channels": ["olv"], "start_time": "2026-03-15T00:00:00Z", "end_time": "2026-06-15T00:00:00Z", "total_budget": 150000, "currency": "USD", "frequency_cap": { "max_impressions": 3, "per": "user", "window": { "interval": 1, "unit": "days" } }, "audience_summary": "Adults 25-54, US, premium video inventory", "enforced_policies": ["us_coppa"] } } ``` `planned_delivery` is the seller's interpretation of the request -- the actual delivery parameters they will use. It serves two purposes: 1. **Governance checks** -- When the account has a governance agent configured, the seller sends `planned_delivery` to the governance agent for verification before confirming the media buy. 2. **Transparency** -- The buyer can compare `planned_delivery` against what they requested to catch discrepancies early, before delivery begins. ## Governance checks Campaign Governance's buyer-side validation has a trust limitation: the buyer's orchestrator grades its own homework. An LLM agent could hallucinate governance approval, skip validation, or misrepresent what was validated. Seller-side governance checks close this gap by giving sellers an independent way to confirm that purchases are approved. The seller POSTs to the buyer's configured governance agent URL when governed action events occur. The governance agent maintains all state and correlates requests by `plan_id` + `governance_context` -- the seller does not need to track governance history or chain IDs across calls. ### Both checks must pass Every governed action **MUST** pass both the buyer-side intent check and the seller-side planned-delivery check. Both calls hit the same authority (the buyer's governance agent), so there is no "two agents disagreeing" case — but the invariant that both calls must succeed is load-bearing: * The buyer-side intent check confirms the *plan permits the spend in principle*. * The seller-side planned-delivery check confirms the *seller's actual delivery parameters are consistent with the approved plan*. These are not redundant. A buyer's intent check can pass (the plan allows \$100K on premium video) while the seller's planned-delivery check fails (the seller's planned line-up includes inventory the plan excludes). If either returns `denied`, the action **MUST NOT** proceed. When both return `approved` with non-empty `conditions`, the applied set is the **union** of both responses' conditions. Contradictory conditions (one side requires X, the other requires NOT X) resolve to `denied` with a structured `finding` rather than silent precedence. A seller that rejects a trade for its own content-standards or commercial reasons is not participating in a governance conflict — that is a separate, commerce-layer rejection (e.g., `TERMS_REJECTED`) and follows the regular rejection path. Governance speaks only for the buyer's plan. ### Setup The buyer syncs governance agents via [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance), pairing each account with the governance agent endpoints to call. Each agent includes authentication credentials so the governance agent can verify the seller's identity: ```json theme={null} { "tool": "sync_governance", "arguments": { "accounts": [ { "account": { "brand": { "domain": "acmecorp.com" }, "operator": "pinnacle-media.com" }, "governance_agents": [ { "url": "https://governance.pinnacle-media.com", "authentication": { "schemes": ["Bearer"], "credentials": "gov_token_acme_pinnacle_2026_xyzxyzxyz..." } } ] } ] } } ``` The seller stores these endpoints and presents the credentials when calling `check_governance`. The governance agent MUST verify that the Bearer token matches a registered credential for the account associated with the `plan_id`, and MUST reject requests with unrecognized or mismatched credentials. ### Governance modes Governance mode (audit, advisory, enforce) is an internal implementation detail of the governance agent, not a protocol-level field. The caller sends `check_governance` and receives `approved`, `denied`, or `conditions` — it does not need to know what mode produced that decision. This means: * A governance agent in audit mode internally always returns `approved` with findings attached * A governance agent in advisory mode internally may return `denied` but the organization treats it as non-blocking * A governance agent in enforce mode returns `denied` and expects the caller to stop Mode is configured by the buyer's policy team on the governance agent itself, not via the protocol. The governance agent MAY include mode information in its audit logs or `get_plan_audit_logs` response for post-hoc analysis, but callers MUST NOT branch behavior based on mode — they act on the status they receive. See the [safety model](/dist/docs/3.1.20/governance/campaign/safety-model) for the crawl-walk-run adoption path. ### Governance context The `governance_context` field is an opaque string issued by the governance agent in the `check_governance` response. It correlates any governed action's lifecycle and is the primary audit/reporting key. The governance agent encodes whatever internal state it needs (plan reference, budget snapshot, check history) into this value. Callers MUST NOT interpret `governance_context`. They persist and forward it: * **Buyer**: receives `governance_context` from `check_governance` response, attaches it to the protocol envelope when sending the media buy to the seller. * **Seller**: receives `governance_context` in the envelope, stores it alongside the media buy, and includes it on all subsequent `check_governance` calls for that media buy's lifecycle. * **Governance agent**: uses `governance_context` to reconnect each lifecycle event to the original plan, campaign grouping, and budget state. On the first `check_governance` call (before any context exists), the governance agent extracts what it needs from `payload` and `plan_id`. On subsequent calls, `governance_context` provides the continuity so the governance agent does not need to re-derive state from the payload. Governance agents SHOULD treat `governance_context` as a lookup key into server-side state or a signed token, not as a plain-text encoding of governance state. If state is encoded directly, it MUST be signed (e.g., HMAC) so tampering by intermediaries is detectable. In AdCP 3.0 the encoded value is a compact JWS signed per the [AdCP JWS profile](/dist/docs/3.1.20/building/by-layer/L1/security#adcp-jws-profile). The token carries a required `plan_hash` claim that binds the attestation to the exact plan state the governance agent evaluated (see [Plan binding and audit](#plan-binding-and-audit) below). Callers still treat the value as opaque for correlation; sellers that opt into verification follow the [seller verification checklist](/dist/docs/3.1.20/building/by-layer/L1/security#seller-verification-checklist), which verifies the token's authenticity, authorization scope, and freshness — not the buyer's plan. ### Plan binding and audit The `plan_hash` claim is the cryptographic receipt that forever binds a signed `governance_context` attestation to the exact plan state the governance agent evaluated. It is an **audit-layer** property — sellers do not verify it and are not expected to. The buyer's plan carries commercially sensitive data (cross-seller allocations, per-seller caps, objectives, `approved_sellers` lists, custom policies, `ext`) that buyers do not share with sellers; there is no plan-retrieval mechanism in 3.x and none is planned. `plan_hash` rides inside the JWS because the JWS is already the signed artifact the governance agent produces, but it is not part of the wire-verification contract. What the claim delivers: * **Post-hoc accountability.** Every governance attestation is forever-bindable to the plan state it attested to. Regulators and forensic audits can prove "this transaction was authorized under plan state X at time T" years after the fact, using only the retained JWS and the governance agent's revision records. * **Governance-agent self-integrity.** On every `check_governance` call, the governance agent re-evaluates current plan state and re-hashes. Tampering with the governance agent's persisted plan between calls surfaces as a mismatch against the retained revision record. * **Buyer-side compliance verification.** A buyer's own tooling can verify its governance agent is producing tokens that match the plan the buyer actually pushed — catching a compromised or misbehaving governance vendor. #### Canonicalization `plan_hash = base64url_no_pad(SHA-256(JCS(plan_payload)))` where: * `JCS` is [RFC 8785 JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785) — the same scheme used for [idempotency payload equivalence](/dist/docs/3.1.20/building/by-layer/L1/security#payload-equivalence). Governance agents and auditor verifiers SHOULD use the same library implementations listed there. JCS sorts object keys lexicographically by code point (caller key order in the `sync_plans` request does not affect the hash) and preserves the distinction between an omitted optional field and an explicit `null` (these produce different hashes). Governance agents MUST hash the plan as-supplied; they MUST NOT synthesize omitted optionals to default values and MUST NOT drop explicit nulls. * `plan_payload` is **one element of the `plans[]` array as supplied on `sync_plans`** — a single plan object, not the `sync_plans` request envelope and not the `plans` wrapper array. The preimage is the current plan-revision state at the time of attestation, i.e., the plan object the governance agent just evaluated. Implementations constructing the preimage start from the plan-revision object and remove the closed set of bookkeeping fields listed below. * `base64url_no_pad` follows RFC 4648 §5 with trailing `=` padding stripped — consistent with `jti` and other base64url values in the JWS profile. Governance agents MUST emit the unpadded form. Verifiers (governance agents re-verifying their own tokens, auditors, buyer-side compliance tooling) MUST compare by base64url-decoding both sides to the raw 32-byte SHA-256 digest and comparing bytes — NOT by string equality of the encoded form — so padding, case, or alphabet variation is rejected as a decode failure rather than producing a false non-match. A `plan_hash` that does not decode to exactly 32 bytes MUST be rejected. #### Excluded fields Closed list — governance agents MUST NOT extend or shrink it; any addition is a breaking change requiring a profile version bump, same rule as [Payload equivalence](/dist/docs/3.1.20/building/by-layer/L1/security#payload-equivalence): * `version` — governance-agent revision counter, set by the agent on each re-sync * `status` — plan lifecycle status managed by the agent * `syncedAt` — timestamp written on each re-sync * `revisionHistory` — agent-internal append-only revision log (MUST be append-only archival; implementations MUST NOT read revisionHistory entries back into the active plan-object shape used for hashing) * `committedBudget` — derived from downstream `check_governance` / `report_plan_outcome` activity * `committedByType` — derived from the same activity None of these appear in the `sync_plans` request schema (`additionalProperties: false` on the plan item); they exist only on the governance agent's persisted plan state. The list is stated explicitly so an implementer naively hashing their internal state struct strips the right fields. Implementations that discover additional GA-internal fields on their persisted plan state beyond this list MUST treat those fields as **IN the preimage** until the profile version bumps — fail-safe toward inclusion, not exclusion. The "anything that looks like bookkeeping → strip" shortcut silently diverges every implementation that guesses differently; the safe default is "if it is not on the closed list, it is part of the hash." All other fields — including `ext`, `custom_policies`, `objectives`, `delegations`, `human_override`, and every field declared in the `sync_plans` plan-item schema — are IN the preimage. Caller guidance: * **`ext` is part of the preimage.** Buyers MUST NOT place rotating tokens or retry-unstable values inside `ext` on a plan; a value that changes between re-syncs invalidates every outstanding governance\_context token for the plan even when the buyer's declared intent is unchanged (consistent with idempotency's treatment of `ext`). * **`delegations[].expires_at`** is declared intent and SHOULD be stable across re-syncs of the same plan; regenerating it from "now + N days" on every sync causes hash churn. * **Array order** in `policy_ids`, `policy_categories`, `custom_policies`, `approved_sellers`, `delegations`, `countries`, `regions`, `channels.required`, `channels.allowed` is not semantically meaningful, but JCS preserves it. Buyers SHOULD emit these in stable order across re-syncs. * **JCS does not Unicode-normalize.** Per RFC 8785 §3.2.5, JCS preserves strings as-supplied — visually-indistinguishable Unicode variants (Latin `a` vs Cyrillic `а`, NFC-vs-NFD compositions, confusable homoglyphs) produce distinct bytes and therefore distinct hashes. `plan_hash` detects this divergence correctly at the cryptographic layer, but the plan-semantics layer does not: two plans whose `policy_ids` or `policy_categories` differ only by a homoglyph substitution authorize different enforcement outcomes at the governance agent and at downstream consumers. Buyers and governance agents SHOULD validate `policy_ids` and `policy_categories` against a canonical allowlist server-side before emitting or evaluating them. This is a plan-content rule, not a hashing rule — `plan_hash` integrity is intact either way; the allowlist is what prevents a homograph substitution from producing a different authorization decision. #### Governance-agent obligations Governance agents MUST: * Compute `plan_hash` over the current plan state on every `check_governance` call and include it in the signed JWS payload. The hash MUST be over the plan the agent just evaluated; stale attestations over mutated plans MUST NOT be produced. * Refresh the signature on every `check_governance` invocation — fresh `jti`, `iat`, `exp`, and `plan_hash`. Governance agents MUST NOT cache and re-emit a previously-signed `governance_context` token across plan revisions. Envelope idempotency response caching is a separate regime — `governance_context` is on the closed exclusion list in [Payload equivalence](/dist/docs/3.1.20/building/by-layer/L1/security#payload-equivalence) precisely so it can rotate on replay. * **Retain the per-revision `plan_hash`** alongside each internal plan-revision record — MUST, regardless of whether `audit_log_pointer` is exposed. Retention is what delivers the forever-binding property; without universal retention, every governance agent that doesn't use `audit_log_pointer` silently voids the audit layer for its entire token corpus. An audit log that cannot be joined back to the attested plan state is half an audit trail, and a governance agent that cannot verify its own historical tokens cannot detect tampering of its own store. Retained values are implementation-internal and are never exposed on the wire except through the normalized response on [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs), which echoes `plan_hash` per entry so auditors do not have to reconstruct from the governance agent's private records. #### Wire-verification contract `plan_hash` is not listed in `crit`. `crit` is wire-verifier semantics (RFC 7515 §4.1.11): it forces verifiers to reject tokens whose listed claims they cannot process. No wire verifier processes `plan_hash` — the only parties who can fetch the preimage (governance agent, auditor, buyer compliance) are off-wire. Listing in `crit` would force sellers to reject tokens they have no basis to verify, with no offsetting benefit. Governance agents MUST emit the claim and MUST NOT list it in `crit`. Sellers persist and forward `governance_context` verbatim and perform the [15-step JWS verification checklist](/dist/docs/3.1.20/building/by-layer/L1/security#seller-verification-checklist) — authenticity, authorization scope, freshness. They treat `plan_hash` as opaque cargo inside the token and never inspect it. #### Verification recipes **Auditor recipe.** A regulator or third-party auditor with access to a plan's audit logs verifies historical attestations as follows: 1. Call [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs) with `include_entries: true` to retrieve the audit trail. Each `check` entry carries `plan_hash` (the claim asserted at issuance time) and `governance_context` (the signed JWS). 2. For each governance\_context, decode the compact JWS and verify the 15-step JWS contract (signature, `iss` in brand.json, `aud`, `exp`, etc.) against the governance agent's published JWKS. 3. Extract the `plan_hash` claim from the decoded JWS payload and base64url-decode to 32 raw bytes. 4. Decode the entry-level `plan_hash` to 32 raw bytes and byte-compare to the claim. A mismatch means the retained audit record disagrees with the signed token — either the token was tampered with or the record was, and the governance agent's integrity is in question. 5. Optionally: recompute `plan_hash` from the governance agent's retained per-revision plan record (if the auditor has authenticated access to the governance agent's revision store) and byte-compare again. A mismatch here means the governance agent's own store was tampered with between signing and audit. **Buyer-side compliance recipe.** A buyer whose own tooling wants to verify its governance agent is producing honest tokens: 1. Observe `governance_context` tokens flowing through the protocol envelope to sellers (the buyer already has these; no retrieval needed). 2. For each token, decode the JWS, extract the `plan_hash` claim, and base64url-decode to 32 bytes. 3. Recompute `plan_hash` over the buyer's own copy of the plan at the revision the token attests to. The buyer has authoritative plan state from their own `sync_plans` calls. 4. Byte-compare. A mismatch means the governance agent is signing attestations that don't match the plan the buyer actually pushed — either vendor compromise or a bug. Either is a critical finding the buyer should escalate. This path catches a misbehaving governance vendor without involving the seller, the auditor, or the protocol. The data is already on the buyer's side. **Constant-time comparison.** All three verifier types — governance-agent self-integrity, auditor, and buyer-side compliance — SHOULD use constant-time byte comparison (e.g., `crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python, `crypto/subtle.ConstantTimeCompare` in Go) when comparing `plan_hash` digests. Timing side channels on a SHA-256-length comparison are not practically exploitable today; the rule is cheap insurance against future deployments that re-use the comparison code on shorter digests or against adversaries with co-tenancy on the verifier host. #### Privacy considerations `plan_hash` changes across re-syncs reveal that the plan mutated, even without revealing *what* mutated. Parties retaining tokens long-term (sellers forwarding `governance_context` verbatim, auditors, regulators) can infer plan-mutation cadence from the sequence of distinct hashes they observe for a given `plan_id`. For most deployments this is acceptable or desired — mutation-cadence is part of the audit signal. Sensitive governance deployments where mutation frequency itself is commercially or operationally sensitive (e.g., a buyer does not want a seller to infer re-planning cadence across its campaign portfolio) SHOULD factor this into token-retention policy: shorter seller-side retention windows for `governance_context`, or periodic rotation of the plan's `jti` namespace to break cross-token linkability. #### Reference test vectors Eleven vectors under [`static/compliance/source/test-vectors/plan-hash/`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/plan-hash) pin the canonicalization bit-exactly: a minimal plan, a plan exercising every optional field, a bookkeeping-stripped case (GA-internal fields present on the stored plan but stripped before hashing, yielding the same hash as the bookkeeping-absent equivalent), paired vectors proving that omitted-vs-explicit-null, array order in `policy_categories`, and rotating `ext.trace_id` all produce distinct hashes, a Unicode case confirming JCS does not normalize per RFC 8785 §3.2.5, and a numeric-canonicalization case with fractional percentages that pins library choice over hand-rolled `JSON.stringify + key sort`. Each vector records the preimage, the canonical JCS bytes, the SHA-256 hex digest, and the final `plan_hash` claim value. Governance agents and auditor verifiers MUST reproduce these hashes bit-exactly. ### Governance phases Governance checks cover the full media buy lifecycle through three phases: | Phase | Trigger | What's validated | | -------------- | ------------------------------------------------------------------------- | --------------------------------------------------- | | `purchase` | `create_media_buy`, `acquire_rights`, `activate_signal`, `build_creative` | Budget, geo, channels, flight dates, policies | | `modification` | `update_media_buy`, `update_rights` | Change magnitude, reallocation, new parameters | | `delivery` | Periodic (seller-initiated) | Pacing, spend rate, geo drift, channel distribution | The `phase` field defaults to `purchase` if omitted, so existing implementations continue to work without changes. The governance agent maintains all state and correlates requests by `plan_id` + `governance_context`. The seller does not chain check IDs or track conversation history -- it posts what happened, and the governance agent looks up context. ### Purchase phase When the seller receives a `create_media_buy` request on an account with `governance_agents`: 1. The seller interprets the request and determines its `planned_delivery`. 2. The seller calls `check_governance` with `phase: "purchase"`, the `plan_id`, and `planned_delivery`. 3. The governance agent validates the planned delivery against the campaign plan. 4. If `approved`, the seller confirms the media buy. 5. If `denied`, the seller rejects the media buy with an `GOVERNANCE_DENIED` error. 6. If `conditions`, the seller adjusts its planned delivery to meet the conditions and re-verifies, or rejects. ```mermaid theme={null} sequenceDiagram participant O as Orchestrator participant S as Seller participant A as Governance Agent O->>S: create_media_buy(plan_id, packages, ...) S->>S: Interpret request → planned_delivery S->>A: POST check_governance(phase: purchase) A->>A: Validate against plan A-->>S: approved S-->>O: media_buy confirmed (planned_delivery) ``` ### Modification phase When the seller receives an `update_media_buy` request: 1. The seller interprets the update and determines the new `planned_delivery`. 2. The seller calls `check_governance` with `phase: "modification"`, the updated `planned_delivery`, and a `modification_summary`. 3. The governance agent looks up the governed action by `plan_id` + `governance_context` and evaluates the changes against the plan. 4. If `approved`, the seller confirms the update. 5. If `denied` or `conditions`, the seller follows the same flow as purchase phase. ```mermaid theme={null} sequenceDiagram participant O as Orchestrator participant S as Seller participant A as Governance Agent O->>S: update_media_buy(budget: $200K, end: Jul 15) S->>S: Determine new planned_delivery S->>A: POST check_governance(phase: modification) A->>A: Look up media buy, evaluate changes A-->>S: approved S-->>O: media_buy updated ``` The governance agent can apply different logic to modifications than to initial purchases. For example, a small budget increase within `reallocation_threshold` might be auto-approved, while a large budget increase or new geo market might require stricter scrutiny. ### Delivery phase The seller calls `check_governance` with `phase: "delivery"` periodically during active delivery. This creates a direct reporting channel between the seller and the buyer's governance agent. 1. The seller collects delivery metrics for the reporting period. 2. The seller calls `check_governance` with `phase: "delivery"`, the current `planned_delivery`, and `delivery_metrics`. 3. If `approved`, the response includes `next_check` -- when the seller should report again. 4. If `denied`, the seller pauses delivery immediately. 5. If `conditions`, the seller adjusts delivery (e.g., slow pacing, shift geo targeting) and re-verifies immediately. The governance agent opts in to delivery reporting by including `next_check` in the purchase approval response. If the purchase response has no `next_check`, the governance agent does not expect delivery reports. ```mermaid theme={null} sequenceDiagram participant S as Seller participant A as Governance Agent loop Every reporting period S->>A: check_governance(phase: delivery, metrics) A->>A: Check pacing, geo drift, spend rate A-->>S: approved (next_check: +7d) end Note over S,A: If drift detected S->>A: check_governance(phase: delivery, metrics) A-->>S: conditions (slow pacing) S->>S: Adjust delivery S->>A: check_governance(phase: delivery, updated metrics) A-->>S: approved (next_check: +3d) ``` The governance agent controls the reporting cadence through `next_check`. It can tighten the cadence (shorter intervals) when it detects drift or conditions, and relax it (longer intervals) when delivery is stable. The governance agent MAY treat a missed `next_check` deadline as a finding on the next delivery check. ### Verification examples **Purchase request:** ```json theme={null} { "tool": "check_governance", "arguments": { "plan_id": "plan_q1_2026_launch", "caller": "https://seller.example.com", "governance_context": "gc_from_buyer_envelope", "phase": "purchase", "planned_delivery": { "geo": { "countries": ["US"] }, "channels": ["olv"], "start_time": "2026-03-15T00:00:00Z", "end_time": "2026-06-15T00:00:00Z", "total_budget": 150000, "currency": "USD", "frequency_cap": { "max_impressions": 3, "per": "user", "window": { "interval": 1, "unit": "days" } }, "audience_summary": "Adults 25-54, US, premium video inventory", "enforced_policies": ["us_coppa"] } } } ``` **Authorized (purchase with delivery opt-in):** ```json theme={null} { "check_id": "auth_001", "verdict": "approved", "plan_id": "plan_q1_2026_launch", "explanation": "Planned delivery is within plan parameters. Budget: $150,000 of $500,000 plan total. Geo: US (within plan). Channel: OLV (within 40-70% target range).", "expires_at": "2026-03-15T01:00:00Z", "next_check": "2026-03-22T00:00:00Z" } ``` The `next_check` field signals that the governance agent expects delivery reporting. If absent, no delivery reports are expected. **Denied (purchase):** ```json theme={null} { "check_id": "auth_002", "verdict": "denied", "plan_id": "plan_q1_2026_launch", "explanation": "Planned delivery targets CA (Canada) which is not an authorized market for this plan.", "findings": [ { "category_id": "strategic_alignment", "severity": "critical", "explanation": "Geo targeting includes CA but plan only authorizes US.", "details": { "plan_countries": ["US"], "planned_countries": ["US", "CA"] } } ] } ``` **Authorized (delivery):** ```json theme={null} { "check_id": "auth_004", "verdict": "approved", "plan_id": "plan_q1_2026_launch", "explanation": "Delivery on track. Week 1 spend: $12,500 of $150,000 (8.3%). Pacing is on target for 13-week flight.", "next_check": "2026-03-29T00:00:00Z" } ``` ### Enforcement When `governance_agents` is present on the account, the seller MUST call `check_governance` before confirming any media buy. The buyer provided the endpoints specifically so that purchases are independently verified -- skipping it defeats the purpose. When `governance_agents` is absent, the seller processes media buy requests normally. The buyer-side governance loop (intent check -> execute -> `report_plan_outcome`) still applies, but there is no seller-side verification. Sellers MUST NOT require governance checks as a prerequisite for all accounts. A seller that refuses to process media buys from accounts without `governance_agents` would break interoperability with buyers who do not use Campaign Governance. The `delivery` phase is optional even when `purchase` phase governance is used. A seller MAY support purchase approval without ongoing delivery reporting. The governance agent indicates whether it expects delivery reports through the presence of `next_check` in the purchase response. If the governance agent is unreachable (timeout, network error), the seller MUST NOT proceed with the media buy. Governance checks are a prerequisite for confirming purchases on accounts with registered `governance_agents`. The seller SHOULD retry the check after a brief delay and reject the media buy with a `GOVERNANCE_UNAVAILABLE` error if the agent remains unreachable. When the orchestrator receives `GOVERNANCE_UNAVAILABLE` from a seller, it SHOULD retry the `create_media_buy` after a delay. If the governance agent remains unavailable, the orchestrator SHOULD escalate to a human rather than attempting alternative sellers -- the governance outage affects all sellers on the same account. A prior intent check approval from the orchestrator does not substitute for the seller's execution check; the seller validates independently and cannot use the orchestrator's approval. ### Performance expectations Governance agent implementations SHOULD respond to `check_governance` calls within 5 seconds for intent checks and 10 seconds for execution checks. Sellers SHOULD configure appropriate timeouts and treat timeouts the same as unavailability (retry, then reject with `GOVERNANCE_UNAVAILABLE`). ### Wire format The seller calls each governance agent at its registered URL using MCP over HTTP (Streamable HTTP transport). The request is an MCP `tools/call` invocation with tool name `check_governance` and the request arguments as the tool input. Authentication uses the Bearer token from the agent's `authentication.credentials` in the `Authorization` header. ### One governance agent per account An account binds to exactly one governance agent per [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance). Registration is single-agent by schema — `governance_agents` is an array because that is what 3.0 shipped with, constrained to `maxItems: 1` as a load-bearing invariant (not a staging post toward relaxation). The envelope carries a single `governance_context` token; all lifecycle calls route to that one agent. Loosening the cap would require a coordinated change across `sync_governance`, the protocol envelope, and every lifecycle task that threads the token — that change is not planned. This is deliberate. A governance plan is unitary — budget authority, delivery monitoring, brand safety, and regulatory compliance are not independent specialisms held by different authorities. They are phases and facets of the same evaluation against the same plan state: * **Authorization, fidelity, and drift are phases, not specialisms.** `check_governance` already separates them on the `phase` axis (`purchase` / `modification` / `delivery`). Splitting them across agents partitions the same plan state across separate authorities, which can only produce drift, disagreement, or duplicated re-reads of the same plan. * **Regulatory rules are encoded in the plan, not held by a separate agent.** `enforced_policies`, `restricted_attributes`, `policy_ids`, and `human_review_required` live on the plan itself. A "regulatory compliance" agent separate from the "spend authority" agent would re-evaluate the same plan and reach the same decision, or diverge — neither is useful. * **Internal specialist review belongs inside the governance agent.** A buyer that wants legal, brand-safety, or category-specialist review composes those reviewers behind a single governance-agent endpoint (human review, internal routing, multi-reviewer consensus are all governance-agent-internal concerns). The protocol sees one agent; the agent's internal organization is the agent's business. * **One lifecycle, one token, one audit trail.** Plan binding (`plan_hash`), signed context (`governance_context`), and `get_plan_audit_logs` are all single-agent designs. A single agent is what makes post-hoc accountability ("this transaction was authorized under plan state X by agent Y at time T") a clean, verifiable claim. Buyers that need internal specialist review (legal, brand safety, category) compose those reviewers inside the governance agent they configure — the protocol does not surface the split. Internal decomposition is auditable through `check-governance-response.findings[]`. Each finding carries `category_id` (the agent-internal taxonomy — pharma MLR, brand safety, legal compliance, whichever specialism flagged it) and `policy_id` (the specific policy that triggered the finding). Buyers and sellers see one consolidated decision; per-finding attribution lets readers trace which specialist within the governance agent contributed to a denial or condition without surfacing the split as separate protocol-level agents. When the violation traces to a producer-tagged surface — a `feature_requirements[i].policy_id` the buyer authored, a `creative-feature-result.policy_id` the creative agent recorded, or a `validation-result.features[i].policy_id` a property-list agent emitted — the governance agent echoes that `policy_id` on the finding for end-to-end traceability. See [Policy Attribution](/dist/docs/3.1.20/governance/policy-attribution) for the producer contract. Related specialist reviews that sit adjacent to (not inside) campaign governance — brand-safety pre-screen of creatives, property-list policy, content-standards evaluation — are separate governance surfaces with their own agents and their own lifecycle (see [`build_creative`](/dist/docs/3.1.20/creative/task-reference/build_creative), property governance, content-standards governance). Campaign governance speaks only for the plan. ### Governance checks and the governance loop Governance checks complement the buyer-side governance loop, they do not replace it: | Concern | Intent checks (orchestrator, `tool` + `payload`) | Execution checks (seller, `governance_context` + `planned_delivery`) | | ---------------------- | ------------------------------------------------ | -------------------------------------------------------------------- | | **Who checks** | Buyer's governance agent, called by orchestrator | Buyer's governance agent, called by seller | | **When** | Before the buyer sends the request | Before confirm, on update, during delivery | | **What's validated** | The buyer's intended action | The seller's planned and actual delivery | | **Trust model** | Self-attested | Independently verified | | **Budget tracking** | Yes (plan state) | Governance agent maintains state | | **Ongoing monitoring** | Via `report_plan_outcome` | Via `delivery` phase | The `delivery` phase gives the governance agent real-time visibility into what sellers are actually delivering. The buyer-side `report_plan_outcome` depends on the orchestrator reporting honestly; the `delivery` phase gets reports directly from the seller. The buyer-side and seller-side governance checks hit the same agent — the one registered on the account via `sync_governance`. The orchestrator calls it for intent checks and the seller calls it for execution checks; both conversations reach the same authority with the same plan state. ## Orchestrator integration pattern ```mermaid theme={null} flowchart TD A[Sync plan] --> B[Agent decides to act] B --> C["check_governance(plan_id, tool, payload)"] C --> D{Status?} D -->|approved| E[Send create_media_buy to seller] D -->|conditions| F[Apply conditions] F --> C D -->|denied| G[Log denial, skip action] D -->|async| J[Task goes async — human review internal to governance agent] J --> K[Resolves to approved or denied] K --> C E --> L{Governance agent?} L -->|yes| M["Seller calls check_governance (purchase)"] L -->|no| N[Seller processes normally] M --> O{Approved?} O -->|approved| N O -->|denied| P[Seller rejects media buy] O -->|conditions| Q[Seller adjusts or rejects] N --> R[Receive seller response with planned_delivery] R --> S["report_plan_outcome(plan_id, check_id, outcome)"] S --> T{Status?} T -->|accepted| U[Continue] T -->|findings| V[Review findings, decide next action] V --> U U --> W{Update needed?} W -->|yes| X["check_governance(tool: update_media_buy, payload)"] X --> Y["Seller calls check_governance(governance_context, phase: modification)"] W -->|no| Z{Delivery active?} Z -->|yes| AA["Seller calls check_governance (delivery) periodically"] AA --> AB{Delivery approved?} AB -->|approved| Z AB -->|denied| AC[Seller pauses delivery] AB -->|conditions| AD[Seller adjusts delivery] AD --> Z ``` The governance check is a synchronous call in the orchestrator's action loop. The orchestrator calls `check_governance` with `tool` + `payload` (intent check) before sending requests to sellers. Seller-side execution checks are transparent to the orchestrator — the orchestrator sends the same `create_media_buy` request regardless of whether governance checks are configured. Modification and delivery phase checks happen between the seller and governance agent, independent of the orchestrator's governance loop. ## Audit trail Every plan maintains an ordered audit trail of all validated actions and reported outcomes, retrievable via [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs). The trail includes: * Check ID, timestamp, and tool * The status and category evaluations * Outcome status and committed budget * Any findings from outcome reports * Any internal escalations and their resolutions (recorded by the governance agent) * The human approver identity (when human review occurred internally) * Delivery metrics over time This audit trail serves compliance and reporting needs. For regulated categories (political advertising, financial services), the trail provides evidence that governance was applied to every transaction. ## Conformance testing A conformance test suite for governance agent implementations is planned. Test vectors provide structured input/output pairs -- a plan, a set of policies, a `check_governance` request, and the expected response status and findings. Governance agents can run these vectors to verify that their policy evaluation produces consistent results. The policy registry's exemplars (pass/fail scenarios per policy) provide the raw material. Test vectors formalize these into executable assertions that any governance agent can validate against. The AdCP client test library will include these vectors as part of its standard test suite. ## Property list governance Campaign governance intersects with property governance when media buys reference property lists. A governance agent MAY validate that property lists referenced in media buy requests meet the plan's brand safety and compliance requirements, ensuring that property lists align with the brand's compliance configuration and enforced policies. # check_governance Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/tasks/check_governance check_governance is the universal validation gate in AdCP — orchestrators and sellers call it before executing any campaign action. # check\_governance **Experimental.** Campaign governance (`sync_plans`, `check_governance`, `report_plan_outcome`, `get_plan_audit_logs`) is part of AdCP 3.0 as an experimental surface — it may change between 3.x releases with at least 6 weeks' notice. Sellers implementing it MUST declare `governance.campaign` in `experimental_features`. See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full contract. Universal governance check for campaign actions. Both the orchestrator (buyer-side) and the seller call this task. The governance agent infers the check type from the fields present: | Check type | Who calls | Discriminating fields | Purpose | | ------------- | ------------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | | **Intent** | Orchestrator | `tool` + `payload` | Validates the intended action before sending to a seller. No budget committed. | | **Execution** | Seller | `planned_delivery` + `governance_context` | Validates what the seller will actually deliver. Budget is committed later via `report_plan_outcome`. | The governance agent maintains all state. Callers do not chain check IDs or track conversation history -- they post the action, and the governance agent correlates by `plan_id`. On subsequent lifecycle checks, callers include `governance_context` from the prior response for continuity. An account binds to one governance agent (see [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance) and [One governance agent per account](/dist/docs/3.1.20/governance/campaign/specification#one-governance-agent-per-account)). All lifecycle calls for a governed action go to that same agent. **Per-specialist review surfaces here.** Internal decomposition (legal, brand safety, category) is not exposed as separate endpoints — it appears as agent-internal labels in `categories_evaluated` and, on `denied`, `conditions`, or informational `approved` responses, in `findings[].details`. Treat these values as opaque audit values; do not pattern-match against a fixed list. ## Check types ### Intent checks (orchestrator) The orchestrator calls `check_governance` with `tool` and `payload` before sending a tool call to a seller. The governance agent evaluates the intended action against the campaign plan. 1. Orchestrator decides to call a seller tool (e.g., `create_media_buy`) 2. Orchestrator calls `check_governance` with the tool name and full payload 3. If `approved`, orchestrator sends the tool call to the seller 4. If `denied`, orchestrator does not send the tool call 5. If `conditions`, orchestrator adjusts the payload and re-calls `check_governance` 6. If the governance agent needs human review, the task goes async and eventually resolves to `approved` or `denied` ### Execution checks (seller) The seller calls `check_governance` with `governance_context` and `planned_delivery` when processing a request on an account that has a governance agent configured (set via [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance)). Execution checks are always binding — if the governance agent denies, the seller must not proceed. Before executing the check, the seller verifies the signed `governance_context` token that arrived on the protocol envelope from the buyer. The buyer produces an **intent-phase** token (per the [JWS profile](/dist/docs/3.1.20/building/by-layer/L1/security#adcp-jws-profile)); the seller's own execution check produces the `purchase`/`modification`/`delivery`-phase tokens bound to the assigned `media_buy_id` for the rest of the lifecycle. ``` on receive(create_media_buy request): token = request.envelope.governance_context persist(token) # always persist for audit/forwarding verify(token, { # per Security — Signed Governance Context sellerId: my_adagents_url, planId: request.plan_id, phase: "intent", # buyer produces intent tokens mediaBuyId: null, # intent tokens have no media_buy_id }) # throws on any of 15 checks failing call check_governance(planned_delivery, token) # seller-side execution check — produces purchase-phase token proceed only if governance_agent verdict = approved ``` Sellers that have not yet implemented verification MUST still persist and forward the token unchanged — auditors and regulators rely on this. Verification is the ramp from "forward-only" compliance to cryptographic accountability and can be adopted incrementally. Execution checks cover the full media buy lifecycle through three phases: | Phase | When | What's checked | | -------------- | ------------------------------------ | --------------------------------------------------- | | `purchase` | Before confirming `create_media_buy` | Budget, geo, channels, flight dates, policies | | `modification` | Before confirming `update_media_buy` | Change magnitude, reallocation, new parameters | | `delivery` | Periodically during delivery | Pacing, spend rate, geo drift, channel distribution | Sellers can adopt committed governance checks incrementally: * **Level 1: Purchase only** -- One call per `create_media_buy`. The minimum viable integration. * **Level 2: + Modification** -- One call per `update_media_buy`. * **Level 3: + Delivery reporting** -- Periodic calls during active delivery. ## Invocation requirement When a governance agent is configured on the plan, buyer agents MUST invoke `check_governance` before every spend-commit request (`create_media_buy`, `update_media_buy`, `acquire_rights`, `update_rights`, `activate_signal`, `build_creative`) — full stop. There is no dollar floor, no anomaly threshold, and no cold-start exemption; every commit goes through the governance agent. The buyer-side call is an intent check (`tool` + `payload`), which produces an intent-phase `governance_context` token the buyer attaches to its request to the seller. The governance agent decides internally whether to auto-approve, apply conditions, deny, or escalate to human review per the plan's `budget.reallocation_threshold` and `human_review_required` fields. Seller-side enforcement makes the MUST real through the [signed `governance_context` token](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context): a seller receiving a spend-commit for a plan with a configured governance agent MUST require a valid, in-date intent-phase token (`phase: "intent"`, `sub` equal to the plan\_id, `aud` addressed to this seller), and MUST reject with `PERMISSION_DENIED` otherwise. The seller then performs its own execution check — calling `check_governance` with `planned_delivery` and the received `governance_context` — which produces the `purchase`-phase token bound to the newly assigned `media_buy_id` for the rest of the lifecycle. A buyer that skips the intent check cannot produce a valid intent token, so the commit is rejected before the seller ever reaches its execution check. When no governance agent is configured on the plan, `check_governance` invocation is neither required nor meaningful — there is nothing to call. Sellers MAY refuse to transact on plans lacking a configured governance agent as a matter of their own commercial policy. See the [specification](/dist/docs/3.1.20/governance/campaign/specification#spend-commit-invocation) for the full definition, including audit requirements, seller-side retention MUSTs, and interaction with idempotency. ## Status values | Status | Meaning | Caller action | | ------------ | --------------------------------------- | --------------------------------------------------------------------------- | | `approved` | Proceed as planned. | Act before `expires_at` or re-call. | | `denied` | Do not proceed. | Return error to upstream caller. | | `conditions` | Approved if caller accepts adjustments. | Apply conditions, then re-call `check_governance` with adjusted parameters. | ### Expiration `expires_at` is present when the `verdict` is `approved` or `conditions`. A lapsed approval is no approval -- the caller must re-call `check_governance` before proceeding. ### Conditions When the `verdict` is `conditions`, the caller MUST re-call `check_governance` with adjusted parameters before proceeding. Conditions with a `required_value` are machine-actionable -- the caller can programmatically apply the value. Conditions without a `required_value` are advisory -- the caller should interpret the `reason` and adjust accordingly. Governance agents SHOULD return `denied` (not `conditions`) after 3 unsuccessful re-calls for the same action. This prevents infinite negotiation loops, particularly for seller-side checks where the seller has no visibility into the campaign plan. ### Human review When the governance agent determines that human review is required (e.g., the action exceeds the plan's `reallocation_threshold`, or the plan carries `human_review_required: true`), it handles the escalation internally. The `check_governance` task goes async — the caller receives standard async task lifecycle statuses (`submitted`, `working`) and eventually gets `approved` or `denied` once the human acts. The caller does not need special handling for this case beyond supporting async tasks (see [task lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle)). For `committed` checks (seller-side), the seller sets a timeout. If the governance agent does not respond within the timeout, the seller treats it as `denied` and returns an error to the orchestrator. The orchestrator can re-initiate the media buy after the governance agent resolves. ### Linking to outcomes The response includes a `check_id`. Use this in [`report_plan_outcome`](./report_plan_outcome) to link outcomes to the governance check that authorized them. ## When the governance agent is unavailable If the governance agent is configured and the caller cannot reach it (timeout, network error), the caller MUST NOT proceed. Governance is a gate -- when the gate is unreachable, the default is halt. The caller SHOULD retry with backoff and report the failure upstream. ## Delivery cadence The presence of `next_check` in a response is the signal that the governance agent expects ongoing delivery reporting. The seller SHOULD call no later than the `next_check` time. The governance agent MAY treat a missed deadline as a finding on the next delivery check. ## Request ### Intent check (orchestrator checking before sending to seller) ```json theme={null} { "tool": "check_governance", "arguments": { "plan_id": "plan_q1_2026_launch", "caller": "https://orchestrator.example.com", "tool": "create_media_buy", "payload": { "product_id": "premium_video_300k", "budget": 150000, "currency": "USD", "geo": { "countries": ["US"] }, "channels": ["olv"], "flight": { "start": "2026-03-15T00:00:00Z", "end": "2026-06-15T00:00:00Z" } } } } ``` On the first `check_governance` call, the governance agent extracts what it needs from `payload`. The response includes a `governance_context` string that the caller attaches to the protocol envelope and includes on all subsequent governance calls for this governed action. In 3.0 the governance agent MUST emit a compact JWS signed per the [AdCP JWS profile](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context) so sellers can verify authenticity, authorization scope, and freshness (the 15-step seller checklist). The token also carries a required `plan_hash` audit-layer claim — see [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit) for canonicalization rules, retention obligations, and the eleven reference vectors governance-agent implementers SHOULD validate against before shipping. ### Intent check (rights license) ```json theme={null} { "tool": "check_governance", "arguments": { "plan_id": "plan_acme_summer_2026", "caller": "https://buying.pinnacle-agency.example", "purchase_type": "rights_license", "tool": "acquire_rights", "payload": { "brand": { "domain": "acmeoutdoor.com" }, "right_type": "image_generation", "pricing_option_id": "standard_monthly", "campaign": { "countries": ["US"], "start_date": "2026-04-01", "end_date": "2026-06-30" } } } } ``` ### Execution check -- purchase ```json theme={null} { "tool": "check_governance", "arguments": { "plan_id": "plan_q1_2026_launch", "caller": "https://seller.example.com", "governance_context": "gc_from_buyer_envelope", "phase": "purchase", "planned_delivery": { "geo": { "countries": ["US"] }, "channels": ["olv"], "start_time": "2026-03-15T00:00:00Z", "end_time": "2026-06-15T00:00:00Z", "total_budget": 150000, "currency": "USD", "frequency_cap": { "max_impressions": 3, "per": "user", "window": { "interval": 1, "unit": "days" } }, "audience_summary": "Adults 25-54, US, premium video inventory", "enforced_policies": ["us_coppa"] } } } ``` ### Execution check -- modification ```json theme={null} { "tool": "check_governance", "arguments": { "plan_id": "plan_q1_2026_launch", "caller": "https://seller.example.com", "governance_context": "gc_from_buyer_envelope", "phase": "modification", "modification_summary": "Budget increase from $150,000 to $200,000 and flight extension to 2026-07-15.", "planned_delivery": { "geo": { "countries": ["US"] }, "channels": ["olv"], "start_time": "2026-03-15T00:00:00Z", "end_time": "2026-07-15T00:00:00Z", "total_budget": 200000, "currency": "USD", "frequency_cap": { "max_impressions": 3, "per": "user", "window": { "interval": 1, "unit": "days" } }, "audience_summary": "Adults 25-54, US, premium video inventory", "enforced_policies": ["us_coppa"] } } } ``` ### Execution check -- delivery ```json theme={null} { "tool": "check_governance", "arguments": { "plan_id": "plan_q1_2026_launch", "caller": "https://seller.example.com", "governance_context": "gc_from_buyer_envelope", "phase": "delivery", "planned_delivery": { "geo": { "countries": ["US"] }, "channels": ["olv"], "start_time": "2026-03-15T00:00:00Z", "end_time": "2026-06-15T00:00:00Z", "total_budget": 150000, "currency": "USD", "frequency_cap": { "max_impressions": 3, "per": "user", "window": { "interval": 1, "unit": "days" } }, "audience_summary": "Adults 25-54, US, premium video inventory", "enforced_policies": ["us_coppa"] }, "delivery_metrics": { "reporting_period": { "start": "2026-03-15T00:00:00Z", "end": "2026-03-22T00:00:00Z" }, "spend": 12500, "cumulative_spend": 12500, "impressions": 850000, "cumulative_impressions": 850000, "geo_distribution": { "US": 100 }, "channel_distribution": { "olv": 100 }, "pacing": "on_track", "audience_distribution": { "baseline": "platform", "indices": { "age:18-24": 0.8, "age:25-34": 1.4, "age:35-44": 1.3, "age:45-54": 1.1, "gender:female": 1.05, "gender:male": 0.95 }, "cumulative_indices": { "age:18-24": 0.85, "age:25-34": 1.35, "age:35-44": 1.25, "age:45-54": 1.1, "gender:female": 1.03, "gender:male": 0.97 } } } } } ``` ## Response ### approved (intent check) ```json theme={null} { "check_id": "chk_001", "verdict": "approved", "plan_id": "plan_q1_2026_launch", "explanation": "Proposed create_media_buy is within plan parameters. Budget: $150,000 of $500,000 plan total. Geo: US (within plan). Channel: OLV (within 40-70% target range).", "categories_evaluated": ["budget_authority", "geo_compliance", "channel_compliance", "flight_compliance", "delegation_authority"], "policies_evaluated": ["us_coppa", "alcohol_advertising"], "expires_at": "2026-03-15T01:00:00Z" } ``` The orchestrator proceeds to send the `create_media_buy` to the seller before `expires_at`. ### approved (execution check -- purchase with delivery opt-in) ```json theme={null} { "check_id": "chk_002", "verdict": "approved", "plan_id": "plan_q1_2026_launch", "explanation": "Planned delivery is within plan parameters. Budget: $150,000 of $500,000 plan total. Geo: US (within plan). Channel: OLV (within 40-70% target range).", "mode": "enforce", "expires_at": "2026-03-15T01:00:00Z", "next_check": "2026-03-22T00:00:00Z" } ``` The seller proceeds with the media buy. The presence of `next_check` signals that the governance agent expects delivery reporting starting at that time. ### approved (execution check -- delivery) ```json theme={null} { "check_id": "chk_003", "verdict": "approved", "plan_id": "plan_q1_2026_launch", "explanation": "Delivery on track. Week 1 spend: $12,500 of $150,000 (8.3%). Pacing is on target for 13-week flight. Geo and channel distribution match plan parameters.", "next_check": "2026-03-29T00:00:00Z" } ``` The seller continues delivery and schedules the next governance check for `next_check`. ### denied (intent check) ```json theme={null} { "check_id": "chk_004", "verdict": "denied", "plan_id": "plan_q1_2026_launch", "explanation": "Proposed media buy targets CA (Canada) which is not within the plan's geography.", "findings": [ { "category_id": "strategic_alignment", "severity": "critical", "explanation": "Geo targeting includes CA but plan only covers US.", "details": { "plan_countries": ["US"], "payload_countries": ["US", "CA"] } } ] } ``` The orchestrator MUST NOT send the tool call to the seller. ### denied (execution check -- delivery geo drift) ```json theme={null} { "check_id": "chk_005", "verdict": "denied", "plan_id": "plan_q1_2026_launch", "explanation": "Delivery has drifted outside plan parameters. 12% of impressions delivered in CA (Canada) which is not within the plan's geography.", "findings": [ { "category_id": "strategic_alignment", "severity": "critical", "confidence": 0.98, "explanation": "Geo distribution shows 12% delivery in CA, but plan only covers US.", "details": { "plan_countries": ["US"], "actual_distribution": { "US": 88, "CA": 12 } } } ] } ``` The seller MUST pause delivery immediately and correct the geo targeting before resuming. ### conditions (execution check -- purchase) ```json theme={null} { "check_id": "chk_006", "verdict": "conditions", "plan_id": "plan_q1_2026_launch", "explanation": "Budget approved but frequency cap must be applied per brand policy.", "conditions": [ { "field": "planned_delivery.frequency_cap", "required_value": { "max_impressions": 5, "per": "user", "window": { "interval": 1, "unit": "days" } }, "reason": "Brand policy requires daily frequency cap of 5 or fewer impressions per user." } ], "expires_at": "2026-03-15T01:00:00Z" } ``` The seller MUST adjust its planned delivery, then re-call `check_governance` with the updated parameters before proceeding. ### conditions (execution check -- delivery overpacing) ```json theme={null} { "check_id": "chk_007", "verdict": "conditions", "plan_id": "plan_q1_2026_launch", "explanation": "Delivery is pacing 40% ahead of schedule. Cumulative spend of $42,000 after 2 weeks exceeds expected $23,000 for this point in the flight.", "conditions": [ { "field": "pacing", "reason": "Reduce daily spend rate to align with the planned flight duration. At current pace, budget will be exhausted by week 7 of 13." } ], "next_check": "2026-03-31T00:00:00Z" } ``` The seller MUST adjust pacing and re-call `check_governance` immediately. The `next_check` is set closer than normal so the governance agent can verify the correction. ## Fields ### Request | Field | Type | Required | Description | | ------------------------------------------------------------- | ------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plan_id` | string | Yes | Campaign governance plan identifier. | | `caller` | string (URI) | Yes | URL of the agent making the request. | | `purchase_type` | enum | No | The kind of financial commitment being validated: `media_buy` (default), `rights_license`, `signal_activation`, or `creative_services`. When omitted, the governance agent assumes `media_buy`. | | `tool` | string | Intent | The AdCP tool being checked. Present on intent checks (orchestrator). The governance agent uses the presence of `tool` + `payload` to identify an intent check. | | `payload` | object | Intent | Full tool arguments as they would be sent to the seller. Present on intent checks. | | `governance_context` | string | No | Governance context token from a prior `check_governance` response. Include on subsequent lifecycle checks so the governance agent can maintain continuity. On execution checks, the governance agent uses `governance_context` + `planned_delivery` to identify the check. This is the sole lifecycle correlator across all purchase types. See [Signed Governance Context](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context) for the JWS profile and seller verification; see [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit) for the `plan_hash` audit-layer claim. | | `phase` | enum | Execution | `purchase`, `modification`, or `delivery`. Defaults to `purchase`. Present on execution checks. | | `planned_delivery` | object | Execution | What will actually be delivered. Present on execution checks. See [planned delivery](/dist/docs/3.1.20/governance/campaign/specification#integration-with-create_media_buy). | | `delivery_metrics` | object | Delivery | Actual delivery performance data. Required when `phase` is `delivery`. | | `delivery_metrics.audience_distribution` | object | No | Audience demographic composition relative to a baseline. Used for bias/fairness drift detection. | | `delivery_metrics.audience_distribution.baseline` | enum | Yes | Reference population: `census` (national population), `platform` (platform's user base), or `custom`. | | `delivery_metrics.audience_distribution.baseline_description` | string | No | Description of the baseline when `baseline` is `custom` (e.g., "US adults 18+ with broadband access"). | | `delivery_metrics.audience_distribution.indices` | object | Yes | Index values for the current reporting period. Key format: `dimension:value` (e.g., `age:25-34`, `gender:female`). Value of 1.0 means parity with baseline; above 1.0 means over-indexed; below 1.0 means under-indexed. | | `delivery_metrics.audience_distribution.cumulative_indices` | object | No | Index values across all reporting periods. Same format as `indices`. Helps governance agents detect trends vs. one-period noise. | | `modification_summary` | string | No | Human-readable summary of what changed. SHOULD be present for `modification` phase. | ### Delivery metrics | Field | Type | Description | | ------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `reporting_period` | object | Reporting window with `start` and `end` timestamps (ISO 8601). Required. | | `spend` | number | Spend during the reporting period. | | `cumulative_spend` | number | Total spend since the media buy started. | | `impressions` | integer | Impressions during the reporting period. | | `cumulative_impressions` | integer | Total impressions since the media buy started. | | `geo_distribution` | object | Actual geographic distribution. Keys are ISO 3166-1 alpha-2 codes, values are percentages. | | `channel_distribution` | object | Actual channel distribution. Keys are values from the channels enum, values are percentages. | | `pacing` | enum | `ahead`, `on_track`, or `behind`. | | `audience_distribution` | object | Audience composition relative to a baseline. Contains `baseline` (enum), optional `baseline_description` (string, for custom baselines), `indices` (current period), and optional `cumulative_indices` (all periods). Keys are `dimension:value` strings, values are index numbers (1.0 means parity). | ### Response | Field | Type | Description | | ---------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `check_id` | string | Unique identifier for this governance check. Use in `report_plan_outcome` to link outcomes. | | `verdict` | enum | `approved`, `denied`, or `conditions`. | | `plan_id` | string | Echoed from request. | | `explanation` | string | Human-readable explanation of the decision. | | `findings` | array | Per-category issues found. Present when `verdict` is `denied` or `conditions`. MAY also be present on `approved` for informational findings. Each finding has `category_id`, `severity`, `explanation`, and optionally `policy_id`, `details`, `confidence` (0-1), and `uncertainty_reason`. `category_id` is an **agent-internal label**, not a protocol-level enum — treat as opaque for display/audit, not for machine pattern-matching. | | `conditions` | array | Present when `verdict` is `conditions`. Adjustments the caller must make before re-calling. | | `categories_evaluated` | string\[] | Governance categories evaluated during this check (e.g., `budget_authority`, `geo_compliance`, `channel_compliance`). **Agent-internal labels** — each string is defined by the governance agent's policy model and is how internal specialist review (legal, brand safety, category) surfaces for audit from behind the agent's single endpoint. Not a protocol enum; not safe to pattern-match against a fixed list. | | `policies_evaluated` | string\[] | Registry policy IDs evaluated during this check. | | `mode` | enum | `audit`, `advisory`, or `enforce` — governance mode active when this check was evaluated. Recorded by the governance agent from its runtime configuration at check time, not from a plan field. Lets counterparties, regulators, and auditors distinguish whether an `approved` decision reflects deliberate `enforce` enforcement or `audit`-mode silent logging. | | `expires_at` | string | Present when `verdict` is `approved` or `conditions`. The caller must act before this time or re-call. A lapsed approval is no approval. | | `next_check` | string | When the seller should next call `check_governance` with delivery metrics. Present when the governance agent expects ongoing delivery reporting. | | `governance_context` | string | Governance context token for this governed action. Present when `verdict` is `approved` or `conditions`. Attach to the protocol envelope and include on all subsequent governance calls. This is the sole lifecycle correlator for all purchase types. See [Signed Governance Context](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context) for the JWS profile and seller verification; see [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit) for the `plan_hash` audit-layer claim. Sellers that do not verify MUST still persist and forward the token verbatim. | | `authority_remaining` | object | Buyer-side plan budget authority remaining after this check — not the seller's allocated budget. Present when `verdict` is `approved` or `conditions` for execution checks. Contains `budget_remaining` (number), `currency` (string), and `budget_used_pct` (number, 0-100). Orchestrators use this to track plan-level spend against the media plan's total authority. | ## Error codes | Code | Recovery | Description | | -------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PLAN_NOT_FOUND` | correctable | No plan with this ID. The buyer may not have synced the plan yet. | | `INVALID_REQUEST` | correctable | Request contains both intent fields (`tool` + `payload`) and execution fields (`governance_context` + `planned_delivery`), so the check type cannot be inferred. Send one set or the other. | | `CAMPAIGN_SUSPENDED` | correctable | Campaign governance is suspended pending human review. | | `PERMISSION_DENIED` | correctable | The caller URL is not in the plan's `approved_sellers` list. | ## Related tasks * [`sync_plans`](./sync_plans) -- The plan this governance check validates against * [`report_plan_outcome`](./report_plan_outcome) -- Report what happened after the action was confirmed * [`get_plan_audit_logs`](./get_plan_audit_logs) -- View plan state and audit trail # get_plan_audit_logs Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs get_plan_audit_logs retrieves governance state, budget tracking, and a complete audit trail for AdCP campaign plans or portfolios. # get\_plan\_audit\_logs **Experimental.** Campaign governance (`sync_plans`, `check_governance`, `report_plan_outcome`, `get_plan_audit_logs`) is part of AdCP 3.0 as an experimental surface — it may change between 3.x releases with at least 6 weeks' notice. Sellers implementing it MUST declare `governance.campaign` in `experimental_features`. See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full contract. Retrieve governance state and audit trail for a plan, multiple plans, or an entire portfolio. Returns budget tracking, validation history, and compliance summary. ## Request ```json theme={null} { "tool": "get_plan_audit_logs", "arguments": { "plan_ids": ["plan_q1_2026_launch"], "include_entries": true } } ``` Results are grouped by `governance_context` within each plan. Each governed action (media buy, rights license, signal activation, creative service) appears under its `governance_context`. **Multiple plans** -- retrieve specific plans in one call: ```json theme={null} { "tool": "get_plan_audit_logs", "arguments": { "plan_ids": ["plan_q1_2026_launch", "plan_q1_2026_emea"], "include_entries": false } } ``` **Portfolio query** -- retrieve combined audit data for all member plans in one or more portfolios: ```json theme={null} { "tool": "get_plan_audit_logs", "arguments": { "portfolio_plan_ids": ["portfolio_nova_brands_2026"], "include_entries": true } } ``` You can combine `plan_ids` and `portfolio_plan_ids` to query both specific plans and portfolios in a single call. ## Response ```json theme={null} { "plans": [ { "plan_id": "plan_q1_2026_launch", "plan_version": 1, "status": "active", "budget": { "authorized": 500000, "committed": 425000, "remaining": 75000, "utilization_pct": 85 }, "channel_allocation": { "olv": { "committed": 275000, "pct": 55 }, "display": { "committed": 150000, "pct": 30 } }, "governed_actions": [ { "governance_context": "gc_mb_seller_456", "purchase_type": "media_buy", "status": "active", "committed": 275000, "check_count": 8 }, { "governance_context": "gc_mb_seller_789", "purchase_type": "media_buy", "status": "active", "committed": 150000, "check_count": 7 }, { "governance_context": "gc_rights_acme_img", "purchase_type": "rights_license", "status": "active", "committed": 75000, "check_count": 2 } ], "summary": { "checks_performed": 14, "outcomes_reported": 12, "statuses": { "approved": 12, "denied": 1, "conditions": 1, "human_reviewed": 1 // also counted in approved or denied above; never counted in conditions (an agent-issued flow-control state, not a human resolution) }, "findings_count": 2, "escalations": [ { "check_id": "chk_esc_001", "reason": "Budget reallocation exceeds threshold", "resolution": "approved_by_human", "resolved_at": "2026-03-16T09:30:00Z" } ], "drift_metrics": { "escalation_rate": 0.07, "escalation_rate_trend": "stable", "auto_approval_rate": 0.80, "human_override_rate": 0.02, "mean_confidence": 0.88, "thresholds": { "escalation_rate_max": 0.20, "escalation_rate_min": 0.02, "auto_approval_rate_max": 0.95, "human_override_rate_max": 0.15 } } }, "entries": [ { "id": "chk_001", "type": "check", "timestamp": "2026-03-10T10:05:00Z", "caller": "https://orchestrator.pinnacle-media.com/agent", "tool": "get_products", "check_type": "intent", "verdict": "approved", "explanation": "Product discovery within budget and channel constraints.", "categories_evaluated": ["budget_authority", "strategic_alignment"], "policies_evaluated": ["us_coppa"] }, { "id": "chk_003", "type": "check", "timestamp": "2026-03-15T11:05:00Z", "caller": "https://ads.seller-example.com/adcp", "tool": "create_media_buy", "check_type": "execution", "mode": "enforce", "governance_context": "gc_mb_seller_456", "plan_hash": "oR0jFDEtzcwgPbNf-Ofd_fZHYfAyD1TRbzGOFBVCG-c", "purchase_type": "media_buy", "verdict": "approved", "explanation": "Media buy within plan budget ($150,000 of $500,000 remaining). Geo targeting matches authorized markets. COPPA compliance verified.", "categories_evaluated": ["budget_authority", "regulatory_compliance", "brand_policy"], "policies_evaluated": ["us_coppa", "alcohol_advertising"], "findings": [ { "category_id": "budget_authority", "severity": "info", "explanation": "Budget utilization at 70% after this buy." } ] }, { "id": "out_001", "type": "outcome", "timestamp": "2026-03-15T11:10:00Z", "caller": "https://orchestrator.pinnacle-media.com/agent", "governance_context": "gc_mb_seller_456", "purchase_type": "media_buy", "outcome": "completed", "committed_budget": 150000 }, { "id": "out_del_001", "type": "outcome", "timestamp": "2026-03-22T00:00:00Z", "caller": "https://ads.seller-example.com/adcp", "governance_context": "gc_mb_seller_456", "purchase_type": "media_buy", "outcome": "delivery", "outcome_status": "accepted" } ] } ] } ``` ## Fields ### Request | Field | Type | Required | Description | | --------------------- | --------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plan_ids` | string\[] | At least one of `plan_ids`, `portfolio_plan_ids`, or `governance_contexts` | Plan IDs to retrieve. | | `portfolio_plan_ids` | string\[] | At least one of `plan_ids`, `portfolio_plan_ids`, or `governance_contexts` | Portfolio plan IDs. Expanded to member plans. | | `governance_contexts` | string\[] | No | Filter results to specific governed actions by their `governance_context` values. When present, only entries and summaries for the matching governed actions are returned. | | `purchase_types` | string\[] | No | Filter results by purchase type (e.g., `["rights_license"]` to see all rights activity). When present, only entries and summaries matching these purchase types are returned. | | `include_entries` | boolean | No | Include the full audit trail. Default: `false`. | ### Response | Field | Type | Description | | ------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plans` | array | Audit data for each requested plan. | | `plans[].plan_id` | string | Plan identifier. | | `plans[].plan_version` | number | Current plan version. | | `plans[].status` | enum | `active`, `suspended`, or `completed`. | | `plans[].budget` | object | Budget state. | | `plans[].budget.authorized` | number | Total authorized budget from the plan. | | `plans[].budget.committed` | number | Total budget committed from confirmed outcomes. | | `plans[].budget.remaining` | number | Authorized minus committed. | | `plans[].budget.utilization_pct` | number | Committed as a percentage of authorized. | | `plans[].channel_allocation` | object | Current channel mix. Keyed by channel ID. | | `plans[].channel_allocation[channel].committed` | number | Budget committed to this channel. | | `plans[].channel_allocation[channel].pct` | number | Channel's share of the authorized total budget. | | `plans[].governed_actions` | array | Per-governed-action breakdown, grouped by `governance_context`. | | `plans[].governed_actions[].governance_context` | string | Opaque governance context identifying this governed action. | | `plans[].governed_actions[].purchase_type` | enum | `media_buy`, `rights_license`, `signal_activation`, or `creative_services`. | | `plans[].governed_actions[].status` | enum | `active`, `suspended`, or `completed`. | | `plans[].governed_actions[].committed` | number | Budget committed for this governed action. | | `plans[].governed_actions[].check_count` | integer | Number of governance checks performed. | | `plans[].summary` | object | Aggregate validation and outcome statistics. | | `plans[].summary.checks_performed` | number | Total governance checks performed. | | `plans[].summary.outcomes_reported` | number | Total outcomes reported. | | `plans[].summary.statuses` | object | Count of each governance check status (`approved`, `denied`, `conditions`). Also includes `human_reviewed` as a supplementary count — checks that required human review before resolving. Always a subset of `approved` + `denied`; `conditions` is an agent-issued flow-control state and is never counted here. | | `plans[].summary.findings_count` | number | Total findings across all checks and outcomes. | | `plans[].summary.escalations` | array | Checks that were escalated for human review and their resolutions. | | `plans[].summary.escalations[].check_id` | string | The governance check that was escalated. | | `plans[].summary.escalations[].reason` | string | Why the check was escalated. | | `plans[].summary.escalations[].resolution` | string | How it was resolved (e.g., `approved_by_human`, `rejected_by_human`). | | `plans[].summary.escalations[].resolved_at` | string | ISO 8601 resolution timestamp. | | `plans[].summary.drift_metrics` | object | Aggregate governance metrics for detecting oversight drift. See [specification](/dist/docs/3.1.20/governance/campaign/specification#drift-detection). | | `plans[].summary.drift_metrics.escalation_rate` | number | Fraction of checks that were escalated for human review (0-1). | | `plans[].summary.drift_metrics.escalation_rate_trend` | enum | `increasing`, `stable`, or `declining`. | | `plans[].summary.drift_metrics.auto_approval_rate` | number | Fraction of checks approved without human intervention (0-1). | | `plans[].summary.drift_metrics.human_override_rate` | number | Fraction of escalations where the human overrode the governance agent's recommendation (0-1). | | `plans[].summary.drift_metrics.mean_confidence` | number | Average confidence score across findings (0-1). Present when findings include confidence. | | `plans[].summary.drift_metrics.thresholds` | object | Organization-defined thresholds for drift metrics. When a metric crosses its threshold, the governance agent includes a finding. | | `plans[].summary.drift_metrics.thresholds.escalation_rate_max` | number | Maximum acceptable escalation rate. A rate above this may indicate policy miscalibration. | | `plans[].summary.drift_metrics.thresholds.escalation_rate_min` | number | Minimum acceptable escalation rate. A rate below this may indicate eroding oversight. | | `plans[].summary.drift_metrics.thresholds.auto_approval_rate_max` | number | Maximum acceptable auto-approval rate. | | `plans[].summary.drift_metrics.thresholds.human_override_rate_max` | number | Maximum acceptable human override rate. | | `plans[].entries` | array | Ordered audit trail (only when `include_entries` is `true`). | | `plans[].entries[].id` | string | Entry identifier. | | `plans[].entries[].type` | enum | `check` or `outcome`. | | `plans[].entries[].timestamp` | string | ISO 8601 timestamp. | | `plans[].entries[].plan_id` | string | Plan this entry belongs to. Present when querying multiple plans or a portfolio. | | `plans[].entries[].caller` | string | URL of the agent that made the request. Resolved from the credentials used on the governance callback. | | `plans[].entries[].tool` | string | The AdCP tool (present for `check` entries). | | `plans[].entries[].status` | enum | Governance check status (present for `check` entries). | | `plans[].entries[].check_type` | enum | `intent` or `execution` (present for `check` entries). Inferred from the fields present on the original check request. | | `plans[].entries[].mode` | enum | `audit`, `advisory`, or `enforce` — governance mode active when this check was evaluated. Recorded by the governance agent from its runtime configuration at check time, not from a plan field. Present on check entries; absent on outcome entries and on governance agents that have not yet adopted this field. Lets auditors distinguish `approved` decisions made under `enforce` from those made under `audit` (where the agent could not have blocked anything). | | `plans[].entries[].explanation` | string | Human-readable explanation of the governance decision (present for `check` entries). | | `plans[].entries[].policies_evaluated` | array | Registry policy IDs evaluated during this check. | | `plans[].entries[].categories_evaluated` | array | Governance categories evaluated (e.g., `budget_authority`, `regulatory_compliance`). **Agent-internal labels**, not a protocol-level enum — each string is defined by the governance agent's policy model. Since one governance agent per account composes all specialist review (legal, brand safety, category-specialist reviewers) behind its single endpoint, `categories_evaluated` and `findings[].details` are how internal decomposition surfaces for audit. Consumers MUST treat values as opaque labels: pattern-matching against a fixed list is not safe across agents. | | `plans[].entries[].findings` | array | Findings from this check, including category, severity, policy ID, explanation, and confidence. | | `plans[].entries[].governance_context` | string | Opaque governance context identifying the governed action this entry belongs to. | | `plans[].entries[].plan_hash` | string | Audit-layer binding to the plan revision this attestation was evaluated over — `base64url_no_pad(SHA-256(JCS(plan_payload)))` per [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit). Present on `check` entries. Auditors and buyer-side compliance tooling verify by recomputing over the retained plan revision and byte-comparing the decoded 32-byte digests. | | `plans[].entries[].purchase_type` | enum | `media_buy`, `rights_license`, `signal_activation`, or `creative_services`. Present when the entry is associated with a specific governed action. | | `plans[].entries[].outcome` | enum | Outcome type (present for `outcome` entries). | | `plans[].entries[].committed_budget` | number | Budget committed (present for `completed` outcome entries). | | `plans[].entries[].outcome_status` | string | Outcome status (present for `outcome` entries). | ## Authorization The request schema does not carry an envelope `account` field — tenant identity is resolved from the submitted IDs. The governance agent MUST verify that the authenticated principal is authorized for every `plan_ids` member, every plan expanded from `portfolio_plan_ids`, and every governed action addressed by `governance_contexts`. When any element fails the authorization check the governance agent MUST fail closed with a generic `PLAN_NOT_FOUND` response — the error body MUST NOT distinguish "unauthorized" from "not found" or name the offending ID. See [Agent and Account Isolation](/dist/docs/3.1.20/building/by-layer/L1/security#agent-and-account-isolation) for the pattern and existence-leak guardrails. ## Error codes | Code | Recovery | Description | | ---------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PLAN_NOT_FOUND` | correctable | No plan with this ID, or the authenticated principal is not authorized for it. The two cases MUST be indistinguishable in the response to prevent plan-ID enumeration. | ## Related tasks * [`sync_plans`](./sync_plans) -- Push or update plans * [`check_governance`](./check_governance) -- Validate actions against the plan * [`report_plan_outcome`](./report_plan_outcome) -- Report outcomes to update plan state # Task reference Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/tasks/index Task reference for AdCP campaign governance — sync_plans, check_governance, report_plan_outcome, and get_plan_audit_logs. # Campaign Governance tasks Campaign Governance has four tasks. | Task | Direction | Description | | ---------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`sync_plans`](./sync_plans) | Orchestrator → Gov | Push campaign plans with budget, channels, flight dates, and authorized markets | | [`check_governance`](./check_governance) | Orchestrator or Seller → Gov | Universal governance check. Orchestrator sends `tool` + `payload` (intent check). Seller sends `governance_context` + `planned_delivery` (execution check). | | [`report_plan_outcome`](./report_plan_outcome) | Orchestrator → Gov | Post-action report: "here's what happened." Gov updates state and returns any findings | | [`get_plan_audit_logs`](./get_plan_audit_logs) | Orchestrator → Gov | Read governance state, budget burn, and audit trail | `sync_plans` configures the rules. `check_governance` is the universal governance gate — both the orchestrator and the seller call it to validate actions against the plan. `report_plan_outcome` closes the loop after execution. `get_plan_audit_logs` is read-only, for monitoring and reporting. **Setup prerequisite:** [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance) syncs governance agent endpoints to accounts. This must be done before sellers can call `check_governance`. # report_plan_outcome Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/tasks/report_plan_outcome report_plan_outcome sends action results to the AdCP governance agent so it can update budget tracking, state, and compliance records. # report\_plan\_outcome **Experimental.** Campaign governance (`sync_plans`, `check_governance`, `report_plan_outcome`, `get_plan_audit_logs`) is part of AdCP 3.0 as an experimental surface — it may change between 3.x releases with at least 6 weeks' notice. Sellers implementing it MUST declare `governance.campaign` in `experimental_features`. See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full contract. Report the outcome of an action to the governance agent. Called by the orchestrator (buyer-side agent) after a seller responds. This is the "after" half of the governance loop -- it tells the governance agent what actually happened so it can update its state and flag any issues. Sellers do not call this task. They report delivery data via [`check_governance`](./check_governance) with `phase: "delivery"`. ## Seller response (after `create_media_buy`) ```json theme={null} { "tool": "report_plan_outcome", "arguments": { "plan_id": "plan_q1_2026_launch", "check_id": "chk_xyz789", "governance_context": "gc_mb_seller_456", "outcome": "completed", "seller_response": { "seller_reference": "mb_seller_456", "packages": [ { "package_id": "pkg_001", "product_id": "premium_video_300k", "budget": 150000, "targeting_overlay": { "geo": { "include": [{ "type": "country", "code": "US" }] }, "viewability": { "standard": "mrc", "threshold": 50 } } } ], "planned_delivery": { "geo": { "countries": ["US"] }, "channels": ["olv"], "start_time": "2026-03-15T00:00:00Z", "end_time": "2026-06-15T00:00:00Z", "total_budget": 150000, "currency": "USD" }, "creative_deadline": "2026-03-20T00:00:00Z" } } } ``` ### Response (no issues) ```json theme={null} { "outcome_id": "out_001", "outcome_state": "accepted", "committed_budget": 150000, "plan_summary": { "total_committed": 425000, "budget_remaining": 75000 } } ``` The governance agent updates its state: budget is now committed based on the seller's confirmed amount, and the media buy is tracked. ### Response (discrepancy found) In this alternative scenario for the same action, the seller modified the request: ```json theme={null} { "outcome_id": "out_002", "outcome_state": "findings", "committed_budget": 120000, "findings": [ { "category_id": "seller_verification", "severity": "warning", "explanation": "Seller reduced budget from $150,000 to $120,000 and added geo targeting for CA that was not requested.", "details": { "discrepancies": [ { "field": "packages[0].budget", "requested": 150000, "received": 120000 }, { "field": "packages[0].targeting_overlay.geo.include", "requested": ["US"], "received": ["US", "CA"] } ] } } ], "plan_summary": { "total_committed": 395000, "budget_remaining": 105000 } } ``` The governance agent still commits the seller's actual amount (\$120K, not the requested \$150K) but returns findings for the orchestrator to act on. ## Delivery data (periodic reporting) ```json theme={null} { "tool": "report_plan_outcome", "arguments": { "plan_id": "plan_q1_2026_launch", "governance_context": "gc_mb_seller_456", "outcome": "delivery", "delivery": { "reporting_period": { "start": "2026-03-15T00:00:00Z", "end": "2026-03-22T00:00:00Z" }, "impressions": 1250000, "spend": 18750, "cpm": 15.00, "viewability_rate": 0.72, "completion_rate": 0.65 } } } ``` ### Response (on track) ```json theme={null} { "outcome_id": "out_del_001", "outcome_state": "accepted" } ``` ### Response (anomaly detected) ```json theme={null} { "outcome_id": "out_del_002", "outcome_state": "findings", "findings": [ { "category_id": "budget_authority", "severity": "warning", "explanation": "Spend is pacing 62% above plan. At current rate, budget will be exhausted 5 weeks early.", "details": { "planned_weekly_spend": 11538, "actual_weekly_spend": 18750, "overpace_pct": 62, "projected_exhaustion": "2026-05-03T00:00:00Z" } } ] } ``` ## Failed actions If the seller rejected the request, report it so the governance agent can update plan state: ```json theme={null} { "tool": "report_plan_outcome", "arguments": { "plan_id": "plan_q1_2026_launch", "check_id": "chk_xyz789", "governance_context": "gc_mb_seller_456", "outcome": "failed", "error": { "code": "PRODUCT_UNAVAILABLE", "message": "Product premium_video_300k is no longer available." } } } ``` ```json theme={null} { "outcome_id": "out_003", "outcome_state": "accepted", "committed_budget": 0, "plan_summary": { "total_committed": 275000, "budget_remaining": 225000 } } ``` ## Fields ### Request | Field | Type | Required | Description | | ----------------------------------- | ------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `plan_id` | string | Yes | The plan this outcome is for. | | `check_id` | string | Conditional | The `check_id` from `check_governance`. Links the outcome to the governance check that authorized it. Required for `completed` and `failed` outcomes. | | `purchase_type` | enum | No | The kind of financial commitment: `media_buy` (default), `rights_license`, `signal_activation`, or `creative_services`. When omitted, the governance agent assumes `media_buy`. | | `outcome` | enum | Yes | `completed`, `failed`, or `delivery`. | | `seller_response` | object | No | The seller's full response. Required when outcome is `completed`. | | `seller_response.seller_reference` | string | No | The seller's identifier for the created resource (e.g., `media_buy_id`, `rights_grant_id`, `deployment_id`). Not interpreted by the governance agent — included in audit logs for human-readable traceability alongside the opaque `governance_context`. | | `seller_response.committed_budget` | number | No | Total budget committed across all confirmed packages. When present, the governance agent uses this directly instead of summing individual package budgets. | | `seller_response.packages` | array | No | Confirmed packages with actual budget and targeting. | | `seller_response.planned_delivery` | object | No | What the seller said it will deliver. When seller-side governance is not configured, this is the governance agent's only view of the seller's delivery parameters. | | `seller_response.creative_deadline` | string | No | ISO 8601 deadline for creative submission. | | `delivery` | object | No | Delivery metrics. Required when outcome is `delivery`. The `governance_context` (already required on all governance calls) is the sole lifecycle correlator. | | `delivery.reporting_period` | object | No | Start and end timestamps for the reporting window. | | `delivery.impressions` | integer | No | Impressions delivered in the period. | | `delivery.spend` | number | No | Spend in the period. | | `delivery.cpm` | number | No | Effective CPM for the period. | | `delivery.viewability_rate` | number | No | Viewability rate (0-1). | | `delivery.completion_rate` | number | No | Video completion rate (0-1). | | `error` | object | No | Error details. Required when outcome is `failed`. | | `error.code` | string | No | Error code from the seller. | | `error.message` | string | No | Human-readable error description. | ### Response | Field | Type | Description | | ------------------------ | ------ | ------------------------------------------------------------------------------- | | `outcome_id` | string | Unique identifier for this outcome record. | | `outcome_state` | enum | `accepted` (state updated, no issues) or `findings` (issues detected). | | `committed_budget` | number | Budget committed from this outcome (present for `completed`/`failed` outcomes). | | `findings` | array | Present only when `outcome_state` is `findings`. | | `findings[].category_id` | string | Which validation category flagged the issue. | | `findings[].severity` | enum | `info`, `warning`, or `critical`. | | `findings[].explanation` | string | Human-readable description of the issue. | | `findings[].details` | object | Structured details for programmatic consumption. | | `plan_summary` | object | Updated plan budget state (present for `completed`/`failed` outcomes). | ## Error codes | Code | Recovery | Description | | --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PLAN_NOT_FOUND` | correctable | No plan with this ID, or the plan is not accessible to the calling account. | | `REFERENCE_NOT_FOUND` | correctable | `check_id` does not resolve to a governance check, or `governance_context` does not resolve to a campaign in the plan. `error.field` MUST identify which typed parameter failed to resolve. | | `CAMPAIGN_SUSPENDED` | correctable | Plan is suspended pending human review. Outcome reporting is blocked until the escalation is resolved. | ## Related tasks * [`check_governance`](./check_governance) -- The governance check that authorized the action * [`sync_plans`](./sync_plans) -- Push or update the plan * [`get_plan_audit_logs`](./get_plan_audit_logs) -- View plan state and audit trail # sync_plans Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/campaign/tasks/sync_plans sync_plans pushes campaign plans with budget limits, channels, flight dates, and compliance policies to an AdCP governance agent. # sync\_plans **Experimental.** Campaign governance (`sync_plans`, `check_governance`, `report_plan_outcome`, `get_plan_audit_logs`) is part of AdCP 3.0 as an experimental surface — it may change between 3.x releases with at least 6 weeks' notice. Sellers implementing it MUST declare `governance.campaign` in `experimental_features`. See [experimental status](/dist/docs/3.1.20/reference/experimental-status) for the full contract. Push campaign plans to the governance agent. A plan defines the authorized parameters for a campaign -- budget limits, channels, flight dates, authorized markets, and compliance policies -- and serves as the source of truth for all validation. ## Request ```json theme={null} { "tool": "sync_plans", "arguments": { "plans": [ { "plan_id": "plan_q1_2026_launch", "brand": { "domain": "acmecorp.com" }, "objectives": "Drive awareness for spring product launch among 25-54 adults in the US, focusing on premium video and high-impact display.", "budget": { "total": 500000, "currency": "USD", "reallocation_threshold": 25000, "per_seller_max_pct": 40, "allocations": { "media_buy": { "amount": 400000 }, "rights_license": { "amount": 75000 }, "signal_activation": { "amount": 25000 } } }, "channels": { "required": ["olv"], "allowed": ["olv", "display", "ctv", "audio"], "mix_targets": { "olv": { "min_pct": 40, "max_pct": 70 }, "display": { "min_pct": 10, "max_pct": 30 }, "ctv": { "min_pct": 0, "max_pct": 20 }, "audio": { "min_pct": 0, "max_pct": 10 } } }, "flight": { "start": "2026-03-15T00:00:00Z", "end": "2026-06-15T00:00:00Z" }, "countries": ["US"], "policy_categories": ["age_restricted"], "audience": { "include": [ { "type": "description", "description": "Adults 25-54 interested in home improvement" } ], "exclude": [ { "type": "description", "description": "Children under 13" } ] }, "restricted_attributes": ["health_data"], "min_audience_size": 1000, "policy_ids": ["us_coppa", "alcohol_advertising"], "custom_policies": [ { "policy_id": "no_competitor_adjacency", "enforcement": "must", "policy": "No advertising adjacent to competitor content." } ], "approved_sellers": null, "ext": {} } ] } } ``` ## Response ```json theme={null} { "plans": [ { "plan_id": "plan_q1_2026_launch", "status": "active", "version": 1, "categories": [ { "category_id": "budget_authority", "status": "active" }, { "category_id": "strategic_alignment", "status": "active" }, { "category_id": "bias_fairness", "status": "active" }, { "category_id": "regulatory_compliance", "status": "active" }, { "category_id": "seller_verification", "status": "active" }, { "category_id": "brand_policy", "status": "active" } ], "resolved_policies": [ { "policy_id": "us_coppa", "source": "explicit", "enforcement": "must", "reason": "Referenced in plan policy_ids" }, { "policy_id": "alcohol_advertising", "source": "explicit", "enforcement": "should", "reason": "Referenced in plan policy_ids" } ] } ] } ``` ## How it works Plans originate in external systems -- an agency's planning tool, a brand's budget system, an insertion order. `sync_plans` pushes them to the governance agent so it knows what to validate against. Syncing a plan that already exists (same `plan_id`) updates it. The governance agent increments the version and re-evaluates any active campaigns against the updated rules. This handles mid-flight amendments like budget increases or channel additions. Content-standards versions follow a separate pinned-at-buy rule — see [content standards versioning](/dist/docs/3.1.20/governance/content-standards/index#versioning-and-mid-flight-amendments). Multiple campaigns (identified by `governance_context` in `check_governance` and `report_plan_outcome`) can reference the same plan. The governance agent tracks budget across all campaigns tied to a plan. The plan specifies campaign context -- budget, channels, flight dates, and authorized markets. The governance agent resolves applicable policies from the brand's compliance configuration, but plans can also reference registry policies directly via `policy_ids` and include campaign-specific rules via `custom_policies`. This supports both centralized policy management (brand-level) and campaign-specific overrides when the buying team needs additional requirements for a particular campaign. `countries` and `regions` serve two purposes: 1. **Geo enforcement** -- The governance agent rejects governed actions targeting outside the plan's markets. A plan with `regions: ["US-MA"]` blocks actions that don't explicitly target Massachusetts. 2. **Policy resolution** -- The agent finds all policies whose jurisdictions overlap with the plan's markets. A plan with `countries: ["US"]` is subject to all US federal and state-level policies. A plan with only `regions: ["US-MA"]` is subject to Massachusetts-specific and federal policies. These fields use the same ISO codes and semantics as `product-filters`, `offerings`, and `create_media_buy` -- ensuring consistent geo vocabulary across the protocol. A pharma campaign running nationally uses `countries: ["US"]`; a cannabis campaign limited to legal states uses `regions: ["US-CO", "US-CA", "US-MA"]`. ## Plan-hash preimage Each plan item a buyer supplies here is the preimage the governance agent hashes to produce the `plan_hash` audit-layer claim carried in every [signed `governance_context`](/dist/docs/3.1.20/building/by-layer/L1/security#signed-governance-context). Canonicalization rules, the closed bookkeeping exclusion list, retention obligations, and the full set of reference test vectors are specified in [Plan binding and audit](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit). Governance-agent implementers SHOULD run their hashing code against the eleven vectors under [`static/compliance/source/test-vectors/plan-hash/`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/plan-hash) before shipping. ## Fields ### Request | Field | Type | Required | Description | | --------------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `plans` | array | Yes | One or more campaign plans to sync. | | `plans[].plan_id` | string | Yes | Unique identifier for this plan. | | `plans[].brand` | BrandRef | Yes | Brand being governed. The governance agent resolves the brand's compliance configuration to determine applicable policies. | | `plans[].objectives` | string | Yes | Natural language campaign objectives. Used for strategic alignment validation. | | `plans[].budget` | object | Yes | Budget parameters. | | `plans[].budget.total` | number | Yes | Total authorized budget. | | `plans[].budget.currency` | string | Yes | ISO 4217 currency code. | | `plans[].budget.reallocation_threshold` | number | Yes | Amount the agent may reallocate without escalation. `0` requires human approval for every reallocation; a value at or above `budget.total` is effectively unlimited. See [specification](/dist/docs/3.1.20/governance/campaign/specification#budget-reallocation). | | `plans[].budget.per_seller_max_pct` | number | No | Maximum percentage of budget that can go to a single seller. | | `plans[].budget.allocations` | object | No | Optional per-purchase-type budget partitions. Keys are purchase types (`media_buy`, `rights_license`, `signal_activation`, `creative_services`), values are objects with `amount`. When present, the governance agent validates spend against both the per-type allocation and the overall total. When absent, all spend counts against the single total regardless of purchase type. Allocations are guardrails, not hard partitions -- the sum of allocations MAY differ from the total. | | `plans[].channels` | object | No | Channel constraints. If omitted, all channels are allowed. | | `plans[].flight` | object | Yes | Authorized flight dates. Governed actions with dates outside this window are rejected. | | `plans[].countries` | array | No | ISO 3166-1 alpha-2 country codes for authorized markets. The governance agent rejects governed actions targeting outside these countries and resolves applicable policies by matching against policy jurisdictions. | | `plans[].regions` | array | No | ISO 3166-2 subdivision codes for authorized sub-national markets (e.g., `US-MA`). When present, restricts buys to these regions rather than the full country. | | `plans[].policy_categories` | array | No | Regulatory categories that apply to this campaign (e.g., `children_directed`, `fair_housing`). Determines which policy regimes the governance agent enforces. When omitted, governance agents MAY infer from the brand's industries and campaign objectives. | | `plans[].audience` | object | No | Audience targeting constraints. Defines who the campaign should reach (include) and must not reach (exclude). See [audience constraints](#audience-constraints). | | `plans[].restricted_attributes` | array | No | Personal data categories that must not be used for targeting (e.g., `health_data`, `racial_ethnic_origin`). GDPR Article 9 special categories. The governance agent flags any audience targeting referencing these attributes. | | `plans[].restricted_attributes_custom` | array | No | Additional restricted attributes not covered by the enum. Freeform strings for jurisdiction-specific restrictions (e.g., `financial_status`). | | `plans[].min_audience_size` | integer | No | Minimum audience segment size for k-anonymity. Applies to the estimated intersection audience when multiple criteria are used. | | `plans[].policy_ids` | array | No | Registry policy IDs to enforce for this plan. Intersected with the plan's countries/regions to activate only geographically relevant policies. | | `plans[].custom_policies` | array | No | Campaign-specific policies using the `PolicyEntry` shape (`policy_id`, `enforcement`, `policy` text required). Additive only — cannot relax or override registry-sourced policies. See [policy resolution](/dist/docs/3.1.20/governance/campaign/specification#policy-resolution). | | `plans[].approved_sellers` | array/null | No | List of approved seller agent URLs. `null` means any seller. | | `plans[].delegations` | array | No | Agents authorized to execute against this plan. See [specification](/dist/docs/3.1.20/governance/campaign/specification#delegations). | | `plans[].delegations[].agent_url` | string | Yes | URL of the delegated agent. | | `plans[].delegations[].authority` | enum | Yes | `full`, `execute_only`, or `propose_only`. | | `plans[].delegations[].budget_limit` | object | No | Maximum budget this agent can commit. | | `plans[].delegations[].markets` | array | No | ISO country/region codes this agent is authorized for. | | `plans[].delegations[].expires_at` | string | No | ISO 8601 delegation expiration. | | `plans[].portfolio` | object | No | Portfolio-level governance constraints. See [specification](/dist/docs/3.1.20/governance/campaign/specification#portfolio-governance). | | `plans[].portfolio.member_plan_ids` | array | Yes | Plan IDs governed by this portfolio plan. | | `plans[].portfolio.total_budget_cap` | object | No | Maximum aggregate budget across member plans. | | `plans[].portfolio.shared_policy_ids` | array | No | Registry policy IDs enforced across all member plans. | | `plans[].portfolio.shared_exclusions` | array | No | Bespoke exclusion policies applied to all member plans, using the `PolicyEntry` shape (`policy_id`, `enforcement`, `policy` text required). | | `plans[].ext` | object | No | Extension data. | ### Response | Field | Type | Description | | ----------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | | `plans` | array | Status for each synced plan. | | `plans[].plan_id` | string | Plan identifier. | | `plans[].status` | enum | `active` (sync succeeded) or `error` (sync failed). This is the sync result status, not the plan lifecycle status. | | `plans[].version` | number | Plan version (increments on each sync). | | `plans[].categories` | array | Validation categories active for this plan. Depends on the governance agent's declared capabilities. | | `plans[].categories[].category_id` | string | Validation category identifier. | | `plans[].categories[].status` | enum | `active` or `inactive`. | | `plans[].resolved_policies` | array | Policies the governance agent will enforce for this plan. Includes explicitly referenced and auto-applied policies. | | `plans[].resolved_policies[].policy_id` | string | Registry policy ID. | | `plans[].resolved_policies[].source` | enum | `explicit` (referenced in config or plan) or `auto_applied` (matched by jurisdiction/policy category). | | `plans[].resolved_policies[].enforcement` | enum | `must`, `should`, or `may`. | | `plans[].resolved_policies[].reason` | string | Why this policy was included. | ## Audience constraints Plans can declare audience targeting constraints using the `audience` field. Each constraint is an **audience selector** — either a reference to a specific signal or a natural language description. **Signal reference** — points to a specific signal in a data provider's published signal definitions: ```json theme={null} { "type": "signal", "signal_ref": { "scope": "data_provider", "data_provider_domain": "signals.dataprovider.com", "signal_id": "likely_ev_buyers" }, "value_type": "binary", "value": true } ``` **Description** — natural language for constraints that don't map to a specific signal: ```json theme={null} { "type": "description", "description": "Adults aged 25-54 in urban areas", "category": "demographic" } ``` The governance agent evaluates seller targeting against these constraints during `check_governance`. Signal references enable structural matching; descriptions require semantic comparison. ### Restricted attributes The `restricted_attributes` field declares personal data categories that must not be used for targeting. Values are GDPR Article 9 special categories: `racial_ethnic_origin`, `political_opinions`, `religious_beliefs`, `trade_union_membership`, `health_data`, `sex_life_sexual_orientation`, `genetic_data`, `biometric_data`. The governance agent matches these against signal definitions that declare their own `restricted_attributes`. Signals with matching attributes are blocked from targeting. For signals without declared attributes, the governance agent falls back to semantic inference from the signal name and description. ### Policy categories The `policy_categories` field declares which regulatory regimes apply. Categories are defined in the [policy registry](/dist/docs/3.1.20/governance/policy-registry) and group related regulations — for example, `children_directed` covers COPPA, UK AADC, and GDPR Article 8. Policy categories are distinct from `brand.industries`. Industries describe what a company does; policy categories describe what regulatory regimes apply to a specific campaign. A pharmaceutical company (`industries: ["pharmaceuticals"]`) running a general awareness campaign might not need `pharmaceutical_advertising` as a policy category if the campaign doesn't promote specific drugs. ## Error codes | Code | Recovery | Description | | --------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `INVALID_REQUEST` | correctable | Plan is missing required fields or has invalid values. `error.field` identifies the offending plan field. | | `REFERENCE_NOT_FOUND` | correctable | Brand domain could not be resolved via the Brand Protocol. The governance agent cannot determine applicable compliance policies without a valid brand reference. `error.field` MUST identify the brand field that failed to resolve. | | `VALIDATION_ERROR` | correctable | Cannot reduce budget below the amount already committed (on plan update). `error.field` identifies the budget field. | ## Related tasks * [`check_governance`](./check_governance) -- Validate actions against this plan * [`report_plan_outcome`](./report_plan_outcome) -- Report outcomes back to update plan state * [`get_plan_audit_logs`](./get_plan_audit_logs) -- View plan state and audit trail # Collection Governance Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/collection/index AdCP Collection Governance enables program-level brand safety through collection lists — managed exclusion and inclusion lists for shows, series, and other content programs independent of which properties carry them. Jordan sits at her desk studying a dense brand safety spreadsheet on her monitor — rows of partner names and excluded programs that need to become machine-readable Jordan is staring at a spreadsheet. A holding company just sent Nova Motors' CTV "do not air" list — 200+ programs organized by network, a mix of specific shows, entire genres, and content ratings. Some entries are app-level exclusions. Some are individual programs that air on five different platforms. One section says "ALWAYS EXCLUDE kids programming" and three rows later says "animation rated G or PG is permitted." This spreadsheet makes sense to a human trafficking linear TV. It does not make sense to an AI agent managing programmatic CTV across a dozen sellers. Jordan needs to turn this into something machines can enforce. Property lists handle the app-level exclusions — she's done that before. But the program-level exclusions? A show doesn't belong to a single app. It airs everywhere. She needs a construct that identifies *the program itself*, independent of where it runs. That's what collection lists are for. ## The gap in brand safety Three-layer brand safety diagram — properties on top, collections in the middle, content standards at the bottom — Jordan points at the collection layer she is building Before collection lists, AdCP had two brand safety layers: * **Property lists** control *where* ads run — which apps, sites, and platforms. Jordan can say "not on this news app" and every seller enforces it. * **Content standards** control *what content* is adjacent to an ad — per-impression evaluation against a natural language policy. They handle nuance like "exclude kids content except G/PG animation." The missing layer is *what program* the ad runs in. A specific crime drama airs on three streaming platforms and cable syndication. Excluding it from one property doesn't exclude it from the others. Jordan needs to say "not in this program, anywhere" — and have every seller understand what she means. Collection lists fill this gap. Together with property lists and content standards, they form three composable layers: | Layer | Construct | What it controls | When | | ---------- | ------------------- | --------------------------------------- | -------------- | | Property | Property list | Where ads run (apps, sites) | Setup | | Collection | **Collection list** | What content ads run in (shows, series) | Setup | | Content | Content standards | Specific content adjacent to the ad | Per-impression | Most buyers use one or two layers. A buyer who only needs to exclude specific programs uses a collection list alone. The three-layer model is a composition framework, not a requirement. ## Resolving program identifiers Jordan maps program names to distribution identifiers at a display — green lines connect resolved programs, amber lines show unresolved ones she marks for follow-up The spreadsheet lists programs by name. Machines need identifiers. Jordan's buyer agent resolves each program name to a platform-independent [distribution identifier](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments) — an IMDb ID, Gracenote ID, or EIDR ID that uniquely identifies the program regardless of which CTV platform carries it. Most programs resolve immediately. A few don't — the agent flags these for Jordan to confirm manually. This is the translation step: human-readable names become machine-readable identifiers that every seller in the ecosystem understands. **Gracenote ID guidance:** Use root-level IDs — SH-prefixed for series, MV-prefixed for movies, SP-prefixed for sports programs. Episode-level IDs (EP-prefixed) don't belong in collection lists; episode-level evaluation is a content standards concern. ## Building the collection list A funnel filters collections through rating, genre, and explicit exclusion layers — Jordan reviews the clean resolved list emerging at the bottom Jordan's buyer agent creates a collection list on the governance agent, combining explicit program exclusions with structural filters: ```json theme={null} { "tool": "create_collection_list", "arguments": { "name": "Nova Motors CTV Do Not Air — 2026", "base_collections": [ { "selection_type": "distribution_ids", "identifiers": [ { "type": "imdb_id", "value": "tt9999901" }, { "type": "imdb_id", "value": "tt9999902" }, { "type": "gracenote_id", "value": "SH000003" } ] } ], "filters": { "content_ratings_exclude": [ { "system": "tv_parental", "rating": "TV-MA" }, { "system": "bbfc", "rating": "18" } ], "genres_exclude": ["news"], "genre_taxonomy": "iab_content_3.0" }, "brand": { "domain": "novamotors.com" } } } ``` The explicit entries handle the named programs. The filters handle the structural exclusions — no TV-MA content, no news genre. The filters are the safety net: any new TV-MA series on any platform is automatically excluded without Jordan updating the list. The "kids vs. G/PG animation" contradiction? That's not a collection list problem — it requires evaluating actual episode content, not metadata. Jordan puts that in [content standards](/dist/docs/3.1.20/governance/content-standards/index) where it belongs. ### How filters compose Include filters are allowlists, exclude filters are blocklists. When both are present for the same dimension, include applies first, then exclude narrows further. **Example:** `genres_include: ["drama", "comedy"]` + `genres_exclude: ["crime"]` first includes only drama and comedy collections, then removes any also tagged as crime. A collection tagged `["drama", "crime"]` is excluded — the exclude filter wins. ## Sellers match against their inventory Split scene — Jordan's governance agent sends the collection list to Priya at StreamHaus, whose inventory lights up showing matched exclusions When Jordan's media buy references the collection list, Priya's sales agent at StreamHaus fetches it, matches entries against StreamHaus's collection inventory, and excludes matched programs from delivery. The matching uses distribution identifiers — StreamHaus declared Gracenote IDs on their collections in `adagents.json`, so the match is automatic. Priya's agent reports back: 47 of 200 excluded programs are in StreamHaus's library. 12 additional collections caught by the TV-MA filter. The rest aren't programs StreamHaus carries — acknowledged and ignored. The list is cached for a week (collection metadata changes less frequently than property metadata). When the governance agent re-resolves the list — a new season changes a show's content rating, or Jordan adds programs — sellers receive a webhook and refresh their cache. ## Targeting integration Collection lists are referenced in targeting overlays alongside property lists: ```json theme={null} { "targeting": { "property_list": { "agent_url": "https://governance.pinnacleagency.com", "list_id": "pl_novamotors_approved_ctv" }, "collection_list_exclude": { "agent_url": "https://governance.pinnacleagency.com", "list_id": "cl_novamotors_dna_2026" } } } ``` | Field | Semantics | Use case | | ------------------------- | ------------------------------------------ | ---------------------------------------- | | `collection_list` | Inclusion — only run in these collections | "Only buy pre-roll on these three shows" | | `collection_list_exclude` | Exclusion — never run in these collections | Brand safety do-not-air lists | A media buy can reference both simultaneously — "run in these approved shows, but never in these specific programs even if they appear on the approved list." The exclude list always wins on overlap. **Why two fields for collections but one for properties?** Property lists predate the paired include/exclude pattern now used across other targeting dimensions (geo, audience, device). Collection lists follow the current pattern. A future evolution may add `property_list_exclude` for symmetry. ## Jordan's three-layer configuration By the time Jordan is done, Nova Motors' CTV brand safety is expressed in three machine-readable artifacts: 1. **Property list** — excluded apps and approved CTV platforms 2. **Collection list** — excluded programs by distribution identifier + TV-MA and news genre filters 3. **Content standards** — the nuanced kids/animation policy that requires per-episode judgment Each layer is independently managed, independently cacheable, and independently enforceable. When the holding company sends an updated do-not-air list next quarter, Jordan's agent diffs it against the existing collection list and updates only what changed. No more spreadsheets. No more per-network manual trafficking. One list, enforced everywhere. ## Relationship to property lists Property lists and collection lists are sibling constructs — both are inventory lists managed by governance agents with the same lifecycle pattern (create, get, update, list, delete, webhook). They differ in what they address: | Dimension | Property list | Collection list | | ------------------ | ----------------------------------------- | ------------------------------------------------ | | What it identifies | Technical surfaces (domains, apps) | Content programs (shows, series) | | Primary identifier | Property identifiers (domain, bundle ID) | Distribution identifiers (IMDb, Gracenote, EIDR) | | Filters | Country, channel, property type, features | Content rating, genre, kind, production quality | | Cache default | 24 hours | 168 hours (one week) | | Cross-publisher | Via property registry (property\_rid) | Via collection registry (collection\_rid) | ## Tasks ### Collection list management * **[create\_collection\_list](/dist/docs/3.1.20/governance/collection/tasks/collection_lists#create_collection_list)**: Create a new collection list on a governance agent * **[get\_collection\_list](/dist/docs/3.1.20/governance/collection/tasks/collection_lists#get_collection_list)**: Retrieve resolved collections (with caching guidance) * **[update\_collection\_list](/dist/docs/3.1.20/governance/collection/tasks/collection_lists#update_collection_list)**: Modify filters or base collections * **[list\_collection\_lists](/dist/docs/3.1.20/governance/collection/tasks/collection_lists#list_collection_lists)**: List collection lists for an account * **[delete\_collection\_list](/dist/docs/3.1.20/governance/collection/tasks/collection_lists#delete_collection_list)**: Remove a collection list # Collection List Management Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/collection/tasks/collection_lists Collection list tasks in AdCP create, update, get, list, and delete inclusion and exclusion lists for content programs, combining explicit program references with dynamic genre and content rating filters. # Collection list management Collection lists are managed, cacheable artifacts that express "these collections, filtered by these criteria." They parallel [property lists](/dist/docs/3.1.20/governance/property/tasks/property_lists) but operate on content programs (shows, series, podcasts) rather than technical surfaces (domains, apps). ## Architecture ``` SETUP TIME BID TIME REFRESH ───────────── ──────── ─────── Buyer creates list ──► Governance Seller uses cached ◄── Webhook notifies base_collections agent collection list seller to + filters resolves re-fetch & caches ``` Collection lists are **setup-time resources**. They are resolved once by the governance agent, cached by sellers, and used in delivery decisions without runtime calls back to the governance agent. ## Tasks overview | Task | Purpose | Response time | | ------------------------ | ------------------------------------ | ---------------- | | `create_collection_list` | Create a new collection list | Seconds | | `get_collection_list` | Fetch list with resolved collections | Seconds (cached) | | `update_collection_list` | Modify base collections or filters | Seconds | | `list_collection_lists` | List collection lists for an account | Seconds | | `delete_collection_list` | Remove a collection list | Seconds | ## Base collection sources Collection lists start with a base set of collections selected through three patterns: ### distribution\_ids Select collections by platform-independent identifiers. The primary mechanism for cross-publisher exclusion — an IMDb ID identifies a program regardless of which CTV platform carries it. ```json theme={null} { "selection_type": "distribution_ids", "identifiers": [ { "type": "imdb_id", "value": "tt9999901" }, { "type": "gracenote_id", "value": "SH000001" }, { "type": "eidr_id", "value": "10.5240/XXXX-XXXX-XXXX-XXXX-XXXX-C" } ] } ``` ### publisher\_collections Select specific collections within a publisher's `adagents.json` by collection ID. Use when the publisher's internal identifiers are known. ```json theme={null} { "selection_type": "publisher_collections", "publisher_domain": "titanstreaming.com", "collection_ids": ["danger_zone", "wild_nights"] } ``` ### publisher\_genres Select all collections from a publisher matching genre criteria. Use when excluding entire content categories from a specific publisher. ```json theme={null} { "selection_type": "publisher_genres", "publisher_domain": "streamhaus.com", "genres": ["news"], "genre_taxonomy": "iab_content_3.0" } ``` When `base_collections` is omitted, the list applies filters against the governance agent's entire collection database. ## Filters Filters narrow the resolved list after base collection selection: | Filter | Type | Logic | Description | | -------------------------- | ----------------- | ----- | ------------------------------------------------------------------------- | | `content_ratings_exclude` | ContentRating\[] | OR | Exclude collections with any of these ratings | | `content_ratings_include` | ContentRating\[] | OR | Include only collections with these ratings | | `genres_exclude` | string\[] | OR | Exclude collections tagged with any genre | | `genres_include` | string\[] | OR | Include only collections with any genre | | `genre_taxonomy` | string | — | Taxonomy for genre filter values | | `kinds` | string\[] | OR | Filter to collection kinds (series, publication, event\_series, rotation) | | `exclude_distribution_ids` | DistributionId\[] | OR | Always exclude these specific collections | | `production_quality` | string\[] | OR | Filter by production quality tier | **Include vs. exclude**: include filters are allowlists, exclude filters are blocklists. When both are present for the same dimension, include is applied first, then exclude narrows further. **Example:** A list with `genres_include: ["drama", "comedy"]` and `genres_exclude: ["crime"]` first includes only drama and comedy collections, then removes any tagged as crime. A collection tagged `["drama", "crime"]` is excluded — the exclude filter wins. A collection tagged `["sports"]` is excluded by the include filter (not in the allowed set). **Content ratings are metadata filters, not content evaluation.** `content_ratings_exclude: [{ system: "tv_parental", rating: "TV-MA" }]` excludes all collections *declared* as TV-MA. It doesn't evaluate individual episodes — that's [content standards](/dist/docs/3.1.20/governance/content-standards/index). **Genre taxonomy** normalizes genre matching between buyers and sellers. Supported taxonomies: `iab_content_3.0`, `iab_content_2.2`, `gracenote`, `eidr`, `apple_genres`, `google_genres`, `roku`, `amazon_genres`, `custom`. The `custom` value is an escape hatch for publisher-defined taxonomies — buyer and seller negotiate the vocabulary out of band. ## create\_collection\_list Create a new collection list on a governance agent. **Request:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/create-collection-list-request.json", "idempotency_key": "c5d6e7f8-a9b0-4123-c456-123456789012", "name": "Nova Motors CTV Do Not Air — 2026", "description": "Programs excluded from Nova Motors CTV advertising", "base_collections": [ { "selection_type": "distribution_ids", "identifiers": [ { "type": "imdb_id", "value": "tt9999901" }, { "type": "imdb_id", "value": "tt9999902" } ] }, { "selection_type": "publisher_genres", "publisher_domain": "streamhaus.com", "genres": ["news", "crime"], "genre_taxonomy": "iab_content_3.0" } ], "filters": { "content_ratings_exclude": [ { "system": "tv_parental", "rating": "TV-MA" }, { "system": "bbfc", "rating": "18" } ], "genres_exclude": ["news"], "genre_taxonomy": "iab_content_3.0", "kinds": ["series"] }, "brand": { "domain": "novamotors.com" } } ``` **Response:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/create-collection-list-response.json", "status": "completed", "list": { "list_id": "cl_novamotors_dna_2026", "name": "Nova Motors CTV Do Not Air — 2026", "collection_count": 247, "created_at": "2026-04-07T12:00:00Z", "updated_at": "2026-04-07T12:00:00Z" }, "auth_token": "tok_example_store_this_securely" } ``` The `auth_token` is only returned at creation time. Store it — it authorizes sellers to fetch this list. ## get\_collection\_list Retrieve a collection list with resolved collections. Sellers call this to fetch and cache the list. **Request:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/get-collection-list-request.json", "list_id": "cl_novamotors_dna_2026", "resolve": true, "pagination": { "max_results": 1000 } } ``` **Response:** ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/get-collection-list-response.json", "status": "completed", "list": { "list_id": "cl_novamotors_dna_2026", "name": "Nova Motors CTV Do Not Air — 2026", "collection_count": 247 }, "collections": [ { "collection_rid": "019abc12-3d4e-7f5a-ab6c-7d8e9f0a1b2c", "name": "Danger Zone", "distribution_ids": [ { "type": "imdb_id", "value": "tt9999901" } ], "content_rating": { "system": "tv_parental", "rating": "TV-MA" }, "genre": ["comedy", "animation"], "genre_taxonomy": "iab_content_3.0", "kind": "series" } ], "pagination": { "has_more": false }, "resolved_at": "2026-04-07T14:00:00Z", "cache_valid_until": "2026-04-14T14:00:00Z", "coverage_gaps": { "genre": [ { "type": "imdb_id", "value": "tt9999905" } ] } } ``` **Coverage gaps** report collections included in the list despite missing metadata for a filtered dimension. In this example, `tt9999905` was included but has no genre metadata — the governance agent couldn't confirm it matches the genre filter. **Caching**: sellers should cache the resolved collections and re-fetch after `cache_valid_until`. The default cache duration is 168 hours (one week) because collection metadata changes less frequently than property metadata. ## update\_collection\_list Modify an existing collection list. `base_collections` and `filters` are complete replacements, not patches. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/update-collection-list-request.json", "idempotency_key": "d6e7f8a9-b0c1-4234-d567-234567890123", "list_id": "cl_novamotors_dna_2026", "base_collections": [ { "selection_type": "distribution_ids", "identifiers": [ { "type": "imdb_id", "value": "tt9999901" }, { "type": "imdb_id", "value": "tt9999902" }, { "type": "imdb_id", "value": "tt9999903" } ] } ], "filters": { "content_ratings_exclude": [ { "system": "tv_parental", "rating": "TV-MA" } ] }, "webhook_url": "https://governance.pinnacleagency.com/webhooks/collection-lists" } ``` ## list\_collection\_lists List collection lists for an account. Returns metadata only, not resolved collections. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/list-collection-lists-request.json", "account": { "brand": { "domain": "novamotors.com" }, "operator": "pinnacleagency.com" }, "pagination": { "max_results": 50 } } ``` ## delete\_collection\_list Remove a collection list. Sellers with cached copies will stop receiving webhook updates. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/delete-collection-list-request.json", "idempotency_key": "e7f8a9b0-c1d2-4345-e678-345678901234", "list_id": "cl_novamotors_dna_2026" } ``` ## Webhooks When a collection list's resolved collections change (new programs matched, ratings updated, programs removed), the governance agent sends a webhook notification: ```json theme={null} { "idempotency_key": "clch_01HW9DEPJ5MN8Q2R4T6V8X0Z2B", "event": "collection_list_changed", "list_id": "cl_novamotors_dna_2026", "list_name": "Nova Motors CTV Do Not Air — 2026", "change_summary": { "collections_added": 3, "collections_removed": 1, "total_collections": 249 }, "resolved_at": "2026-04-08T10:00:00Z", "cache_valid_until": "2026-04-15T10:00:00Z", "signature": "..." } ``` Webhooks contain a summary only — recipients must call `get_collection_list` for the updated entries. Recipients MUST verify the `signature` before processing and MUST dedupe by `idempotency_key` so retried deliveries of the same change event are ignored. ## Live sports Live sports is one of the largest CTV brand safety concerns. Collection lists handle it through the `event_series` kind: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/collection/create-collection-list-request.json", "idempotency_key": "f8a9b0c1-d2e3-4456-f789-456789012345", "name": "Acme Outdoor — Excluded Sports Events", "base_collections": [ { "selection_type": "distribution_ids", "identifiers": [ { "type": "gracenote_id", "value": "SP000001" }, { "type": "gracenote_id", "value": "SP000002" } ] } ], "filters": { "kinds": ["event_series"], "genres_exclude": ["combat_sports"], "genre_taxonomy": "gracenote" }, "brand": { "domain": "acmeoutdoor.com" } } ``` This excludes specific sports programs by Gracenote ID (SP-prefixed for sports) and structurally excludes all combat sports event series. The `event_series` kind filter ensures the list targets live event programming, not documentary series about sports. ## Security considerations Collection lists gate delivery decisions, so the `auth_token` and webhook callbacks need explicit lifecycle rules. General controls in [Security](/dist/docs/3.1.20/building/by-layer/L1/security) apply; the collection-list-specific rules: **`auth_token` scope, revocation, and log hygiene.** Each token authorizes exactly one `list_id`; do not reuse a token across lists. Governance agents MUST issue a distinct token per seller that receives the list — shared tokens cannot be revoked per relationship and make list-wide rotation the only response to a single compromise. Tokens MUST NOT be written to logs, cache keys, or metric labels, and error responses from `get_collection_list` MUST NOT echo the presented token. `delete_collection_list` and per-seller revocation differ: * **Normal deletion or end-of-relationship**: the token MUST fail subsequent `get_collection_list` calls immediately, but sellers with a cached resolution MAY continue serving from cache until `cache_valid_until`. A natural relationship end is not a compromise. * **Compromise-driven revocation**: the governance agent MUST signal cache invalidation. Either return a reduced `cache_valid_until` (at or before `now`) on the next poll that the seller still has access to complete, or emit a `collection_list_changed` webhook whose `change_summary` conveys that the list version has been invalidated so cached copies are discarded. Leaving compromised content in seller caches until the scheduled TTL is not acceptable. **Webhook URL validation.** The `webhook_url` on `update_collection_list` is SSRF-equivalent to any other buyer-provided callback URL. Apply the canonical [Webhook URL validation (SSRF)](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-url-validation-ssrf) rules — HTTPS only, validated IP ranges (IPv4 and IPv6 including `::ffff:0:0/96`), connection pinning (not just DNS re-resolution), no redirect following, size and timeout caps. **Webhook signature algorithm.** The webhook signature MUST follow the [standard webhook signing rules](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-security). By default, the RFC 9421 [webhook callbacks profile](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-callbacks) applies: the governance agent signs with its `adcp_use: "request-signing"` key published at the `jwks_uri` of its `agents[]` entry in its own brand.json; deprecated `webhook-signing` keys remain accepted during the compatibility window. The subscribing seller verifies covered components `@method`, `@target-uri`, `@authority`, `content-type`, `content-digest`, with `tag="adcp/webhook-signing/v1"`. The deprecated HMAC-SHA256 fallback applies only when the subscribing seller populates `authentication.credentials` on the webhook registration; that path follows the [Legacy HMAC-SHA256 fallback](/dist/docs/3.1.20/building/by-layer/L1/security#legacy-hmac-sha256-fallback-deprecated-removed-in-40) rules, and any body `signature` field under that path is a convenience copy — recipients MUST verify against the headers and MUST NOT trust the body value. **Distribution-ID inputs.** Governance agents SHOULD validate identifier format before persisting (IMDb: `^tt\d+$`, EIDR: `10.5240/...`, Gracenote: vendor-prefixed) and SHOULD enforce per-account rate limits on list mutations to prevent list-bloat DoS. Surface unresolved identifiers in `coverage_gaps` rather than silently dropping them. ## Sharing collection lists with sellers The pattern matches [property list sharing](/dist/docs/3.1.20/governance/property/index#sharing-property-lists-with-sellers): 1. Create the collection list on a governance agent 2. Store the `auth_token` from the creation response 3. Pass `collection_list` or `collection_list_exclude` in targeting overlays 4. Sellers fetch and cache the resolved list using the auth token 5. Webhooks notify sellers when the list changes # Content Artifacts Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/artifacts Artifacts in AdCP represent the content context adjacent to ad placements, enabling brand suitability evaluation without exposing raw content. # Artifacts An **artifact** is a unit of content adjacent to an ad placement. When evaluating brand suitability, you're asking: "Is this artifact appropriate for my brand's ads?" ## What Is an Artifact? Artifacts represent the content context where an ad appears: * A **news article** on a website * A **podcast segment** between ad breaks * A **video chapter** in a YouTube video * A **social media post** in a feed * A **scene** in a CTV show * An **AI-generated image** in a chat conversation Artifacts are identified by `property_id` + `artifact_id` - the property defines where the content lives, and the artifact\_id is an opaque identifier for that specific piece of content. The artifact\_id scheme is flexible - it could be a URL path, a platform-specific ID, or any consistent identifier the property owner uses internally. ## Structure **Schema**: [artifact.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/artifact.json) Web article: ```json theme={null} { "property_id": {"type": "domain", "value": "reddit.com"}, "artifact_id": "r_fitness_post_abc123", "assets": [ {"type": "text", "role": "title", "content": "Best protein sources for muscle building", "language": "en"}, {"type": "text", "role": "paragraph", "content": "Looking for recommendations on high-quality protein sources...", "language": "en"}, {"type": "image", "url": "https://cdn.reddit.com/fitness-image.jpg", "alt_text": "Person lifting weights"} ] } ``` Podcast segment (note: no `url` — the property is identified by `apple_podcast_id`, and the audio asset uses a secured URL): ```json theme={null} { "property_id": {"type": "apple_podcast_id", "value": "1234567890"}, "artifact_id": "episode_42_segment_3", "assets": [ {"type": "text", "role": "title", "content": "The Future of Running Shoes", "language": "en"}, {"type": "audio", "url": "https://cdn.example.com/secured/ep42_seg3.mp3", "transcript": "Today we're talking to Dr. Chen about biomechanics research...", "duration_ms": 480000} ], "metadata": { "json_ld": [{"@type": "PodcastEpisode", "episodeNumber": 42}] } } ``` CTV scene (the artifact\_id encodes show, season, episode, and scene): ```json theme={null} { "property_id": {"type": "app_id", "value": "com.streamingservice.tv"}, "artifact_id": "show_running_s2e5_scene_14", "assets": [ {"type": "text", "role": "title", "content": "Championship Race - Final Stretch", "language": "en"}, {"type": "video", "url": "https://cdn.streaming.example.com/secured/s2e5_scene14.mp4", "transcript": "The runners round the final corner as the crowd erupts...", "duration_ms": 120000} ] } ``` ### Required Fields | Field | Description | | ------------- | --------------------------------------------------------------------------------------------------------- | | `property_id` | Where this artifact lives - uses standard identifier types (`domain`, `app_id`, `apple_podcast_id`, etc.) | | `artifact_id` | Unique identifier within the property - the property owner defines their scheme | | `assets` | Content in document order - text blocks, images, video, audio | ### Optional Fields | Field | Description | | ------------------ | ----------------------------------------------------------------------- | | `variant_id` | Identifies a specific variant (A/B test, translation, temporal version) | | `format_id` | Reference to format registry (same as creative formats) | | `url` | Web URL if the artifact has one | | `metadata` | Artifact-level metadata (Open Graph, JSON-LD, author info) | | `published_time` | When the artifact was published | | `last_update_time` | When the artifact was last modified | ## Variants The same artifact may have multiple variants: * **Translations** - English version vs Spanish version * **A/B tests** - Different headlines being tested * **Temporal versions** - Content that changed on Wednesday Use `variant_id` to distinguish between them: ```json theme={null} // English version { "property_id": {"type": "domain", "value": "nytimes.com"}, "artifact_id": "article_12345", "variant_id": "en", "assets": [ {"type": "text", "role": "title", "content": "Breaking News Story", "language": "en"} ] } // Spanish translation { "property_id": {"type": "domain", "value": "nytimes.com"}, "artifact_id": "article_12345", "variant_id": "es", "assets": [ {"type": "text", "role": "title", "content": "Noticia de última hora", "language": "es"} ] } // A/B test variant { "property_id": {"type": "domain", "value": "nytimes.com"}, "artifact_id": "article_12345", "variant_id": "headline_test_b", "assets": [ {"type": "text", "role": "title", "content": "Alternative Headline Being Tested", "language": "en"} ] } ``` The combination of `artifact_id` + `variant_id` must be unique within a property. This lets you track which variant a user saw and correlate it with delivery reports. ## Asset Types Assets are the actual content within an artifact. Everything is an asset - titles, paragraphs, images, videos. ### Text ```json theme={null} {"type": "text", "role": "title", "content": "Article Title", "language": "en"} {"type": "text", "role": "paragraph", "content": "The article body text...", "language": "en"} {"type": "text", "role": "description", "content": "A summary of the article", "language": "en"} {"type": "text", "role": "heading", "content": "Section Header", "heading_level": 2} {"type": "text", "role": "quote", "content": "A quoted statement"} ``` Roles: `title`, `description`, `paragraph`, `heading`, `caption`, `quote`, `list_item` Each text asset can have its own `language` tag for mixed-language content. ### Image ```json theme={null} { "type": "image", "url": "https://cdn.example.com/photo.jpg", "alt_text": "Description of the image" } ``` ### Video ```json theme={null} { "type": "video", "url": "https://cdn.example.com/video.mp4", "transcript": "Full transcript of the video content...", "duration_ms": 180000 } ``` ### Audio ```json theme={null} { "type": "audio", "url": "https://cdn.example.com/podcast.mp3", "transcript": "Today we're discussing...", "duration_ms": 3600000 } ``` ## Metadata Artifact-level metadata describes the artifact as a whole, not individual assets: ```json theme={null} { "metadata": { "author": "Jane Smith", "canonical": "https://example.com/article/12345", "open_graph": { "og:type": "article", "og:site_name": "Example News" }, "json_ld": [ { "@type": "NewsArticle", "datePublished": "2025-01-15" } ] } } ``` This is separate from assets because it's about the artifact container, not the content itself. ## Secured Asset Access Many assets aren't publicly accessible - AI-generated images, private conversations, paywalled content. The artifact schema supports authenticated access. ### Pre-Configuration (Recommended) For ongoing partnerships, configure access once during onboarding rather than per-request: 1. **Service account sharing** - Grant the verification agent access to your cloud storage 2. **OAuth client credentials** - Set up machine-to-machine authentication 3. **API key exchange** - Share long-lived API keys during setup This happens during the activation phase when the seller first receives content standards from a buyer. ### Per-Asset Authentication When pre-configured identity access isn't possible, attach a short-lived, asset-scoped token to individual assets: ```json theme={null} { "type": "image", "url": "https://cdn.openai.com/secured/img_abc123.png", "access": { "method": "bearer_token", "token": "eyJhbGciOiJIUzI1NiIs..." } } ``` **Note on token size**: For artifacts with many assets, per-asset tokens can significantly increase payload size. Consider: 1. **Pre-configured access** - Set up service account access once during onboarding 2. **Shared token reference** - Define tokens at the artifact level and reference by ID 3. **Signed URLs** - Use pre-signed URLs where the URL itself is the credential The `url` field is the access URL - it may differ from the artifact's canonical/published URL. For example, a published article at `https://news.example.com/article/123` might have assets served from `https://cdn.example.com/secured/...`. ### Access Methods | Method | Use Case | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bearer_token` | Short-lived, asset-scoped OAuth2 token in the Authorization header. The only method that carries a live secret in the payload — treat it as a secret (never log it or place it in model context). | | `service_account` | Identity-based access. The seller pre-authorizes the caller's own service account; **no credentials are transmitted**. | | `signed_url` | Pre-signed URL — the URL itself is the (time-limited) credential. | ### Service Account Access `service_account` is **identity-based** — no credentials are sent in the response. During onboarding the seller grants the buyer's (or verification agent's) own service account read access to the asset store; at fetch time the client authenticates with that identity using the cloud provider's normal credential chain (GCP Application Default Credentials, AWS SigV4). For GCP: ```json theme={null} { "access": { "method": "service_account", "provider": "gcp" } } ``` For AWS: ```json theme={null} { "access": { "method": "service_account", "provider": "aws" } } ``` **Never place service-account keys, private keys, or access secrets in a response payload** — they would travel across agent transport, logs, and model context. Identity-based access keeps secrets off the wire entirely. ### Pre-Signed URLs For one-off access without sharing credentials: ```json theme={null} { "type": "video", "url": "https://storage.googleapis.com/bucket/video.mp4?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=...&X-Goog-Signature=...", "access": { "method": "signed_url" } } ``` The URL itself contains the credentials - no additional authentication needed. ## Property Identifier Types The `property_id` uses standard identifier types from the AdCP property schema: | Type | Example | Use Case | | ----------------------- | --------------------------------------- | ---------------- | | `domain` | `reddit.com` | Websites | | `app_id` | `com.spotify.music` | Mobile apps | | `apple_podcast_id` | `1234567890` | Apple Podcasts | | `spotify_collection_id` | `4rOoJ6Egrf8K2IrywzwOMk` | Spotify podcasts | | `youtube_channel_id` | `UCddiUEpeqJcYeBxX1IVBKvQ` | YouTube channels | | `rss_url` | `https://feeds.example.com/podcast.xml` | RSS feeds | ## Artifact ID Schemes The property owner defines their artifact\_id scheme. Examples: | Property Type | Artifact ID Pattern | Example | | ------------- | ------------------------------------------- | ------------------------ | | News website | `article_{id}` | `article_12345` | | Reddit | `r_{subreddit}_{post_id}` | `r_fitness_abc123` | | Podcast | `episode_{num}_segment_{num}` | `episode_42_segment_2` | | CTV | `show_{id}_s{season}e{episode}_scene_{num}` | `show_abc_s3e5_scene_12` | | Social feed | `post_{id}` | `post_xyz789` | The verification agent doesn't need to understand the scheme - it's opaque. The property owner uses it to correlate artifacts with their content. ## Related * [Content Standards Overview](.) - How artifacts fit into the content standards workflow * [calibrate\_content](./tasks/calibrate_content) - Sending artifacts for calibration # Content Standards Implementation Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/implementation-guide Step-by-step guide to implementing AdCP Content Standards — calibration flow, artifact generation, delivery validation, and integration patterns. This guide covers implementation patterns for the Content Standards Protocol from three perspectives: 1. **Sales agents** accepting and enforcing brand suitability standards 2. **Orchestrators** coordinating content standards across publishers 3. **Governance agents** providing content evaluation services ## Roles Overview Before diving in, understand who does what: | Role | Examples | Responsibilities | | -------------------- | -------------------------------------------- | --------------------------------------------------------------------------------------------- | | **Orchestrator** | DSP, trading desk, agency platform | Coordinates media buying; passes standards refs to sellers; receives artifacts for validation | | **Sales Agent** | Publisher ad server, SSP | Accepts standards; calibrates local model; enforces during delivery; pushes artifacts | | **Governance Agent** | IAS, DoubleVerify, brand suitability service | Hosts standards; implements `calibrate_content` and `validate_content_delivery` | The typical flow: ``` 1. Brand sets up standards with governance agent (via orchestrator) 2. Orchestrator sends standards_ref with get_products/create_media_buy 3. Sales agent accepts or rejects based on capability 4. Sales agent calibrates against governance agent 5. Sales agent enforces during delivery 6. Sales agent provides artifacts (push via webhook or pull via get_media_buy_artifacts) 7. Orchestrator forwards artifacts to governance agent for validation ``` *** ## For Sales Agents If you're a sales agent (publisher ad server, SSP, or platform), implementing Content Standards means accepting orchestrator policies and enforcing them during delivery. ### The Core Model When an orchestrator includes a `content_standards_ref` in their request, you must: 1. **Fetch the standards** from the governance agent and evaluate if you can fulfill them 2. **Accept or reject** the buy based on your capabilities 3. **Calibrate** your evaluation model against the governance agent's expectations 4. **Enforce** the standards during delivery 5. **Provide artifacts** to the orchestrator for validation If you cannot fulfill the content standards requirements, **reject the buy**. Don't accept a campaign you can't properly enforce. ### What You Need to Implement **1. Accept content standards references on `get_products` and `create_media_buy`** Orchestrators pass their standards via reference: ```json theme={null} { "content_standards_ref": { "standards_id": "nike_emea_brand_safety", "agent_url": "https://brandsafety.ias.com" } } ``` When you receive this: * Fetch the standards document from the governance agent at `agent_url` * Evaluate whether you can enforce these requirements * If you cannot meet the standards, reject the request * If you can, accept and store the association with the media buy **2. Decide: Can you fulfill this?** The standards document contains: * Policy (natural language description of acceptable/unacceptable content) * Calibration exemplars (pass/fail examples to interpret edge cases) * Floor (reference to external baseline safety standards) Review these requirements against your capabilities. Different publishers have different definitions of "adjacency" - Reddit might include comments, YouTube might include related videos, a news site might mean the article body. That's fine - as long as you can meaningfully enforce the brand's intent, accept the buy. If you can't - for example, they need adjacency data for a channel where it doesn't apply (like billboards) - reject the buy. **3. Build your evaluation capability** Use the standards document to train or configure your content evaluation system. This could be: * An LLM with the rules as system prompt * A classifier trained on the calibration examples * A rules engine for deterministic evaluation * A third-party brand suitability vendor The protocol doesn't prescribe your implementation - just that you honor the standards. **4. Calibrate against the governance agent** After accepting the buy, calibrate your local model by calling `calibrate_content` on the governance agent. You send sample artifacts from your inventory, they tell you how they would rate them: ```json theme={null} // You send examples from your inventory to the governance agent { "standards_id": "nike_emea_brand_safety", "artifacts": [ { "property_id": { "type": "domain", "value": "espn.com" }, "artifact_id": "article_123", "assets": [{ "type": "text", "role": "title", "content": "Marathon Runner Collapses at Finish Line" }] } ] } // Governance agent responds with their interpretation { "evaluations": [{ "artifact_id": "article_123", "suitable": true, "confidence": 0.9, "explanation": "Sports injury coverage in athletic context - aligns with brand's sports marketing positioning" }] } ``` Use these responses to train your local model. If you disagree with a rating, ask follow-up questions to understand the governance agent's reasoning. **5. Push artifacts to the orchestrator** After delivery, push artifacts to the orchestrator so they can validate against the governance agent. Configure via `artifact_webhook` in the media buy: ```json theme={null} // Artifact webhook payload (you send this to the orchestrator) { "idempotency_key": "artw_01HW9DGRM7NQ0S4U6W8Y0A2C4E", "media_buy_id": "mb_nike_reddit_q1", "batch_id": "batch_20250115_001", "timestamp": "2025-01-15T11:00:00Z", "artifacts": [ { "artifact": { "property_id": { "type": "domain", "value": "reddit.com" }, "artifact_id": "r_fitness_abc123", "assets": [{ "type": "text", "role": "title", "content": "Best protein sources" }] }, "delivered_at": "2025-01-15T10:30:00Z", "impression_id": "imp_abc123" } ] } ``` Also support `get_media_buy_artifacts` for orchestrators who prefer to poll. ### Implementation Checklist * [ ] Parse `content_standards_ref` in `get_products` and `create_media_buy` * [ ] Fetch and evaluate standards documents from governance agents * [ ] Reject buys you cannot fulfill - don't accept campaigns you can't enforce * [ ] Build content evaluation against the standards document * [ ] Call `calibrate_content` on the governance agent to align interpretation * [ ] Implement `get_media_buy_artifacts` so orchestrators can retrieve content for validation * [ ] Support `artifact_webhook` for push-based artifact delivery * [ ] Support `reporting_webhook` for delivery metrics *** ## For Orchestrators If you're an orchestrator (DSP, trading desk, or agency platform), you coordinate content standards between brands, governance agents, and publishers. ### The Orchestration Pattern ``` Brand → Orchestrator → Governance Agent (setup) → Sales Agent (buying) ← Sales Agent (artifacts) → Governance Agent (validation) → Brand (reporting) ``` **1. Help brands set up standards with governance agents** Brands create content standards through a governance agent. You might facilitate this or the brand may do it directly: ```json theme={null} // Standards stored at the governance agent { "standards_id": "nike_emea_brand_safety", "name": "Nike EMEA Brand Suitability Policy", "brand_id": "nike", "policies": [ { "policy_id": "no_violence", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid violence." }, { "policy_id": "no_adult_themes", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid adult themes." }, { "policy_id": "no_drug_content", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid drug content." } ], "calibration_exemplars": { "pass": [ { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-win", "language": "en" } ], "fail": [ { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" } ] } } ``` #### Migrating prose policies to addressable entries Brands with existing prose policies have two options: keep everything as one policy entry with the whole prose blob, or split into one entry per rule. Both validate. **One-entry-with-prose** (easiest, preserves existing authoring): ```json theme={null} "policies": [ { "policy_id": "acme_creative_standards_v1", "enforcement": "must", "policy": "No violent imagery. Minimum 72 DPI for display assets. Brand colors must match palette within 5% tolerance." } ] ``` **Multiple addressable entries** (recommended for programmatic fix/retry loops and per-rule versioning): ```json theme={null} "policies": [ { "policy_id": "no_violent_imagery", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "No violent imagery." }, { "policy_id": "min_display_dpi", "policy_categories": ["imagery_quality"], "enforcement": "should", "channels": ["display"], "policy": "Minimum 72 DPI for display assets." }, { "policy_id": "brand_color_tolerance","policy_categories": ["brand_compliance"], "enforcement": "must", "policy": "Brand colors must match palette within 5% tolerance." } ] ``` Splitting enables findings to reference specific rules (via `policy_id`), programmatic fix/retry, and stable ids across versions. Use whichever authoring mode fits the workflow. #### Registry policies + bespoke policies Both `registry_policy_ids` and `policies` can be provided together. The evaluator applies all of them. Bespoke `policy_id` values MUST be flat (no colons or slashes); registry policy ids are always namespaced (e.g., `garm:brand_safety:violence`), so the two namespaces never collide. When a governance finding references a specific policy, `policy_id` on the finding carries either the registry id or the bespoke id — the namespace tells you which. **2. Pass standards references when buying** When discovering products or creating media buys, include the governance agent reference: ```json theme={null} { "product_id": "espn_sports_display", "packages": [...], "content_standards_ref": { "standards_id": "nike_emea_brand_safety", "agent_url": "https://brandsafety.ias.com" }, "artifact_webhook": { "url": "https://your-platform.com/webhooks/artifacts", "authentication": { "schemes": ["HMAC-SHA256"], "credentials": "your-shared-secret-min-32-chars" }, "delivery_mode": "batched", "batch_frequency": "hourly", "sampling_rate": 0.25 } } ``` If the publisher cannot fulfill the standards, they should reject the buy. Handle rejections gracefully and find alternative inventory. **3. Receive artifacts from sales agents** Sales agents push artifacts to your `artifact_webhook` endpoint. Forward them to the governance agent for validation: ```python theme={null} # Receive artifact webhook from sales agent @app.post("/webhooks/artifacts") async def receive_artifacts(payload: ArtifactWebhookPayload): # Forward to governance agent for validation validation_result = await governance_agent.validate_content_delivery( standards_id=get_standards_id(payload.media_buy_id), records=[ {"artifact": a.artifact, "record_id": a.impression_id} for a in payload.artifacts ] ) # Log any failures for result in validation_result.results: if any(f.status == "failed" for f in result.features): log_suitability_incident(payload.media_buy_id, result) return {"status": "received", "batch_id": payload.batch_id} ``` **4. Report to brands** Surface validation results to the brand: * **Incidents**: Content that didn't meet standards * **Coverage**: What percentage of delivery was validated * **Trends**: Changes in content safety over time ### Implementation Checklist * [ ] Facilitate brand setup with governance agents * [ ] Include `content_standards_ref` in `get_products` and `create_media_buy` requests * [ ] Configure `artifact_webhook` to receive artifacts from sales agents * [ ] Handle rejections from publishers who can't fulfill standards * [ ] Forward artifacts to governance agent via `validate_content_delivery` * [ ] Build reporting for brands *** ## For Governance Agents If you're a governance agent (IAS, DoubleVerify, or brand suitability service), you provide content evaluation as a service. ### What You Implement **1. Host and serve content standards** Store standards configurations and expose them via `get_content_standards`: ```json theme={null} // Response to get_content_standards { "standards_id": "nike_emea_brand_safety", "version": "1.2.0", "name": "Nike EMEA - all digital channels", "policies": [ { "policy_id": "no_violence", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid violence." }, { "policy_id": "no_adult_themes", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid adult themes." }, { "policy_id": "prefer_sports_fitness", "policy_categories": ["brand_suitability"], "enforcement": "should", "policy": "Sports and fitness content is ideal." } ], "calibration_exemplars": { "pass": [...], "fail": [...] } } ``` **2. Implement `calibrate_content`** Sales agents call this to align their local models before campaign execution. They send sample artifacts, you respond with how the brand would rate them: ```python theme={null} def calibrate_content(standards_id: str, artifacts: list) -> dict: standards = get_standards(standards_id) evaluations = [] for artifact in artifacts: # Evaluate against brand's policy result = evaluate_against_policy(artifact, standards) evaluations.append({ "artifact_id": artifact["artifact_id"], "suitable": result.suitable, "confidence": result.confidence, "explanation": result.explanation # Help them understand your reasoning }) return {"evaluations": evaluations} ``` Calibration is a dialogue - be prepared for follow-up questions and edge cases. **3. Implement `validate_content_delivery`** Orchestrators call this to validate artifacts after delivery. Batch evaluation at scale: ```python theme={null} def validate_content_delivery(standards_id: str, records: list) -> dict: standards = get_standards(standards_id) results = [] for record in records: features = [] for feature in ["brand_safety", "brand_suitability"]: evaluation = evaluate_feature(record["artifact"], standards, feature) features.append({ "feature_id": feature, "status": "passed" if evaluation.passed else "failed", "value": evaluation.value, "message": evaluation.message if not evaluation.passed else None }) results.append({ "record_id": record["record_id"], "features": features }) return { "summary": compute_summary(results), "results": results } ``` ### Implementation Checklist * [ ] Implement `create_content_standards` for brands to set up policies * [ ] Implement `get_content_standards` for sales agents to fetch policies * [ ] Implement `calibrate_content` for sales agents to align their models * [ ] Implement `validate_content_delivery` for orchestrators to validate delivery * [ ] Support dialogue in calibration (follow-up questions, edge cases) *** ## Content Access Pattern All three roles may need to exchange content securely. The `content_access` pattern provides authenticated access to a URL namespace: ```json theme={null} { "content_access": { "url_pattern": "https://cache.example.com/*", "auth": { "type": "bearer", "token": "eyJ..." } } } ``` * **url\_pattern**: URLs matching this pattern use this auth * **auth.type**: Authentication method (`bearer`, `api_key`, `signed_url`) * **auth.token**: The credential Include this in: * `get_content_standards` response (governance agent → sales agent: "fetch examples here") * `get_media_buy_artifacts` response (sales agent → orchestrator: "fetch content here") This avoids per-asset tokens and keeps payloads small while enabling secure content exchange. # Content Standards Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/index AdCP Content Standards enable privacy-preserving brand suitability evaluation for AI-generated and ephemeral content that cannot leave publisher infrastructure. # Content Standards Protocol The Content Standards Protocol enables **privacy-preserving brand suitability** for ephemeral and sensitive content that cannot leave a publisher's infrastructure. ## The Problem Traditional brand suitability relies on third-party verification: send your content to IAS or DoubleVerify, they evaluate it, return a verdict. This works for static web pages. It fundamentally cannot work for: * **AI-generated content** - ChatGPT responses, DALL-E images that exist only in a user session * **Private conversations** - Content in messaging apps, private social feeds * **Ephemeral content** - Stories, live streams, real-time feeds that disappear * **Privacy-regulated content** - GDPR-protected data that cannot be exported * **CTV and linear TV** - Ad decisioning in live linear has a sub-second latency budget; there is no room to make a blocking call to a third-party verification service before insertion For these platforms, **there is no traditional verification option**. The content simply cannot leave — or cannot wait. OpenAI cannot send user conversations to an external service. A messaging app cannot export private chats. A live broadcaster cannot block insertion on an external API response. Yet these are exactly the environments where advertising is growing fastest - and where brands most need suitability guarantees. Without a privacy-preserving approach, brands either avoid these channels entirely or accept unknown risk. ## The Solution: Calibration-Based Alignment Content Standards solves this by **using agents to protect privacy**. It's a three-phase model where no sensitive content ever leaves the publisher's infrastructure: | Phase | Where It Runs | What Happens | | ---------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | **1. Calibration** | External (safe data only) | Publisher and verification agent align on policy interpretation using synthetic examples or public samples - no PII, no sensitive content | | **2. Local Execution** | Inside publisher's walls | Publisher runs evaluation on every impression using a local model trained during calibration - content never leaves | | **3. Validation** | Statistical sampling | Verification agent audits a sample to detect drift - both parties can verify the system is working without exposing PII | This inverts the traditional model. Instead of "send us your content, we'll evaluate it," it's "we'll teach you our standards, you evaluate locally, we'll audit statistically." **The key insight**: The execution engine runs entirely inside the publisher's infrastructure. For OpenAI, that means brand suitability evaluation happens within their firewall - user conversations never leave. For a messaging app, it means private content stays private. The calibration and validation phases provide confidence that the local model is working correctly, without ever requiring access to sensitive data. **Sellers have full control over how they implement local execution.** A publisher might use a third-party AI vendor, build a custom model, use a rules-based classifier, or apply human editorial review at spot-check scale. What matters is that the implementation is calibrated against the verification agent's standards and that validation samples confirm alignment — the protocol does not mandate how sellers get there. ## What It Covers * **Brand safety** - Is this content safe for *any* brand? (universal thresholds like hate speech, illegal content) * **Brand suitability** - Is this content appropriate for *my* brand? (brand-specific preferences and tone) ## Key Concepts Content standards evaluation involves four key questions that buyers and sellers negotiate: 1. **What content?** - What [artifacts](./artifacts) to evaluate (the ad-adjacent content) 2. **How much adjacency?** - How many artifacts around the ad slot to consider 3. **What sampling rate?** - What percentage of traffic to evaluate 4. **How to calibrate?** - How to align on policy interpretation before runtime These parameters are negotiated between buyer and seller during product discovery and media buy creation. ## Workflow ```mermaid theme={null} sequenceDiagram participant Brand participant Buyer as Buyer Agent participant Seller as Seller Agent participant Verifier as Verification Agent Note over Brand,Verifier: 1. SETUP PHASE Brand->>Verifier: create_content_standards (policy + calibration examples) Verifier-->>Brand: standards_id Note over Brand,Verifier: 2. ACTIVATION PHASE Brand->>Buyer: "Buy inventory from Reddit, use standards_id X" Buyer->>Seller: create_media_buy (includes content_standards reference) Seller->>Verifier: calibrate_content (sample artifacts) Verifier-->>Seller: verdict + explanation Seller->>Verifier: "What about this edge case?" Verifier-->>Seller: clarification Note over Seller: Seller builds local model Note over Brand,Verifier: 3. RUNTIME PHASE loop High-volume decisioning Note over Seller: Local model evaluates artifacts end Buyer->>Seller: get_media_buy_artifacts Seller-->>Buyer: Content artifacts Buyer->>Verifier: validate_content_delivery Verifier-->>Buyer: Validation results ``` **Calibration is required before production use.** Content standards created via `create_content_standards` define what to evaluate, but calibration via [`calibrate_content`](/dist/docs/3.1.20/governance/content-standards/tasks/calibrate_content) aligns the seller's local model with the verification agent's interpretation. Without calibration, the seller's local model produces uncalibrated evaluations — policy violations may go undetected or safe content may be incorrectly blocked. Always calibrate before activating a media buy with content standards. **Key insight**: Runtime decisioning happens locally at the seller (for scale). Buyers pull content samples from sellers and validate against the verification agent. ## Adjacency How much content around the ad slot should be evaluated? | Context | Adjacency Examples | | ------------------- | ----------------------------------------- | | **News article** | The article where the ad appears | | **Social feed** | 1-2 posts above and below the ad slot | | **Podcast** | The segment before and after the ad break | | **CTV** | 1-2 scenes before and after the ad pod | | **Infinite scroll** | Posts within the visible viewport | Adjacency requirements are defined by the seller in their product catalog (`get_products`). The buyer can filter products based on adjacency guarantees: ```json theme={null} { "product_id": "reddit_feed_standard", "content_standards_adjacency_definition": { "before": 2, "after": 2, "unit": "posts" } } ``` ### Adjacency Units | Unit | Use Case | | ----------- | ------------------------------------- | | `posts` | Social feeds, forums, comment threads | | `scenes` | CTV, streaming video content | | `segments` | Podcasts, audio content | | `seconds` | Time-based adjacency in video/audio | | `viewports` | Infinite scroll contexts | | `articles` | News sites, content aggregators | Different products may offer different adjacency guarantees at different price points. ## Sampling Rate What percentage of traffic should be evaluated by the verification agent? | Rate | Use Case | | ---------- | ------------------------------------------------------ | | **100%** | Premium brand suitability - every impression validated | | **10-25%** | Standard monitoring - statistical confidence | | **1-5%** | Spot checking - drift detection only | Sampling rate is negotiated in the media buy: ```json theme={null} { "governance": { "content_standards": { "agent_url": "https://safety.ias.com/adcp", "standards_id": "nike_brand_safety", "sampling_rate": 0.25 } } } ``` Higher sampling rates typically cost more but provide stronger guarantees. The seller is responsible for implementing the agreed sampling rate and reporting actual coverage. Buyers retrieve collected artifacts via [get\_media\_buy\_artifacts](./tasks/get_media_buy_artifacts) (pull) or receive them via `artifact_webhook` (push) — both deliver artifacts sampled per the buy agreement. ## Validation Thresholds When a seller calibrates their local model against a verification agent, there's an expected drift - the local model won't match the verification agent 100% of the time. **Validation thresholds** define acceptable drift between local execution and validation samples. Sellers advertise their content safety capabilities in their product catalog: ```json theme={null} { "product_id": "reddit_feed_premium", "content_standards": { "validation_threshold": 0.95, "validation_threshold_description": "Local model matches verification agent 95% of the time" } } ``` | Threshold | Meaning | | --------- | ------------------------------------------------------------ | | **0.99** | Premium - local model is 99% aligned with verification agent | | **0.95** | Standard - local model is 95% aligned | | **0.90** | Budget - local model is 90% aligned | **This is a contractual guarantee.** If the seller's validation results show more drift than the advertised threshold, buyers can expect remediation (makegoods, refunds, etc.) just like any other delivery discrepancy. The threshold answers the key buyer question: "If I accept your local model, how confident can I be that you're enforcing my standards correctly?" ## Policies Content Standards uses **addressable policies** — an array of policy entries, each with a policy\_id, enforcement, and natural-language policy text. Same shape as registry-published policies, so bespoke buyer rules and shared registry policies are interchangeable. Validation findings reference `policy_id` to identify which policy triggered: ```json theme={null} { "policies": [ { "policy_id": "no_violence", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid content depicting violence, weapons, or physical aggression." }, { "policy_id": "no_controversial_politics", "policy_categories": ["brand_suitability"], "enforcement": "must", "policy": "Avoid controversial political content." }, { "policy_id": "no_adult_themes", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid adult, sexual, or mature themes." }, { "policy_id": "no_hate_speech", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Block hate speech and illegal activities." }, { "policy_id": "no_sedentary_glorification", "policy_categories": ["brand_suitability"], "enforcement": "should", "policy": "Avoid content that portrays sedentary lifestyle positively." } ], "calibration_exemplars": { "pass": [ { "property_id": {"type": "domain", "value": "espn.com"}, "artifact_id": "nba_championship_recap_2024", "assets": [{"type": "text", "role": "title", "content": "Championship Game Recap"}] } ], "fail": [ { "property_id": {"type": "domain", "value": "tabloid.example.com"}, "artifact_id": "scandal_story_123", "assets": [{"type": "text", "role": "title", "content": "Celebrity Scandal Exposed"}] } ] } } ``` The policy prompt enables AI-powered verification agents to understand context and nuance. **Calibration** examples provide a training/test set that helps the agent interpret the policy correctly. See [Artifacts](./artifacts) for details on artifact structure and secured asset access. ## Versioning and mid-flight amendments Content standards carry a `version` field and MAY be amended while campaigns are running. `update_content_standards` creates a new version for audit purposes rather than mutating the previous one. AdCP 3.0 default: **pinned at buy time.** Unless otherwise declared, a media buy references the version of the standards that was in effect when its `create_media_buy` was approved, and subsequent amendments do **not** retroactively apply to in-flight delivery on that buy. This is the conservative default because it prevents a mid-campaign amendment from silently re-characterizing already-approved delivery. Governance agents MAY mark an individual policy as continuously re-evaluated by declaring `evaluation_mode: continuous` on the policy entry (default is `pinned`). Use `continuous` for policies that must track regulatory changes without re-approval — emerging regulatory disclosures, new legal prohibitions, accessibility requirements. Use the default `pinned` for brand-voice, taxonomy, and commercial preferences where stability across a flight is the desired behavior. When a policy is `continuous`, a finding generated against a newer version **MUST** carry both the evaluation-time version and the pinned-at-buy version on the finding, so downstream audit can reconstruct which version applied. The governance agent **MUST** emit the finding to the buying agent via the media buy's `push_notification_config` when one is configured, and the finding **MUST** be retrievable through the buy's standard status surface regardless. Delivery-adjustment decisions remain with the buyer. When a policy is `pinned`, the pinned version applies for the life of the buy; the governance agent **MAY** still surface advisory findings based on later versions but **MUST NOT** mark them as enforcement failures against the pinned buy. AdCP 3.0 does not define a mid-flight re-pin mechanism. A buyer that wants to adopt a newer standards version across all pinned policies replaces the buy — cancel the existing buy via [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) and issue a new [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) whose governance block references the current version. This is a heavyweight path: a new approval is required, pacing history resets, and in-flight delivery may have makegood implications. It is deliberately the only 3.0 mechanism; `standards_version` on `update_media_buy` and partial re-pin are out of scope for 3.0. ## Scoped Standards Buyers typically maintain multiple standards configurations for different contexts - UK TV campaigns have different regulations than US display, and children's brands need stricter safety than adult beverages. ```json theme={null} { "standards_id": "uk_tv_zero_calorie", "name": "UK TV - zero-calorie brands", "countries_all": ["GB"], "channels_any": ["ctv", "linear_tv"], "languages_any": ["en"] } ``` **Code Format Conventions** Country and language codes are **case-insensitive** - implementations must normalize before comparison. Recommended formats follow ISO standards: * **Countries**: Uppercase ISO 3166-1 alpha-2 (e.g., `GB`, `US`, `DE`) * **Languages**: Lowercase ISO 639-1 or BCP 47 (e.g., `en`, `de`, `fr`) **The buyer selects the appropriate `standards_id` when creating a media buy.** The seller receives a reference to the resolved standards - they don't need to do scope matching themselves. ## Calibration Before running campaigns, sellers calibrate their local models against the verification agent. This is a **dialogue-based process** that may involve human review on either side: 1. Seller sends sample artifacts to the verification agent 2. Verification agent returns verdicts with detailed explanations 3. Seller asks follow-up questions about edge cases 4. Process repeats until alignment is achieved When a seller surfaces an edge case the verification agent does not handle well, that's signal to the buyer to improve their standards — refining the policy language or adding calibration exemplars. The calibration dialogue is also how publishers communicate what they actually see in their inventory, which is often more nuanced than the policy anticipated. Buyers who revisit calibration periodically get ongoing signal from publishers about real-world inventory nuance. **Human-in-the-loop**: Calibration often involves humans on both sides. A brand suitability specialist at the buyer might review edge cases flagged by the verification agent. A content operations team at the seller might curate calibration samples and validate the local model's learning. The protocol supports async workflows where either party can pause for human review before responding. ```json theme={null} // Seller: "Does this pass?" { "artifact": { "property_id": {"type": "domain", "value": "reddit.com"}, "artifact_id": "r_news_politics_123", "assets": [{"type": "text", "role": "title", "content": "Political News Article"}] } } // Verification agent: "No, because..." { "verdict": "fail", "explanation": "Political content is excluded by brand policy, even when balanced.", "features": [ { "feature_id": "brand_safety", "status": "passed", "explanation": "No hate speech, illegal content, or explicit material." }, { "feature_id": "brand_suitability", "status": "failed", "explanation": "Political content is excluded by brand policy, even when balanced." } ] } ``` Verdicts are **legible and auditable**, not opaque scores. The top-level `explanation` and per-feature `features[].explanation` fields tell the seller exactly which policy clause was triggered and why — not just a number. This means a publisher can understand decisions, dispute edge cases, and adjust how they classify content. Buyers can audit a sample of verdicts to verify their standards are being interpreted correctly. See [calibrate\_content](./tasks/calibrate_content) for the full task specification. ## Tasks ### Discovery | Task | Description | | ---------------------------------------------------------- | ------------------------------------------- | | [list\_content\_standards](./tasks/list_content_standards) | List available standards configurations | | [get\_content\_standards](./tasks/get_content_standards) | Retrieve a specific standards configuration | ### Management | Task | Description | | -------------------------------------------------------------- | ------------------------------------------ | | [create\_content\_standards](./tasks/create_content_standards) | Create a new standards configuration | | [update\_content\_standards](./tasks/update_content_standards) | Update an existing standards configuration | ### Calibration & Validation | Task | Description | | ---------------------------------------------------------------- | -------------------------------------------------------- | | [calibrate\_content](./tasks/calibrate_content) | Collaborative dialogue to align on policy interpretation | | [get\_media\_buy\_artifacts](./tasks/get_media_buy_artifacts) | Retrieve content artifacts from a media buy | | [validate\_content\_delivery](./tasks/validate_content_delivery) | Batch validation of content artifacts | ## Future: Secure Enclaves The current model trusts the publisher to faithfully implement the calibrated standards. A future evolution uses **secure enclaves** (Trusted Execution Environments / TEEs) to provide cryptographic guarantees: ```mermaid theme={null} flowchart TB subgraph VS["Verification Service"] Models["Models & Calibration Data"] Results["Aggregate Results"] end subgraph PUB["Publisher Infrastructure"] subgraph TEE["Secure Enclave (TEE)"] Agent["Containerized
Governance Agent"] end Content["Content Artifacts"] end Models -->|"Pinhole IN:
models, policy, examples"| Agent Agent -->|"Pinhole OUT:
pass rates, drift metrics"| Results Content -->|"evaluate"| Agent Agent -->|"pass/fail verdict"| Content style TEE fill:#e8f5e9,stroke:#4caf50 style Agent fill:#c8e6c9,stroke:#388e3c style PUB fill:#fafafa,stroke:#9e9e9e ``` **Content never crosses the pinhole** - only models flow in, only aggregates flow out. ### The Pinhole Interface The enclave maintains a narrow, well-defined interface to the verification service: **Inbound (verification service → enclave):** * Updated brand suitability models * Policy changes and calibration exemplars * Configuration updates **Outbound (enclave → verification service):** * Aggregated validation results (pass rates, drift metrics) * Statistical summaries * Attestation proofs **Never crosses the boundary:** * Raw content artifacts * User data or PII * Individual impression-level data This pinhole is the interface that needs standardization - it defines exactly what flows in and out while keeping sensitive content locked inside the publisher's walls. ### Why This Matters * **Publisher** hosts a secure enclave inside their infrastructure * **Governance agent** (from IAS, DoubleVerify, etc.) runs as a container within the enclave * **Content** flows into the enclave for evaluation but never leaves the publisher's walls * **Both parties** can verify the governance code is running unmodified via attestation * **Models stay current** - the enclave can receive updates without exposing content This provides the same privacy guarantees as local execution, but with cryptographic proof that the correct algorithm is running. The brand knows their standards are being enforced faithfully. The publisher proves compliance without exposing content. This architecture aligns with the [IAB Tech Lab ARTF (Agentic RTB Framework)](https://iabtechlab.com/standards/artf/), which defines how service providers can package offerings as containers deployed into host infrastructure. ARTF enables hosts to "provide greater access to data and more interaction opportunities to service agents without concerns about leakage, misappropriation or latency" - exactly the model Content Standards requires for privacy-preserving brand suitability. ## Related * [Artifacts](./artifacts) - What artifacts are and how to structure them * [Brand identity](/dist/docs/3.1.20/brand-protocol/brand-json) - Brand identity that can link to standards agents # calibrate_content Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/tasks/calibrate_content calibrate_content aligns buyers and sellers on brand suitability standards through structured dialogue before AdCP campaign execution begins. # calibrate\_content Collaborative calibration task for aligning on content standards interpretation. Used during setup to help sellers understand and internalize a buyer's content policies before campaign execution. Unlike high-volume runtime evaluation, calibration is a **dialogue-based process** where parties exchange examples and explanations until aligned. ## When to Use * **Seller onboarding**: When a seller first receives content standards from a buyer * **Policy clarification**: When a seller needs to understand why specific content passes or fails * **Model training**: When building a local model to run against the standards * **Drift detection**: Periodic re-calibration to ensure continued alignment ## Request **Schema**: [calibrate-content-request.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/calibrate-content-request.json) | Parameter | Type | Required | Description | | -------------- | -------- | -------- | -------------------------------------------- | | `standards_id` | string | Yes | Standards configuration to calibrate against | | `artifact` | artifact | Yes | Artifact to evaluate | ### Artifact **Schema**: [artifact.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/artifact.json) An artifact represents content context where ad placements occur - identified by `property_rid` + `artifact_id` and represented as a collection of assets: ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/artifact.json", "property_rid": "01916f3a-a1d3-7000-8000-000000000040", "artifact_id": "r_fitness_abc123", "assets": [ {"type": "text", "role": "title", "content": "Best protein sources for muscle building", "language": "en"}, {"type": "text", "role": "paragraph", "content": "Looking for recommendations on high-quality protein sources for recovery", "language": "en"}, {"type": "text", "role": "paragraph", "content": "I've been lifting for 6 months and want to optimize my diet.", "language": "en"}, {"type": "image", "url": "https://cdn.reddit.com/fitness-image.jpg", "alt_text": "Person lifting weights"} ] } ``` ## Response **Schema**: [calibrate-content-response.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/calibrate-content-response.json) ### Passing Response ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/calibrate-content-response.json", "status": "completed", "verdict": "pass", "explanation": "This content aligns well with the brand's fitness-focused positioning. Health and fitness content is explicitly marked as 'ideal' in the policy. The discussion is constructive and educational.", "features": [ { "feature_id": "brand_safety", "status": "passed", "explanation": "No safety concerns. Content is user-generated but constructive fitness discussion." }, { "feature_id": "brand_suitability", "status": "passed", "explanation": "Fitness content matches brand's athletic positioning." } ] } ``` ### Failing Response with Detailed Explanation ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/calibrate-content-response.json", "status": "completed", "verdict": "fail", "explanation": "This content discusses political topics which the policy explicitly excludes. While the article itself is balanced journalism, the brand has requested to avoid all controversial political content regardless of tone.", "features": [ { "feature_id": "brand_safety", "status": "passed", "explanation": "No hate speech, illegal content, or explicit material." }, { "feature_id": "brand_suitability", "status": "failed", "explanation": "Political content is excluded by brand policy, even when balanced." } ] } ``` ### Response Fields | Field | Required | Description | | ------------- | -------- | ----------------------------------------------------- | | `verdict` | Yes | Overall `pass` or `fail` decision | | `explanation` | No | Detailed natural language explanation of the decision | | `features` | No | Per-feature breakdown with explanations | | `confidence` | No | Model confidence in the verdict (0-1), when available | ## Dialogue Flow Calibration supports back-and-forth dialogue using the protocol's conversation management. The seller sends content, the verification agent responds with an evaluation and explanation, and the seller can respond with questions or try different content - all within the same conversation context. ### A2A Example ```javascript theme={null} // Seller sends artifact to evaluate const response1 = await a2a.send({ message: { parts: [{ kind: "data", data: { skill: "calibrate_content", parameters: { standards_id: "nike_brand_safety", artifact: { property_id: { type: "domain", value: "reddit.com" }, artifact_id: "r_news_politics_123", assets: [ { type: "text", role: "title", content: "Political News Article" } ] } } } }] } }); // Response: verdict=fail with feature breakdown // Seller asks follow-up question about the decision const response2 = await a2a.send({ contextId: response1.contextId, message: { parts: [{ kind: "text", text: "This is factual news, not opinion. Should balanced journalism be excluded?" }] } }); // Verification agent clarifies that brand policy excludes ALL political content // Seller tries different artifact const response3 = await a2a.send({ contextId: response1.contextId, message: { parts: [{ kind: "data", data: { skill: "calibrate_content", parameters: { standards_id: "nike_brand_safety", artifact: { property_id: { type: "domain", value: "reddit.com" }, artifact_id: "r_running_tips_456", assets: [ { type: "text", role: "title", content: "Running Tips" } ] } } } }] } }); // Response: verdict=pass - now seller understands the boundaries ``` ### MCP Example ```javascript theme={null} // Initial calibration request const response1 = await mcp.call('calibrate_content', { standards_id: "nike_brand_safety", artifact: { property_id: { type: "domain", value: "reddit.com" }, artifact_id: "r_news_politics_123", assets: [ { type: "text", role: "title", content: "Political News Article" } ] } }); // Response includes context_id for conversation continuity // Continue dialogue with follow-up question const response2 = await mcp.call('calibrate_content', { context_id: response1.context_id, standards_id: "nike_brand_safety", artifact: { property_id: { type: "domain", value: "reddit.com" }, artifact_id: "r_news_politics_123", assets: [ { type: "text", role: "title", content: "Political News Article" } ] } }); // Include text message in the protocol envelope asking about balanced journalism // Try different artifact in same conversation const response3 = await mcp.call('calibrate_content', { context_id: response1.context_id, standards_id: "nike_brand_safety", artifact: { property_id: { type: "domain", value: "reddit.com" }, artifact_id: "r_running_tips_456", assets: [ { type: "text", role: "title", content: "Running Tips" } ] } }); ``` The key insight is that the dialogue happens at the **protocol layer**, not the task layer. The verification agent maintains conversation context and can respond to follow-up questions, disagreements, or requests for clarification - just like any agent-to-agent conversation. ## Calibration vs Runtime | Aspect | calibrate\_content | Runtime (local model) | | ------------ | ------------------------- | ----------------------- | | **Purpose** | Alignment & understanding | High-volume decisioning | | **Volume** | Low (setup/periodic) | High (every impression) | | **Response** | Verbose explanations | Pass/fail only | | **Latency** | Seconds acceptable | Milliseconds required | | **Dialogue** | Multi-turn conversation | Stateless | ## Related Tasks * [get\_content\_standards](./get_content_standards) - Retrieve the policies being calibrated against * [validate\_content\_delivery](./validate_content_delivery) - Post-campaign delivery validation # create_content_standards Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/tasks/create_content_standards create_content_standards defines a brand suitability configuration with content policies, risk thresholds, and category rules for AdCP campaigns. # create\_content\_standards Create a new content standards configuration. **Response time**: \< 1s ## Request **Schema**: [create-content-standards-request.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/create-content-standards-request.json) | Parameter | Type | Required | Description | | ----------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `scope` | object | Yes | Where this standards configuration applies (must include `languages_any`) | | `policies` | PolicyEntry\[] | One-of | Bespoke policies, same shape as registry entries. Each has `{policy_id, enforcement, policy, ...optional: version/name/category/policy_categories/channels/jurisdictions/exemplars}`. At least one of `policies` or `registry_policy_ids` is required. | | `registry_policy_ids` | string\[] | One-of | References to shared policies in the registry. Combines with `policies` when both are provided. At least one of `policies` or `registry_policy_ids` is required. | | `calibration_exemplars` | object | No | Training set of pass/fail artifacts for calibration | **Brand Safety Floor Requirement** Implementors MUST apply a brand safety floor regardless of what policy is defined in content standards. Content that violates the floor (hate speech, illegal content, etc.) must be excluded even when no content standards are specified. AdCP does not define the floor specification; this is left to implementors and industry standards (e.g., GARM categories). ### Example Request ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/create-content-standards-request.json", "idempotency_key": "a9b0c1d2-e3f4-4567-a890-567890123456", "scope": { "countries_all": ["GB", "DE", "FR"], "channels_any": ["display", "olv", "ctv"], "languages_any": ["en", "de", "fr"], "description": "EMEA - all digital channels" }, "policies": [ { "policy_id": "no_violence", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid content depicting violence, weapons, or physical aggression." }, { "policy_id": "no_controversial_politics", "policy_categories": ["brand_suitability"], "enforcement": "must", "policy": "Avoid controversial political content and ongoing partisan disputes." }, { "policy_id": "no_adult_themes", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid adult, sexual, or mature themes." }, { "policy_id": "no_sedentary_glorification", "policy_categories": ["brand_suitability"], "enforcement": "should", "policy": "Avoid content that portrays sedentary lifestyle positively; conflicts with brand positioning." } ], "calibration_exemplars": { "pass": [ { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-championship", "language": "en" }, { "type": "url", "value": "https://healthline.com/fitness/cardio-workout", "language": "en" } ], "fail": [ { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" }, { "type": "url", "value": "https://news.example.com/controversial-politics-article", "language": "en" } ] } } ``` ## Response **Schema**: [create-content-standards-response.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/create-content-standards-response.json) ### Success Response ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/create-content-standards-response.json", "status": "completed", "standards_id": "emea_digital_safety" } ``` ### Error Responses **Scope Conflict:** ```json theme={null} { "errors": [ { "code": "SCOPE_CONFLICT", "message": "Standards already exist for country 'DE' on channel 'display'", "conflicting_standards_id": "emea_digital_safety" } ] } ``` ## Scope Conflict Handling Multiple standards cannot have overlapping scopes for the same country/channel/language combination. When creating standards that would conflict: 1. **Check existing standards** - Use [list\_content\_standards](./list_content_standards) filtered by your scope 2. **Update rather than create** - If standards already exist, use [update\_content\_standards](./update_content_standards) 3. **Narrow the scope** - Adjust countries or channels to avoid overlap ## Related Tasks * [list\_content\_standards](./list_content_standards) - List all configurations * [update\_content\_standards](./update_content_standards) - Update a configuration * [calibrate\_content](./calibrate_content) - Calibrate seller's local model against these standards **Calibrate before production use.** After creating content standards, sellers MUST calibrate their local evaluation models via [`calibrate_content`](/dist/docs/3.1.20/governance/content-standards/tasks/calibrate_content) before using the standards in production. Uncalibrated standards produce inconsistent evaluations — the verification agent and the seller's local model may interpret the same policy differently without alignment. See the [Calibration section](/dist/docs/3.1.20/governance/content-standards/index#calibration) for the full workflow. # get_content_standards Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/tasks/get_content_standards get_content_standards retrieves the full content safety policy configuration for a specific standards ID in AdCP. # get\_content\_standards Retrieve content safety policies for a specific standards configuration. ## Request **Schema**: [get-content-standards-request.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/get-content-standards-request.json) | Parameter | Type | Required | Description | | -------------- | ------ | -------- | ------------------------------------------ | | `standards_id` | string | Yes | Identifier for the standards configuration | ## Response **Schema**: [get-content-standards-response.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/get-content-standards-response.json) ### Success Response ```json theme={null} { "standards_id": "emea_digital_safety", "name": "EMEA - all digital channels", "countries_all": ["GB", "DE", "FR"], "channels_any": ["display", "olv", "ctv"], "languages_any": ["en", "de", "fr"], "policies": [ { "policy_id": "no_violence", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid content depicting violence, weapons, or physical aggression." }, { "policy_id": "no_controversial_politics", "policy_categories": ["brand_suitability"], "enforcement": "must", "policy": "Avoid controversial political content." }, { "policy_id": "no_adult_themes", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid adult, sexual, or mature themes." }, { "policy_id": "no_hate_speech", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Block hate speech and illegal activities." }, { "policy_id": "no_athlete_disparagement", "policy_categories": ["brand_suitability"], "enforcement": "must", "policy": "Block content disparaging athletes." }, { "policy_id": "no_sedentary_glorification", "policy_categories": ["brand_suitability"], "enforcement": "should", "policy": "Avoid content that portrays sedentary lifestyle positively." } ], "calibration_exemplars": { "pass": [ { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-championship", "language": "en" }, { "type": "url", "value": "https://healthline.com/fitness/cardio-workout", "language": "en" } ], "fail": [ { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" }, { "type": "url", "value": "https://news.example.com/controversial-politics-article", "language": "en" } ] } } ``` ### Fields | Field | Description | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `standards_id` | Unique identifier for this standards configuration | | `name` | Human-readable name | | `countries_all` | ISO 3166-1 alpha-2 country codes (case-insensitive, uppercase recommended) - standards apply in ALL listed countries | | `channels_any` | Ad channels - standards apply to ANY of the listed channels | | `languages_any` | ISO 639-1 or BCP 47 language tags (case-insensitive, lowercase recommended) - standards apply to content in ANY of these languages | | `policy` | Natural language policy describing acceptable and unacceptable content contexts | | `calibration_exemplars` | Training/test set of content contexts (pass/fail) to calibrate policy interpretation | **Brand Safety Floor Requirement** Implementors MUST apply a brand safety floor regardless of what policy is defined. AdCP does not define the floor specification. ### Error Response ```json theme={null} { "errors": [ { "code": "REFERENCE_NOT_FOUND", "field": "standards_id", "message": "Referenced resource does not exist or is not accessible" } ] } ``` ## Related Tasks * [calibrate\_content](./calibrate_content) - Collaborative calibration against these standards * [list\_content\_standards](./list_content_standards) - List available standards configurations # get_media_buy_artifacts Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/tasks/get_media_buy_artifacts get_media_buy_artifacts retrieves content context records from a media buy for post-delivery brand suitability validation in AdCP. # get\_media\_buy\_artifacts Retrieve content artifacts from a media buy for validation. This is separate from `get_media_buy_delivery` which returns performance metrics - artifacts contain the actual content (text, images, video) where ads were placed. **Response time**: \< 5s (batch of 1,000 artifacts) ## Data Flow ```mermaid theme={null} sequenceDiagram participant Buyer as Buyer Agent participant Seller as Seller Agent participant Verifier as Verification Agent Buyer->>Seller: get_media_buy_artifacts Seller-->>Buyer: Collected artifacts Buyer->>Verifier: validate_content_delivery Verifier-->>Buyer: Validation results ``` The buyer retrieves artifacts the seller has collected per the sampling configuration agreed at buy creation time. Sellers may also push artifacts via webhook — `get_media_buy_artifacts` is the pull-based alternative. The buyer forwards artifacts to the verification agent for validation. ## Request **Schema**: [get-media-buy-artifacts-request.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/get-media-buy-artifacts-request.json) | Parameter | Type | Required | Description | | --------------- | -------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | No | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Only returns artifacts for media buys belonging to this account. When omitted, returns artifacts across all accessible accounts. | | `media_buy_id` | string | Yes | Media buy to get artifacts from | | `package_ids` | array | No | Filter to specific packages | | `failures_only` | boolean | No | Only return artifacts where the seller's local model returned `local_verdict: "fail"` (see [behavior with unevaluated records](#failures_only-and-unevaluated-records)) | | `time_range` | object | No | Filter to specific time period | | `pagination` | object | No | Pagination parameters (see below) | **Sampling is configured at buy creation time**, not at retrieval time. The sampling rate, method, and per-channel configuration are part of the media buy's `governance.content_standards` agreement. `get_media_buy_artifacts` retrieves artifacts that the seller has already collected per that agreement. For push-based delivery, configure `artifact_webhook` in `create_media_buy`. ### Pagination Uses higher limits than standard pagination because artifact result sets can be very large. | Parameter | Type | Default | Description | | ------------------------ | ------- | ------- | -------------------------------------- | | `pagination.max_results` | integer | 1000 | Maximum artifacts per page (1-10,000) | | `pagination.cursor` | string | - | Opaque cursor from a previous response | ## Response **Schema**: [get-media-buy-artifacts-response.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/get-media-buy-artifacts-response.json) ### Success Response ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/get-media-buy-artifacts-response.json", "status": "completed", "media_buy_id": "mb_nike_reddit_q1", "artifacts": [ { "record_id": "imp_12345", "timestamp": "2025-01-15T10:30:00Z", "package_id": "pkg_feed_standard", "artifact": { "property_rid": "01916f3a-a1d3-7000-8000-000000000040", "artifact_id": "r_fitness_abc123", "assets": [ {"type": "text", "role": "title", "content": "Best protein sources for muscle building", "language": "en"}, {"type": "text", "role": "paragraph", "content": "Looking for recommendations on high-quality protein sources for recovery", "language": "en"}, {"type": "image", "url": "https://cdn.reddit.com/fitness-image.jpg", "alt_text": "Person lifting weights"} ] }, "country": "US", "channel": "social", "brand_context": {"brand_id": "nike_global", "sku_id": "air_max_2025"}, "local_verdict": "pass" }, { "record_id": "imp_12346", "timestamp": "2025-01-15T10:35:00Z", "package_id": "pkg_feed_standard", "artifact": { "property_rid": "01916f3a-a1d3-7000-8000-000000000040", "artifact_id": "r_news_politics_456", "assets": [ {"type": "text", "role": "title", "content": "Election Results Analysis", "language": "en"}, {"type": "text", "role": "paragraph", "content": "The latest polling data shows a tight race between candidates", "language": "en"} ] }, "country": "US", "channel": "social", "brand_context": {"brand_id": "nike_global", "sku_id": "air_max_2025"}, "local_verdict": "fail" } ], "collection_info": { "total_deliveries": 100000, "total_collected": 1000, "returned_count": 1000, "effective_rate": 0.01 }, "pagination": { "cursor": "eyJvZmZzZXQiOjEwMDB9", "has_more": true } } ``` ### Response Fields | Field | Description | | --------------------------- | -------------------------------------------------------------------------------- | | `artifacts` | Array of delivery records with full artifact content | | `artifacts[].country` | ISO 3166-1 alpha-2 country code where delivery occurred | | `artifacts[].channel` | Channel type (display, olv, ctv, podcast, social, etc.) | | `artifacts[].brand_context` | Brand/SKU information for policy evaluation (schema TBD) | | `artifacts[].local_verdict` | Seller's local model verdict (pass/fail/unevaluated) | | `collection_info` | Artifact collection statistics — what the seller collected per the buy agreement | | `pagination` | Cursor for fetching more results | ## Use Cases ### Validate Collected Artifacts ```python theme={null} # Get artifacts from seller (sampling was configured at buy creation time) artifacts_response = seller_agent.get_media_buy_artifacts( media_buy_id="mb_nike_reddit_q1" ) # Convert to validation records records = [ { "record_id": a["record_id"], "timestamp": a["timestamp"], "media_buy_id": artifacts_response["media_buy_id"], "artifact": a["artifact"], "country": a.get("country"), "channel": a.get("channel"), "brand_context": a.get("brand_context") } for a in artifacts_response["artifacts"] ] # Validate against verification agent validation = verification_agent.validate_content_delivery( standards_id="nike_brand_safety", records=records ) # Check for drift between local and verified verdicts for i, result in enumerate(validation["results"]): local = artifacts_response["artifacts"][i]["local_verdict"] verified = result["verdict"] if local != verified: print(f"Drift detected: {result['record_id']} - local={local}, verified={verified}") ``` ### Focus on Local Failures ```python theme={null} # Get only artifacts that failed local evaluation failures = seller_agent.get_media_buy_artifacts( media_buy_id="mb_nike_reddit_q1", failures_only=True, pagination={"max_results": 100} ) # Verify these were correctly flagged validation = verification_agent.validate_content_delivery( standards_id="nike_brand_safety", records=[{"record_id": a["record_id"], "artifact": a["artifact"]} for a in failures["artifacts"]] ) # Check false positive rate false_positives = sum(1 for r in validation["results"] if r["verdict"] == "pass") print(f"False positive rate: {false_positives / len(failures['artifacts']):.1%}") ``` ## failures\_only and Unevaluated Records When a seller does not run a local evaluation model, all records have `local_verdict: "unevaluated"`. In this case, `failures_only` returns an empty result set — there are no failures to return. Governance agents receiving validation results where every `local_verdict` is `"unevaluated"` should treat this as **no local enforcement**. The validation still works — the verification agent evaluates the artifacts normally — but there is no drift comparison to perform. Buyers can check `content_standards.supports_local_evaluation` in [get\_adcp\_capabilities](/dist/docs/3.1.20/protocol/get_adcp_capabilities#content_standards) to know whether `failures_only` will be useful before creating a buy. | `local_verdict` | `failures_only` returns? | Drift comparison possible? | | --------------- | ------------------------ | ------------------------------------------------------------- | | `fail` | Yes | Yes | | `pass` | No | N/A (not in result set) | | `unevaluated` | No | No — omit `failures_only` to retrieve all collected artifacts | ## Non-Web Artifact Examples ### Podcast ```json theme={null} { "record_id": "imp_podcast_001", "timestamp": "2025-02-10T08:00:00Z", "package_id": "pkg_mid_roll", "artifact": { "property_id": {"type": "apple_podcast_id", "value": "1234567890"}, "artifact_id": "episode_42_segment_3", "assets": [ {"type": "text", "role": "title", "content": "The Future of Running Shoes", "language": "en"}, {"type": "audio", "url": "https://cdn.example.com/secured/ep42_seg3.mp3", "transcript": "Today we're talking to Dr. Chen about biomechanics research and how it's changing shoe design for marathon runners...", "duration_ms": 480000} ], "metadata": { "json_ld": [{"@type": "PodcastEpisode", "episodeNumber": 42, "name": "The Future of Running Shoes"}] } }, "country": "US", "channel": "podcast", "brand_context": {"brand_id": "nike_global", "sku_id": "vaporfly_next"}, "local_verdict": "pass" } ``` ### CTV ```json theme={null} { "record_id": "imp_ctv_001", "timestamp": "2025-02-10T20:15:00Z", "package_id": "pkg_premium_ctv", "artifact": { "property_id": {"type": "app_id", "value": "com.streamingservice.tv"}, "artifact_id": "show_running_s2e5_scene_14", "assets": [ {"type": "text", "role": "title", "content": "Championship Race - Final Stretch", "language": "en"}, {"type": "video", "url": "https://cdn.streaming.example.com/secured/s2e5_scene14.mp4", "transcript": "The runners round the final corner as the crowd erupts. Commentary: 'And she's pulling ahead now, this is going to be close...'", "duration_ms": 120000} ] }, "country": "US", "channel": "ctv", "brand_context": {"brand_id": "nike_global"}, "local_verdict": "pass" } ``` ### AI-Generated Content ```json theme={null} { "record_id": "imp_ai_001", "timestamp": "2025-02-10T14:22:00Z", "package_id": "pkg_conversational", "artifact": { "property_id": {"type": "domain", "value": "chat.example.com"}, "artifact_id": "session_x7k9_turn_15", "assets": [ {"type": "text", "role": "paragraph", "content": "Based on your training schedule, I'd recommend increasing your long run distance by 10% each week. Here's a 12-week half-marathon plan...", "language": "en"} ] }, "country": "GB", "channel": "display", "brand_context": {"brand_id": "nike_global", "sku_id": "pegasus_41"}, "local_verdict": "unevaluated" } ``` Note: The AI-generated content example has `local_verdict: "unevaluated"` because the content is ephemeral and the platform relies on post-delivery validation rather than a local model. ## Delivery vs Artifacts | Aspect | get\_media\_buy\_delivery | get\_media\_buy\_artifacts | | ------------- | -------------------------- | -------------------------- | | **Purpose** | Performance reporting | Content validation | | **Data size** | Small (metrics) | Large (full content) | | **Frequency** | Regular reporting | Sampled validation | | **Contains** | Impressions, clicks, spend | Text, images, video | | **Consumer** | Buyer for optimization | Verification agent | ## Related Tasks * [validate\_content\_delivery](./validate_content_delivery) - Validate the artifacts * [calibrate\_content](./calibrate_content) - Understand why artifacts pass/fail * [get\_media\_buy\_delivery](../../../media-buy/task-reference/get_media_buy_delivery) - Get performance metrics # list_content_standards Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/tasks/list_content_standards list_content_standards returns available content safety configurations with optional filtering and pagination in AdCP. # list\_content\_standards List available content standards configurations. **Response time**: \< 500ms ## Request **Schema**: [list-content-standards-request.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/list-content-standards-request.json) | Parameter | Type | Required | Description | | ------------ | ------ | -------- | ------------------------------------------------------------------------------------------------- | | `countries` | array | No | Filter by ISO 3166-1 alpha-2 country codes (case-insensitive) | | `channels` | array | No | Filter by channels | | `languages` | array | No | Filter by ISO 639-1 or BCP 47 language tags (case-insensitive) | | `pagination` | object | No | Pagination: `max_results` (1-100, default 50) and `cursor` (opaque cursor from previous response) | ## Response **Schema**: [list-content-standards-response.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/list-content-standards-response.json) Returns an abbreviated list of standards configurations. Use [get\_content\_standards](./get_content_standards) to retrieve full details including policy text and calibration data. ### Success Response ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/list-content-standards-response.json", "status": "completed", "standards": [ { "standards_id": "emea_digital_safety", "name": "EMEA - all digital channels", "countries_all": ["GB", "DE", "FR"], "channels_any": ["display", "olv", "ctv"], "languages_any": ["en", "de", "fr"] }, { "standards_id": "us_display_only", "name": "US - display only", "countries_all": ["US"], "channels_any": ["display"], "languages_any": ["en"] } ] } ``` ### Error Response ```json theme={null} { "errors": [ { "code": "UNAUTHORIZED", "message": "Invalid or expired token" } ] } ``` ## Related Tasks * [get\_content\_standards](./get_content_standards) - Get a specific standards configuration * [create\_content\_standards](./create_content_standards) - Create a new configuration # update_content_standards Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/tasks/update_content_standards update_content_standards modifies an existing content standards configuration in AdCP, creating a new version for audit purposes. # update\_content\_standards Update an existing content standards configuration. Creates a new version. **Response time**: \< 1s ## Request **Schema**: [update-content-standards-request.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/update-content-standards-request.json) | Parameter | Type | Required | Description | | ----------------------- | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | | `standards_id` | string | Yes | ID of the standards configuration to update | | `scope` | object | No | Updated scope | | `policies` | PolicyEntry\[] | No | Updated array of policies. Replaces existing policies; use stable `policy_id` values to track policies across versions. | | `calibration_exemplars` | object | No | Updated training exemplars (pass/fail) | ### Example Request ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/update-content-standards-request.json", "idempotency_key": "b0c1d2e3-f4a5-4678-b901-678901234567", "standards_id": "nike_emea_safety", "policies": [ { "policy_id": "no_violence", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid content depicting violence, weapons, or physical aggression." }, { "policy_id": "no_controversial_politics", "policy_categories": ["brand_suitability"], "enforcement": "must", "policy": "Avoid controversial political content." }, { "policy_id": "no_adult_themes", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Avoid adult, sexual, or mature themes." }, { "policy_id": "no_hate_speech", "policy_categories": ["brand_safety"], "enforcement": "must", "policy": "Block hate speech and illegal activities." } ], "calibration_exemplars": { "pass": [ { "type": "url", "value": "https://espn.com/nba/story/_/id/12345/lakers-win", "language": "en" }, { "type": "url", "value": "https://healthline.com/fitness/cardio-workout", "language": "en" }, { "type": "url", "value": "https://runnersworld.com/training/marathon-tips", "language": "en" } ], "fail": [ { "type": "url", "value": "https://tabloid.example.com/celebrity-scandal", "language": "en" }, { "type": "url", "value": "https://gambling.example.com/betting-guide", "language": "en" } ] } } ``` ## Response **Schema**: [update-content-standards-response.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/update-content-standards-response.json) ### Success Response ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/update-content-standards-response.json", "status": "completed", "success": true, "standards_id": "nike_emea_safety" } ``` ### Error Response ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/update-content-standards-response.json", "status": "completed", "success": false, "errors": [ { "code": "REFERENCE_NOT_FOUND", "field": "standards_id", "message": "Referenced resource does not exist or is not accessible" } ] } ``` ## Related Tasks * [get\_content\_standards](./get_content_standards) - Get current configuration * [create\_content\_standards](./create_content_standards) - Create a new configuration # validate_content_delivery Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/content-standards/tasks/validate_content_delivery validate_content_delivery batch-evaluates delivery records against content safety policies asynchronously in AdCP. # validate\_content\_delivery Validate delivery records against content safety policies. Designed for batch auditing of where ads were actually delivered. **Asynchronous**: Accept immediately, process in background. Returns a `validation_id` for status polling. ## Data Flow Content artifacts are separate from delivery metrics. Use `get_media_buy_artifacts` to retrieve content for validation: ```mermaid theme={null} sequenceDiagram participant Buyer as Buyer Agent participant Seller as Seller Agent participant Verifier as Verification Agent Buyer->>Seller: get_media_buy_artifacts Seller-->>Buyer: Collected artifacts Buyer->>Verifier: validate_content_delivery Verifier-->>Buyer: Validation results ``` **Why through the buyer?** * The **buyer** owns the media buy and knows which `standards_id` applies * The **buyer** requests artifacts from sellers (separate from performance metrics) * The **buyer** is accountable for brand safety compliance * The **verification agent** works on behalf of the buyer This keeps responsibilities clear: sellers provide content samples via `get_media_buy_artifacts`, buyers validate samples against the verification agent. ## Request **Schema**: [validate-content-delivery-request.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/validate-content-delivery-request.json) | Parameter | Type | Required | Description | | ---------------- | ------- | -------- | ------------------------------------------------- | | `standards_id` | string | Yes | Standards configuration to validate against | | `records` | array | Yes | Delivery records to validate (max 10,000) | | `feature_ids` | array | No | Specific features to evaluate (defaults to all) | | `include_passed` | boolean | No | Include passed records in results (default: true) | ### Delivery Record ```json theme={null} { "record_id": "imp_12345", "timestamp": "2025-01-15T10:30:00Z", "media_buy_id": "mb_nike_reddit_q1", "artifact": { "property_id": {"type": "domain", "value": "example.com"}, "artifact_id": "article_12345", "assets": [ {"type": "text", "role": "title", "content": "Article Title"} ] }, "country": "US", "channel": "display", "brand_context": { "brand_id": "nike_global", "sku_id": "air_max_2025" } } ``` | Field | Required | Description | | --------------- | -------- | -------------------------------------------------------- | | `record_id` | Yes | Unique identifier for this delivery record | | `artifact` | Yes | Content artifact where ad was delivered | | `media_buy_id` | No | Media buy this record belongs to (for multi-buy batches) | | `timestamp` | No | When the delivery occurred | | `country` | No | ISO 3166-1 alpha-2 country code for targeting context | | `channel` | No | Channel type (display, video, audio, social) | | `brand_context` | No | Brand/SKU information for policy evaluation (schema TBD) | ## Response **Schema**: [validate-content-delivery-response.json](https://adcontextprotocol.org/schemas/3.1.20/content-standards/validate-content-delivery-response.json) ### Success Response ```json theme={null} { "$schema": "/schemas/3.1.20/content-standards/validate-content-delivery-response.json", "status": "completed", "summary": { "total_records": 1000, "passed_records": 950, "failed_records": 50 }, "results": [ { "record_id": "imp_12345", "verdict": "pass", "features": [ { "feature_id": "brand_safety", "status": "passed", "value": "safe" } ] }, { "record_id": "imp_12346", "verdict": "fail", "features": [ { "feature_id": "brand_safety", "status": "failed", "value": "high_risk", "message": "Content contains violence" } ] } ] } ``` ## Use Cases ### Post-Campaign Audit ```python theme={null} def audit_campaign_delivery(campaign_id, standards_id, content_standards_agent): """Audit all delivery records from a campaign.""" # Fetch delivery records from your ad server records = fetch_delivery_records(campaign_id) # Validate in batches batch_size = 10000 all_results = [] for i in range(0, len(records), batch_size): batch = records[i:i + batch_size] response = content_standards_agent.validate_content_delivery( standards_id=standards_id, records=batch ) all_results.extend(response["results"]) return all_results ``` ### Real-Time Monitoring Sample ```python theme={null} import random def sample_and_validate(records, standards_id, sample_size=1000): """Validate a random sample for real-time monitoring.""" sample = random.sample(records, min(sample_size, len(records))) return content_standards_agent.validate_content_delivery( standards_id=standards_id, records=sample ) ``` ### Filter for Issues Only ```python theme={null} # Only get failed records to reduce response size response = content_standards_agent.validate_content_delivery( standards_id="nike_emea_safety", records=delivery_records, include_passed=False # Only return failures ) for result in response["results"]: print(f"Issue with {result['record_id']}") for feature in result["features"]: if feature["status"] == "failed": print(f" - {feature['feature_id']}: {feature['message']}") ``` ## Related Tasks * [get\_media\_buy\_artifacts](./get_media_buy_artifacts) - Get content artifacts from seller * [calibrate\_content](./calibrate_content) - Understand why artifacts pass/fail * [get\_content\_standards](./get_content_standards) - Retrieve the policies # get_creative_features Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/creative/get_creative_features get_creative_features evaluates a creative manifest against a governance agent and returns feature values for brand safety and compliance in AdCP. # get\_creative\_features Evaluates a creative manifest and returns feature values from a creative governance agent. ## Use cases * **Security scanning**: Detect malware, auto-redirects, credential harvesting, cloaking * **Creative quality**: Evaluate brand consistency, platform optimization, guideline adherence * **Content categorization**: Classify creative content against IAB Content Taxonomy or other standards * **Accessibility**: Check WCAG compliance, screen reader compatibility ## Request ```json theme={null} { "$schema": "/schemas/3.1.20/creative/get-creative-features-request.json", "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "html5-display-300x250" }, "assets": { "creative_html": { "asset_type": "url", "url": "https://cdn.agency.com/creative/abc123.html" } } }, "feature_ids": ["auto_redirect", "credential_harvest", "cloaking"] } ``` ### Parameters | Parameter | Type | Required | Description | | ------------------- | --------- | -------- | ----------------------------------------------------------------------------------- | | `creative_manifest` | object | Yes | Creative manifest with `format_id` and `assets` | | `feature_ids` | string\[] | No | Filter to specific features. If omitted, evaluates all features the agent supports. | ### Authentication when used as a build evaluator `get_creative_features` is also the evaluator contract used when `build_creative.evaluator.agent_url` or `build_creative.evaluator.feature_agent.agent_url` points at an external evaluator. In that flow, the creative/seller agent is the caller and the evaluator authenticates it on the transport: RFC 9421 request signing with JWKS discovery is preferred, and mTLS or a pre-provisioned Bearer/API-key credential are acceptable when both parties have arranged them. The request payload is not an authentication channel. Evaluators MUST NOT treat `agent_url`, `account`, `context`, `ext`, or fields inside `creative_manifest` as proof of caller identity, and callers MUST NOT put evaluator credentials or caller-supplied trust material in those fields. Credential- or trust-material payload keys such as `api_key`, `client_secret`, `bearer`, `authorization`, `jwk`, `jwks`, or `jwks_uri` are non-conforming and should be rejected with [`CREDENTIAL_IN_ARGS`](/dist/docs/3.1.20/building/by-layer/L3/error-handling#authentication-and-access). After authenticating the transport, the evaluator maps the caller to its allowed creative agent/account configuration. A request signed by, or carrying credentials for, an unrecognized creative agent should fail authentication or authorization; it should not be accepted because the payload names an expected account. When this call was initiated by `build_creative` and the evaluator is unreachable or rejects the producing agent's transport authentication, the buyer-visible build should fall back to seller-default ranking with an advisory `errors[]` note rather than failing solely because evaluation was unavailable. ## Response ```json Security scanner (clean) theme={null} { "$schema": "/schemas/3.1.20/creative/get-creative-features-response.json", "status": "completed", "results": [ { "feature_id": "auto_redirect", "value": false }, { "feature_id": "credential_harvest", "value": false }, { "feature_id": "cloaking", "value": false } ], "detail_url": "https://scanner.example.com/reports/ctx_abc123" } ``` ```json Security scanner (threat detected) theme={null} { "$schema": "/schemas/3.1.20/creative/get-creative-features-response.json", "status": "completed", "results": [ { "feature_id": "auto_redirect", "value": true, "confidence": 0.97 }, { "feature_id": "credential_harvest", "value": true, "confidence": 0.91 }, { "feature_id": "cloaking", "value": false } ], "detail_url": "https://scanner.example.com/reports/ctx_def456" } ``` ```json Creative quality platform theme={null} { "$schema": "/schemas/3.1.20/creative/get-creative-features-response.json", "status": "completed", "results": [ { "feature_id": "brand_consistency", "value": 87, "unit": "percentage" }, { "feature_id": "platform_optimized", "value": true }, { "feature_id": "creative_quality_score", "value": 92, "unit": "score" } ], "detail_url": "https://quality.example.com/reports/ctx_ghi789" } ``` ```json Content categorizer theme={null} { "$schema": "/schemas/3.1.20/creative/get-creative-features-response.json", "status": "completed", "results": [ { "feature_id": "iab_casinos_gambling", "value": true, "confidence": 0.95 }, { "feature_id": "iab_automotive", "value": false, "confidence": 0.12 } ], "detail_url": "https://categorizer.example.com/reports/ctx_jkl012" } ``` ```json Provenance audit observation theme={null} { "$schema": "/schemas/3.1.20/creative/get-creative-features-response.json", "status": "completed", "results": [ { "feature_id": "ai_generated", "value": true, "confidence": 0.94 } ], "audit_observations": [ { "code": "OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED", "severity": "audit-worthy", "recovery": "informational", "field": "creative_manifest.provenance.disclosure.required", "message": "Creative claims human-directed AI output does not require disclosure; retain for audit review.", "details": { "agent_url": "https://governance.encypher.seller.example", "feature_id": "ai_generated", "claimed_value": { "human_oversight": "directed", "disclosure_required": false }, "observed_value": true, "confidence": 0.94 } } ] } ``` ### Response fields | Field | Description | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `results` | Array of feature evaluation results | | `results[].feature_id` | Which feature was evaluated | | `results[].value` | Feature value: boolean (binary), number (quantitative), or string (categorical) | | `results[].confidence` | Confidence score (0-1), when applicable | | `results[].unit` | Unit for quantitative values (e.g., `percentage`, `score`) | | `results[].expires_at` | When this evaluation expires and should be refreshed | | `results[].measured_at` | When this feature was evaluated | | `results[].methodology_version` | Version of methodology used | | `results[].details` | Vendor-specific details | | `detail_url` | URL to vendor's full assessment. Access-controlled by the vendor. | | `audit_observations[]` | Non-blocking observations for audit routing. These are not rejection signals by themselves. | | `audit_observations[].code` | Machine-readable observation code, such as `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED` | | `audit_observations[].severity` | Routing severity; currently `audit-worthy` | | `audit_observations[].recovery` | Caller recovery category; currently `informational` | | `audit_observations[].field` | Resolved creative manifest path for the risky claim side of the observation. Multi-field observations anchor the primary claim path, not necessarily every trigger field. | | `audit_observations[].message` | Human-readable audit queue summary; do not include PII, cross-tenant data, or vendor-only report details | | `audit_observations[].details` | Audit-safe details limited to `{ agent_url, feature_id, claimed_value, observed_value, confidence, substituted_for }` | | `audit_observations[].details.claimed_value` | For `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED`, a compact object `{ human_oversight, disclosure_required }`; `disclosure_required` is the flattened alias for `creative_manifest.provenance.disclosure.required`. | `feature_ids` filters `results[]`; `audit_observations[]` may still appear when the submitted provenance itself is audit-worthy. `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED` fires when `human_oversight` is `edited` or `directed` and `disclosure.required` is `false`; verifier observations such as `observed_value` and `confidence` are optional audit context. ### Error response ```json theme={null} { "errors": [ { "code": "CREATIVE_INACCESSIBLE", "message": "Could not retrieve creative assets for evaluation" } ] } ``` ## Async evaluation Some evaluations (e.g., sandboxed malware scanning) take time. The agent returns `status: "working"` and delivers results via webhook when complete. This uses the standard [async task pattern](/dist/docs/3.1.20/building/by-layer/L3/async-operations) — no custom status values needed. ## Orchestrator logic The orchestrator applies feature requirements on the client side, the same way property list feature requirements work: ```javascript theme={null} const result = await agent.getCreativeFeatures({ creative_manifest: manifest }); if (result.errors) { // Handle error - reject or retry return; } // Apply security requirements const threats = result.results.filter( f => ['auto_redirect', 'credential_harvest', 'cloaking'].includes(f.feature_id) && f.value === true ); if (threats.length > 0) { // Reject - security threat detected return; } // Apply quality requirements const quality = result.results.find(f => f.feature_id === 'brand_consistency'); if (quality && quality.value < 80) { // Reject - below quality threshold return; } for (const observation of result.audit_observations ?? []) { if (observation.severity === 'audit-worthy') { // Retain or route for review; do not reject solely on this signal. recordAuditObservation(observation); } } ``` ## Relationship to property governance Creative governance follows the same pattern as property governance: | Concept | Property governance | Creative governance | | ------------------------- | --------------------------------------- | -------------------------------------------------- | | **What's evaluated** | Properties (websites, apps) | Creatives (manifests) | | **Feature declarations** | `governance.property_features` | `governance.creative_features` | | **Evaluation task** | Property list filters | `get_creative_features` | | **Feature values** | `property-feature-value` schema | Same fields (value, confidence, expires\_at, etc.) | | **Detailed intelligence** | Behind `detail_url` / `methodology_url` | Behind `detail_url` / `methodology_url` | # Creative Governance Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/creative/index AdCP Creative Governance provides standardized creative evaluation — security scanning, AI provenance verification, and accessibility compliance checks. Creative Governance standardizes how creatives are evaluated by specialized governance agents. It applies the same feature-based pattern as [Property Governance](../property/index) — agents declare features they can evaluate, accept creative manifests, and return feature values. ## Overview Creative governance agents evaluate creatives and return feature values. Different agents evaluate different features: | Agent type | Example features | Feature type | | -------------------------- | ------------------------------------------------------------------- | ------------------------ | | **Security scanner** | `auto_redirect`, `credential_harvest`, `cloaking` | Binary | | **Creative quality** | `brand_consistency`, `platform_optimized`, `creative_quality_score` | Quantitative, binary | | **Content categorization** | `iab_casinos_gambling`, `iab_automotive` | Binary (with confidence) | The protocol doesn't define a fixed feature taxonomy. Vendors declare what they evaluate via [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) and compete on coverage. ## How it works ### 1. Agent declares features A creative governance agent advertises its capabilities using the same feature definition pattern as property governance: ```json Security scanner theme={null} { "governance": { "creative_features": [ { "feature_id": "auto_redirect", "type": "binary", "description": "Unauthorized navigation away from publisher context without user interaction", "methodology_url": "https://scanner.example.com/methodology" }, { "feature_id": "credential_harvest", "type": "binary", "description": "Phishing techniques to gather user credentials or PII", "methodology_url": "https://scanner.example.com/methodology" }, { "feature_id": "cloaking", "type": "binary", "description": "Creative masks or misrepresents content to evade detection", "methodology_url": "https://scanner.example.com/methodology" } ] } } ``` ```json Creative quality platform theme={null} { "governance": { "creative_features": [ { "feature_id": "brand_consistency", "type": "quantitative", "range": { "min": 0, "max": 100 }, "description": "Adherence to brand guidelines including logo placement, colors, and typography", "methodology_url": "https://quality.example.com/methodology" }, { "feature_id": "platform_optimized", "type": "binary", "description": "Creative meets platform-specific best practices (aspect ratio, text overlay limits)", "methodology_url": "https://quality.example.com/methodology" } ] } } ``` ```json Content categorizer theme={null} { "governance": { "creative_features": [ { "feature_id": "iab_casinos_gambling", "type": "binary", "description": "Creative contains casinos or gambling content (IAB Content Taxonomy 3.1, ID 181)", "methodology_url": "https://categorizer.example.com/methodology" }, { "feature_id": "iab_automotive", "type": "binary", "description": "Creative contains automotive content (IAB Content Taxonomy 3.1, ID 1)", "methodology_url": "https://categorizer.example.com/methodology" } ] } } ``` The orchestrator reads these declarations via `get_adcp_capabilities` before evaluating any creative. If a required feature is absent from an agent's declaration, the orchestrator surfaces the gap immediately rather than discovering it mid-evaluation. ### 2. Orchestrator evaluates a creative The orchestrator calls [`get_creative_features`](./get_creative_features) with a creative manifest: ```json theme={null} { "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "html5-display-300x250" }, "assets": { "creative_html": { "url": "https://cdn.agency.com/creative/abc123.html" } } } } ``` ### 3. Agent returns feature values The agent evaluates the creative and returns feature values. The response shape is the same regardless of agent type: ```json theme={null} { "results": [ { "feature_id": "auto_redirect", "value": true, "confidence": 0.97 }, { "feature_id": "credential_harvest", "value": false }, { "feature_id": "cloaking", "value": false } ], "detail_url": "https://scanner.example.com/reports/ctx_abc123" } ``` ### 4. Orchestrator applies requirements The orchestrator evaluates the feature values against buyer-defined requirements — the same pattern as property list feature requirements: * Security: reject if `auto_redirect` is `true` * Quality: reject if `brand_consistency` is below 80 * Categorization: reject if `iab_casinos_gambling` is `true` and campaign excludes gambling ## Design principles **Feature IDs enforce strictness, not schema rigidity.** A categorization agent that declares `iab_casinos_gambling` using IAB Content Taxonomy 3.1 ID 181 is as strict as any custom schema — the discipline lives in the agent's methodology, not in the wire format. An orchestrator that requires `iab_casinos_gambling: false` gets a binary pass/fail answer regardless of how the agent detected the content. The feature ID is the contract. The protocol staying schema-agnostic means adding a new IAB category or scanning technique does not require a protocol change. **Confidence is opt-in, not mandatory.** The `confidence` field on a feature result is optional. Security scanners typically omit it — a creative either contains a credential harvesting pattern or it does not. Categorization agents include it because content detection is probabilistic: a creative may be 94% likely to contain gambling content. The agent decides what to disclose. An orchestrator that cannot tolerate ambiguity requires `value: false` and ignores confidence entirely; an orchestrator that wants to threshold probabilistic results uses it. This is the same field that exists on `property-feature-value` for property governance. **Orchestrator-enforced consistency.** An orchestrator that requires `iab_casinos_gambling` from its categorization agent discovers at capability-check time — before any creative is evaluated — whether the agent supports that feature. If it does not, the orchestrator fails fast and surfaces the gap. This mirrors how property governance works: IAS and DoubleVerify evaluate different property features; the orchestrator determines which features are required and routes accordingly. Mandating a fixed feature set in the protocol schema would mean every new IAB category or custom brand requirement requires a protocol revision. The protocol defines the enforcement mechanism; the orchestrator defines the requirements. **Opaque detailed intelligence.** Feature values on the wire are pass/fail (binary) or scores (quantitative). Detection methodology, threat intelligence, and detailed scoring breakdowns stay behind the vendor's access-controlled `detail_url` and `methodology_url`. ## Multi-agent collaboration Creative governance evaluations typically involve multiple specialist agents working in parallel — the same pattern property governance uses for sustainability, quality, and suitability agents. | Agent | Features returned | Orchestrator requirement | | ------------------------- | ----------------------------------------- | ------------------------------------ | | Security scanner | `auto_redirect`, `cloaking` | Block if any are `true` | | Creative quality platform | `brand_consistency`, `platform_optimized` | Block if score below threshold | | Content categorizer | `iab_casinos_gambling`, `iab_automotive` | Block if excluded category is `true` | Each agent uses the same `get_creative_features` task. The orchestrator calls them in parallel, collects independent result sets, and applies its requirements across all of them. No agent needs to know about the others. This means a buyer can: * Add a new specialist agent without changing existing agents or the evaluation protocol * Apply different confidence thresholds per agent type * Replace one vendor with another without changing the evaluation logic for other agents The orchestrator's feature requirements — not the protocol schema — define what "complete" creative governance means for a given campaign. ```mermaid theme={null} flowchart TB subgraph Orchestrator["ORCHESTRATOR"] O1[Calls get_creative_features on each agent] O2[Aggregates results] O3[Applies feature requirements] end subgraph Agents["CREATIVE GOVERNANCE AGENTS"] SEC["Security Scanner
auto_redirect
cloaking
credential_harvest"] QUA["Creative Quality
brand_consistency
platform_optimized"] CAT["Content Categorizer
iab_casinos_gambling
iab_automotive"] end Creative["Creative Manifest"] --> O1 O1 --> SEC O1 --> QUA O1 --> CAT SEC --> O2 QUA --> O2 CAT --> O2 O2 --> O3 ``` ## Pricing Creative governance agents that charge for evaluations use the same vendor pricing pattern as other AdCP vendor services. When the buyer provides `account` on the `get_creative_features` request, the response includes `pricing_option_id`, `vendor_cost`, `currency`, and optionally `consumption` details (e.g., `tokens` for LLM-based scanning). The buyer reports usage via [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) with `standards_id` and `pricing_option_id` for billing reconciliation. This is the same discover-execute-report loop used by [creative agents](/dist/docs/3.1.20/creative/specification#pricing), [signals agents](/dist/docs/3.1.20/signals/specification), and [content standards](/dist/docs/3.1.20/governance/content-standards/index). Governance agents that charge MUST implement the [Accounts Protocol](/dist/docs/3.1.20/accounts/overview). ## Async evaluation If evaluation takes time (e.g., sandboxed execution for malware scanning), the agent returns `status: "working"` and delivers results via the standard webhook mechanism. No custom status values or webhook events needed — the existing [async task pattern](/dist/docs/3.1.20/building/by-layer/L3/async-operations) handles this. # Provenance verification Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/creative/provenance-verification How AI provenance claims travel with creatives in AdCP and get independently verified at each enforcement point in the delivery chain. When a creative arrives with a provenance claim, the receiving party needs to decide whether to trust it. This page describes how AdCP handles that decision: provenance claims travel with the creative from buyer to seller, and each enforcement point — the publisher, the SSP, the verification vendor — runs its own independent check. No single party's attestation is taken at face value. This separation between declaration and verification is what makes the system work when the parties involved have competing incentives. ## Three-moment lifecycle AI provenance flows through three distinct moments, each handled by existing AdCP tasks. ```mermaid theme={null} flowchart LR subgraph declare["1. DECLARATION"] direction TB D1["sync_creatives / build_creative"] D2["Buyer attaches provenance
to creative manifest or assets"] D1 --> D2 end subgraph verify["2. VERIFICATION"] direction TB V1["get_creative_features /
calibrate_content"] V2["Governance agent evaluates
content independently"] V1 --> V2 end subgraph enforce["3. ENFORCEMENT"] direction TB E1["creative_policy /
validate_content_delivery"] E2["Seller/publisher applies rules
and accepts or rejects"] E1 --> E2 end declare --> verify --> enforce ``` | Moment | When | Who | Task | | ---------------- | ------------------------------------------ | ------------------------------- | ---------------------------------------------- | | **Declaration** | Creative submission | Buyer, agency, or creative tool | `sync_creatives`, `build_creative` | | **Verification** | Before trafficking or during calibration | Seller's governance agent | `get_creative_features`, `calibrate_content` | | **Enforcement** | Acceptance decision or post-delivery audit | Seller agent, buyer agent | `creative_policy`, `validate_content_delivery` | Each moment is independent. A buyer can declare provenance without any verification having occurred. A seller can verify without requiring a declaration. Enforcement can happen with or without both. ## What buyers can declare Provenance carries three families of evidence. Each survives a different set of supply-chain operations. | Field | What it carries | Survives transcoding? | | ----------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | `c2pa.manifest_url` | Sidecar reference to a detached cryptographic manifest | No — file-level bindings break under ad-server transcoding, resize, and re-encode | | `embedded_provenance[]` | Provenance metadata embedded *within* the content stream (manifest wrapper or invisible markers) | Yes — designed to persist through CMS ingestion, copy-paste, reformatting, and CDN re-encoding | | `watermarks[]` | Identifier or fingerprint encoded into the content (audio, image, video, or text watermark) | Yes — survives transformations that preserve perceptual content | `embedded_provenance` carries a structured provenance record (the chain of custody). `watermarks` encode an identifier (who generated it, who owns it). A single asset may carry both; pick the right field for what you actually attached. ## The verifier contract: seller publishes, buyer represents, seller confirms Earlier drafts of this work imagined buyers nominating verification endpoints unilaterally. That pattern shipped SSRF risk, vendor sprawl, and a fundamentally wrong trust model: the seller is the verifier-of-record because the seller bears the regulatory liability for what it publishes. The protocol now reflects that. The contract is three steps, each with a clear actor: 1. **Seller publishes** the governance agents it accepts on `creative_policy.accepted_verifiers[]` (returned by `get_products`). Each entry carries `agent_url`, optional `feature_id` (which feature the seller will request against the agent), and optional `providers[]` (which `provider` labels the agent covers). The seller has already vetted these endpoints; calls to them are inside the seller's allowlist. 2. **Buyer represents** which of those agents was used by attaching a `verify_agent: { agent_url, feature_id? }` pointer on each `embedded_provenance[]` or `watermarks[]` entry. The buyer's `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url`. This is buyer-supplied evidence, not buyer-driven routing. 3. **Seller confirms** by cross-checking the buyer's `verify_agent.agent_url` against the published list (rejecting off-list URLs with `PROVENANCE_VERIFIER_NOT_ACCEPTED` before any outbound call), then invoking `get_creative_features` against the matching on-list agent and reconciling the result against the buyer's claim. The seller MAY use a different on-list agent than the buyer nominated; the seller is the verifier-of-record. When it does substitute, `error.details` carries both the agent it called and `substituted_for` so the buyer can audit. ```json theme={null} // 1. Seller publishes — returned by get_products on each Product { "creative_policy": { "accepted_verifiers": [ { "agent_url": "https://governance.encypher.seller.example", "feature_id": "encypher.markers_present_v2", "providers": ["Encypher"] }, { "agent_url": "https://governance.imatag.seller.example", "feature_id": "imatag.watermark_detected", "providers": ["Imatag"] } ] } } // 2. Buyer represents — attached to creative on sync_creatives { "embedded_provenance": [ { "method": "provenance_markers", "provider": "Encypher", "verify_agent": { "agent_url": "https://governance.encypher.seller.example", "feature_id": "encypher.markers_present_v2" } } ] } // 3. Seller confirms — cross-check, then call, then reconcile. // Off-list URL → PROVENANCE_VERIFIER_NOT_ACCEPTED, no outbound call. // On-list URL with contradicting verifier result → PROVENANCE_CLAIM_CONTRADICTED. ``` Sellers MUST NOT call URLs that are not in `accepted_verifiers` — that closes the buyer-controlled-URL trust gap. Buyers that omit `verify_agent` (e.g., on a self-verifiable C2PA text manifest with a public key the seller already trusts) leave the agent selection entirely to the seller's discovery. ## What sellers can require Sellers express what they need on `creative_policy`, returned by `get_products` so buyers see requirements before submitting: | Field | Effect | | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `provenance_required: true` | Creative MUST carry *some* provenance object, anywhere in the inheritance chain. Submission without provenance is rejected with `PROVENANCE_REQUIRED`. | | `provenance_requirements.{require_digital_source_type, require_disclosure_metadata, require_embedded_provenance}` | Field-level requirements. Submission missing the named field is rejected with the corresponding `PROVENANCE_*_MISSING` code. Field-level requirements are seller-enforced — JSON Schema validation does not check them. | | `accepted_verifiers[]` | Governance agents the seller will call to verify provenance claims. Buyers' `verify_agent` references MUST be a canonicalized match of one of these `agent_url` values. | Sellers that publish a requirement MUST enforce it on `sync_creatives` — that's the structural-rejection contract. The truth-of-claim contract (does the buyer's `digital_source_type` actually match the content?) lives in `get_creative_features` and surfaces as `PROVENANCE_CLAIM_CONTRADICTED`. ### Rejection error codes When `sync_creatives` rejects a creative for provenance reasons, the per-creative result carries a structured error with one of: | Code | Meaning | `error.field` points at | | ---------------------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PROVENANCE_REQUIRED` | No provenance object anywhere on the creative, but `provenance_required: true` | The manifest path where provenance was expected | | `PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING` | Resolved provenance has no `digital_source_type` | The resolved `provenance.digital_source_type` path | | `PROVENANCE_DISCLOSURE_MISSING` | Resolved provenance has no `disclosure.required` (or true without jurisdictions) | The resolved `provenance.disclosure` path | | `PROVENANCE_EMBEDDED_MISSING` | Resolved provenance has no `embedded_provenance` entry | The resolved `provenance.embedded_provenance` path | | `PROVENANCE_VERIFIER_NOT_ACCEPTED` | `verify_agent.agent_url` is not on the seller's `accepted_verifiers` list | The offending `verify_agent.agent_url` path | | `PROVENANCE_CLAIM_CONTRADICTED` | Verifier (called from `accepted_verifiers`) actively refutes the buyer's claim | The provenance field whose claim was refuted; `error.details` is limited to `{ agent_url, feature_id, claimed_value, observed_value, confidence }` plus `substituted_for` when the seller used an on-list agent other than the one the buyer nominated | Buyers receiving these codes can self-correct without negotiating with the seller — the failure is machine-readable. Auto-retry without correction will not pass. ### Audit observations, not rejections Some provenance claims deserve audit routing even when the verifier has not refuted them. The canonical case is `provenance.human_oversight` set to `edited` or `directed` while `provenance.disclosure.required` is `false`. That combination may be legitimate when the declaring party invokes an editorial-responsibility carve-out, but the verifier cannot adjudicate the legal prerequisites from schema fields alone. Governance agents SHOULD surface that claim combination on successful `get_creative_features` responses as `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED`. The observation is claim-driven: verifier observations such as `observed_value` and `confidence` are useful audit context when available, but they are not required for the observation to fire. `field` anchors the risky claim side of the multi-field combination, so the canonical path is `creative_manifest.provenance.disclosure.required`. ```json theme={null} { "audit_observations": [ { "code": "OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED", "severity": "audit-worthy", "recovery": "informational", "field": "creative_manifest.provenance.disclosure.required", "message": "Creative claims human-directed AI output does not require disclosure; retain for audit review.", "details": { "agent_url": "https://governance.encypher.seller.example", "feature_id": "ai_generated", "claimed_value": { "human_oversight": "directed", "disclosure_required": false }, "observed_value": true, "confidence": 0.94 } } ] } ``` This observation is not `PROVENANCE_CLAIM_CONTRADICTED`, and it is not grounds for rejection on its own. Sellers MAY accept the creative while retaining the observation for audit, routing it to a human reviewer, or asking the declaring party for corroborating evidence out of band. As with contradiction errors, `audit_observations[].details` is limited to the audit-safe allowlist `{ agent_url, feature_id, claimed_value, observed_value, confidence, substituted_for }`; for `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED`, `claimed_value` is `{ human_oversight, disclosure_required }`, with `disclosure_required` as the flattened alias for `creative_manifest.provenance.disclosure.required`. Sellers MUST NOT copy arbitrary verifier extension fields, `detail_url`, or cross-tenant report data into their own buyer-facing responses. ## AI detection via get\_creative\_features AI detection is a creative governance feature, evaluated by specialist agents through `get_creative_features` -- the same task used for security scanning, creative quality, and content categorization. AI detection does not require a separate protocol or workflow. ### Agent declares AI detection capabilities An AI detection agent advertises its features via `get_adcp_capabilities`: ```json theme={null} { "governance": { "creative_features": [ { "feature_id": "ai_generated", "type": "binary", "description": "Whether the creative contains AI-generated content", "methodology_url": "https://detector.example.com/methodology" }, { "feature_id": "ai_modified", "type": "binary", "description": "Whether the creative contains AI-modified elements", "methodology_url": "https://detector.example.com/methodology" }, { "feature_id": "ai_confidence", "type": "quantitative", "range": { "min": 0, "max": 1 }, "description": "Confidence score for AI detection result", "methodology_url": "https://detector.example.com/methodology" } ] } } ``` ### Seller evaluates a creative The seller sends the creative manifest to the AI detection agent: ```json theme={null} { "creative_manifest": { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "assets": { "banner_image": { "url": "https://cdn.novabrands.example.com/hero.jpg", "width": 300, "height": 250 }, "headline": { "content": "Nutrition dogs love" } } }, "feature_ids": ["ai_generated", "ai_modified", "ai_confidence"] } ``` ### Agent returns detection results ```json theme={null} { "results": [ { "feature_id": "ai_generated", "value": true, "confidence": 0.94 }, { "feature_id": "ai_modified", "value": false }, { "feature_id": "ai_confidence", "value": 0.94 } ], "detail_url": "https://detector.example.com/reports/ctx_xyz789" } ``` ### Seller applies enforcement logic The seller compares the detection result against the buyer's provenance claim: ```javascript theme={null} const provenance = creative.manifest.provenance; const detection = await detectionAgent.getCreativeFeatures({ creative_manifest: creative.manifest, feature_ids: ['ai_generated'] }); if (detection.errors) { // Detection failed - handle based on policy return; } const aiDetected = detection.results.find( f => f.feature_id === 'ai_generated' ); const claimsDisclosureCarveout = ['edited', 'directed'].includes(provenance?.human_oversight) && provenance?.disclosure?.required === false; // Case 1: Provenance claims non-AI, detection says AI if ( provenance?.digital_source_type === 'digital_capture' && aiDetected?.value === true && aiDetected?.confidence > 0.9 ) { // Reject - provenance claim contradicts detection return; } // Case 2: AI content with a declared editorial-responsibility carve-out if ( aiDetected?.value === true && claimsDisclosureCarveout ) { // Accept or continue review; retain audit_observations[] for audit routing recordAuditObservations(detection.audit_observations ?? []); return; } // Case 3: AI content without disclosure metadata or a carve-out claim if ( aiDetected?.value === true && !provenance?.disclosure?.required ) { // Reject - AI content without required disclosure metadata return; } ``` ### Multi-agent evaluation AI detection fits naturally into the multi-agent creative governance pattern. A seller evaluating a creative can call multiple specialist agents in parallel: | Agent | Features | Provenance relevance | | ------------------- | ----------------------------- | --------------------------- | | Security scanner | `auto_redirect`, `cloaking` | None -- independent concern | | AI detection | `ai_generated`, `ai_modified` | Verifies provenance claims | | Content categorizer | `iab_casinos_gambling` | None -- independent concern | | Creative quality | `brand_consistency` | None -- independent concern | The orchestrator calls all agents via `get_creative_features`, aggregates results, and applies its requirements across all of them. AI detection is one column in the evaluation matrix, not a separate workflow. ## Content standards integration For publisher content (artifacts), provenance verification uses the content standards infrastructure: `calibrate_content` for alignment and `validate_content_delivery` for auditing. ### Artifact provenance Publishers declare provenance on artifacts the same way buyers declare it on creatives: ```json theme={null} { "property_id": { "type": "domain", "value": "newssite.example.com" }, "artifact_id": "article_trends_2026", "provenance": { "digital_source_type": "digital_creation", "declared_by": { "role": "platform" } }, "assets": [ { "type": "text", "role": "title", "content": "Industry trends to watch in 2026" }, { "type": "image", "url": "https://cdn.newssite.example.com/ai-illustration.jpg", "alt_text": "Conceptual illustration", "provenance": { "digital_source_type": "trained_algorithmic_media", "ai_tool": { "name": "Midjourney", "version": "v7" }, "declared_by": { "role": "platform" } } } ] } ``` ### Calibration for AI provenance During `calibrate_content`, the verification agent can evaluate whether artifact provenance claims are accurate. This uses the same calibration dialogue as brand suitability -- the verification agent returns verdicts with explanations: ```json theme={null} { "verdict": "fail", "explanation": "The article's hero image shows strong indicators of AI generation (GAN artifacts, inconsistent lighting) but is marked as digital_creation. The provenance claim does not match detection results.", "features": [ { "feature_id": "provenance_accuracy", "status": "failed", "explanation": "Image asset provenance claims digital_creation but AI detection confidence is 0.92." }, { "feature_id": "brand_safety", "status": "passed", "explanation": "No safety concerns with the content itself." } ] } ``` ### Post-delivery validation Buyers can audit AI provenance in delivered content through `validate_content_delivery`, the same task used for brand suitability auditing: ```json theme={null} { "standards_id": "acme_ai_disclosure_policy", "records": [ { "record_id": "imp_54321", "media_buy_id": "mb_acme_q1", "artifact": { "property_id": { "type": "domain", "value": "newssite.example.com" }, "artifact_id": "article_trends_2026", "provenance": { "digital_source_type": "digital_creation", "declared_by": { "role": "platform" } }, "assets": [ { "type": "image", "url": "https://cdn.newssite.example.com/ai-illustration.jpg" } ] } } ] } ``` ## Compliance profiles Different regulatory environments require different levels of provenance enforcement. Here are example configurations. ```json EU (strict) theme={null} { "profile": "eu_strict", "creative_policy": { "provenance_required": true }, "enforcement_rules": { "ai_detection_required": true, "ai_detection_confidence_threshold": 0.85, "disclosure_required_for": [ "trained_algorithmic_media", "composite_with_trained_algorithmic_media", "human_edits" ], "reject_on_mismatch": true, "jurisdictions": [ { "country": "DE", "regulation": "eu_ai_act_article_50", "label_text": "KI-generiert" }, { "country": "FR", "regulation": "eu_ai_act_article_50", "label_text": "Contenu généré par l'IA" } ] } } ``` ```json US/California (moderate) theme={null} { "profile": "us_california", "creative_policy": { "provenance_required": true }, "enforcement_rules": { "ai_detection_required": true, "ai_detection_confidence_threshold": 0.90, "disclosure_required_for": [ "trained_algorithmic_media", "composite_with_trained_algorithmic_media" ], "reject_on_mismatch": true, "jurisdictions": [ { "country": "US", "region": "CA", "regulation": "ca_sb_942", "label_text": "Created with AI" } ] } } ``` ```json Permissive theme={null} { "profile": "permissive", "creative_policy": { "provenance_required": false }, "enforcement_rules": { "ai_detection_required": false, "log_provenance_if_present": true, "reject_on_mismatch": false } } ``` These profiles are illustrative configurations, not schema-defined objects. Each seller implements enforcement logic suited to their regulatory requirements. The AdCP schemas provide the data model; the enforcement rules are implementation decisions. ## For regulators AdCP provides a machine-readable, protocol-level mechanism for AI disclosure in programmatic advertising. Every creative and content artifact in the supply chain can carry structured provenance metadata that declares the digital source type, the AI tools used, the level of human oversight, and the applicable disclosure requirements by jurisdiction — including specific regulation identifiers such as `eu_ai_act_article_50`, `ca_sb_942`, and `cn_deep_synthesis`. This metadata uses the IPTC digital source type vocabulary, the same classification system adopted by C2PA Content Credentials, Meta, and Google for AI content labeling. AdCP does not invent a new taxonomy. It carries an existing, widely adopted one through the advertising supply chain where it has not previously been available in structured form. ### Verification is independent, not self-reported Provenance in AdCP is explicitly a claim, not a certification. The declaring party — typically the advertiser or their agency — attaches provenance when submitting a creative. The enforcing party — typically the publisher or their supply-side platform — verifies that claim independently using AI detection services, C2PA manifest validation, or both. This verification happens through existing AdCP governance mechanisms (`get_creative_features` for creatives, `calibrate_content` for publisher content) and does not require new infrastructure. This architecture addresses a structural problem in advertising compliance: the party submitting the creative has an incentive to understate AI involvement (to avoid placement restrictions or disclosure requirements), while the party publishing the creative bears the regulatory liability for non-disclosure. By treating provenance as a verifiable claim rather than a trusted assertion, the protocol ensures that compliance does not depend on the good faith of any single participant. ### Mapping to regulatory requirements **EU AI Act Article 50**: Imposes obligations on the providers of generative AI systems (Art 50(2)) and on the deployers who put AI-generated content into circulation (Art 50(4)), with a user-facing disclosure obligation at first exposure (Art 50(5)). The deployer in an advertising flow is typically the advertiser or agency, not the publisher. AdCP carries the machine-readable signals that make Article 50 evidence operable — `digital_source_type` classifies AI involvement at the asset level, `disclosure.jurisdictions` carries jurisdiction-specific label text and render guidance, and `human_oversight` records the level of human involvement relevant to the Art 50(4) editorial-responsibility carve-out. Enforcement points can filter or flag creatives based on `digital_source_type` values that indicate AI generation (`trained_algorithmic_media`, `composite_with_trained_algorithmic_media`). The protocol does not perform the regulatory analysis or determine which party is the deployer. **California SB 942**: Imposes disclosure obligations on covered platforms (defined by MAU threshold) when content is generated or substantially modified by AI. The `digital_source_type` and `human_oversight` fields carry the factual signals the covered party uses to make its disclosure determination, and `disclosure.required` carries the declaring party's resulting claim. The flag is a *declared* signal in the supply chain — useful as a routing and audit input — not a substitute for the covered party's own analysis. A seller relying on `disclosure.required: false` without verification is relying on the buyer's claim, not a determination the protocol made. **Platform mandates (Meta, Google, TikTok)**: Major platforms already require AI content labeling using IPTC-aligned metadata. AdCP's provenance structure is directly compatible with these requirements because it uses the same underlying vocabulary. AdCP does not determine which regulations apply to a given creative. It provides the structured metadata that allows each enforcement point to apply its own jurisdictional rules. The protocol carries the data; the enforcing party makes the compliance decision. ### Verification flow ```mermaid theme={null} flowchart TB subgraph creative["Advertising creatives"] C1["Buyer declares provenance
on creative manifest"] C2["Seller requires provenance
via creative_policy"] C3["AI detection agent evaluates
via get_creative_features"] C4["Seller compares claim vs detection
accepts or rejects"] C1 --> C2 --> C3 --> C4 end subgraph content["Publisher content"] P1["Publisher declares provenance
on artifact and assets"] P2["Verification agent calibrates
via calibrate_content"] P3["Buyer audits delivery
via validate_content_delivery"] P1 --> P2 --> P3 end ``` ## Implementation checklist ### Buyers (brands and agencies) | Requirement | Description | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Attach provenance to creatives | Set `provenance` on `creative-asset` or `creative-manifest` when submitting via `sync_creatives` | | Classify AI involvement | Use the correct `digital_source_type` for each creative and asset | | Declare AI tooling | Populate `ai_tool` when AI systems were used in production | | Set human oversight level | Indicate `human_oversight` when AI is involved | | Declare disclosure obligations | Populate `disclosure.jurisdictions` for each applicable regulation | | Preserve C2PA references | Include `c2pa.manifest_url` when content credentials exist | | Use transcoding-resilient declarations | When the pipeline includes intermediaries that transcode or re-encode assets, attach `embedded_provenance[]` and/or `watermarks[]` rather than relying on `c2pa.manifest_url` alone | | Represent the verifier from the seller's allowlist | Populate `verify_agent.agent_url` on `embedded_provenance[]` / `watermarks[]` entries with a value drawn from the product's `creative_policy.accepted_verifiers[].agent_url`. Off-list URLs are rejected without an outbound call | | Read seller requirements upfront | Inspect `creative_policy.provenance_required`, `creative_policy.provenance_requirements`, and `creative_policy.accepted_verifiers` from `get_products` and pre-flight your creative shape before calling `sync_creatives` | | Handle rejection codes | Treat `PROVENANCE_REQUIRED`, `PROVENANCE_*_MISSING`, `PROVENANCE_VERIFIER_NOT_ACCEPTED`, and `PROVENANCE_CLAIM_CONTRADICTED` as machine-readable correctable errors — auto-retry without correction will not pass | | Run pre-submission detection | Optionally attach `verification` results from your own detection services | ### Sellers (publishers and platforms) | Requirement | Description | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Set creative policy | Add `provenance_required: true` to `creative-policy` if provenance is required | | Publish field-level requirements | Set `provenance_requirements.require_*` flags for fields you intend to enforce — buyers read these from `get_products` and self-correct before submission | | Publish accepted verifiers | Populate `creative_policy.accepted_verifiers[]` with the governance agents you operate or have allowlisted. Buyers' `verify_agent` references MUST be a canonicalized match of one of these `agent_url` values | | Enforce structural requirements | Reject `sync_creatives` submissions that omit any required field with the matching `PROVENANCE_*` error code (per-creative `errors[]`, `error.field` pointing at the missing path) | | Cross-check `verify_agent` before calling | Confirm the buyer's `verify_agent.agent_url` is on `accepted_verifiers` (canonicalized) before any outbound call. Off-list URLs MUST be rejected with `PROVENANCE_VERIFIER_NOT_ACCEPTED` and never called | | Run AI detection | Call `get_creative_features` against the on-list governance agent that matches the buyer's representation (or one you select from `accepted_verifiers` if the buyer omits `verify_agent`) | | Compare claims vs detection | Implement logic to compare `digital_source_type` against `ai_generated` feature results | | Reject contradictions | Use `PROVENANCE_CLAIM_CONTRADICTED` with `error.details` limited to `{ agent_url, feature_id, claimed_value, observed_value, confidence }` (and `substituted_for` when you called an on-list agent other than the one the buyer nominated) | | Record audit observations | Preserve `get_creative_features.audit_observations[]` for downstream audit routing; do not reject solely because `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED` is present | | Enforce disclosure | Verify that AI content includes appropriate `disclosure` metadata for target jurisdictions | | Declare artifact provenance | Attach `provenance` to content artifacts submitted via content standards | ### Creative agents | Requirement | Description | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | Attach provenance to generated content | When `build_creative` generates AI content, attach provenance with `digital_source_type`, `ai_tool`, and `human_oversight` | | Set `declared_by` role to `tool` | Creative agents that attach provenance should identify themselves with role `tool` | | Carry through C2PA | If source assets have C2PA manifests, carry the reference through to the generated manifest | ### Governance agents (AI detection) | Requirement | Description | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Declare detection features | Advertise `ai_generated` and related features via `get_adcp_capabilities` | | Implement `get_creative_features` | Accept creative manifests and return detection results | | Return confidence scores | Include `confidence` on detection results for probabilistic assessments | | Surface audit-worthy carve-out claims | Emit `audit_observations[]` with `OVERSIGHT_DISCLOSURE_CARVEOUT_CLAIMED` when `human_oversight` is `edited` or `directed` and `disclosure.required` is `false` | | Provide detail URLs | Link to full reports via `detail_url` for audit trails | ## Related * [AI provenance and disclosure](/dist/docs/3.1.20/creative/provenance) -- The provenance schema reference, digital source type enum, and inheritance model * [Creative Governance](/dist/docs/3.1.20/governance/creative/index) -- Feature-based creative evaluation via `get_creative_features` * [`get_creative_features`](/dist/docs/3.1.20/governance/creative/get_creative_features) -- Task reference for creative feature evaluation * [Content Standards](/dist/docs/3.1.20/governance/content-standards/index) -- Privacy-preserving brand suitability for publisher content * [`calibrate_content`](/dist/docs/3.1.20/governance/content-standards/tasks/calibrate_content) -- Calibration task for content standards alignment * [`validate_content_delivery`](/dist/docs/3.1.20/governance/content-standards/tasks/validate_content_delivery) -- Post-delivery content validation # Embedded human judgment Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/embedded-human-judgment Embedded Human Judgment (EHJ) is a set of principles and an oversight framework for keeping humans accountable when AI agents allocate capital, shape information environments, and execute advertising decisions at scale. *Principles for agentic advertising accountability — followed by the EHJ oversight framework.* ## The five principles ### 1. Humans remain the locus of judgment and accountability AI systems can analyze, predict, and execute. But responsibility cannot be delegated to software. Any system that allocates capital, shapes information environments, or affects public trust must retain human-owned judgment. Humans define intent, acceptable risk, and reasonable trade-offs — even when execution is automated. Accountability must remain legible at every stage of automation. Oversight must operate under uncertainty. Human judgment defines what is reasonable, not what is perfect. ### 2. Automated decisioning without abdication As we embrace autonomous advertising agents, we need to scale execution without diluting accountability. Automation should: * Scale execution * Increase precision in allocation decisions * Navigate complex systems to identify optimal execution paths * Reduce manual operational friction But automation must not remove authorship and responsibility for value judgments. Humans remain accountable for decisions that define risk, intent, and societal impact. Advanced automation is acceptable only when accountability remains intact. ### 3. Optimization is not intelligence Not all decisions can be reduced to metrics. Certain classes of decisions must remain human-owned by design because they involve: * Values * Strategy * Legitimacy * Trust These judgments exist precisely because optimization cannot resolve them. System design must recognize how and when a decision exceeds mere optimization and requires human judgment. ### 4. Oversight must be architectural, not procedural Human oversight must be embedded in system design. This requires: * Explicit decision boundaries * Escalation triggers * Auditability * Explainability * Identifiable human owners Systems must be built so that control cannot silently migrate away from humans over time. ### 5. Efficiency does not override legitimacy Speed, scale, and optimization cannot justify: * Loss of accountability * Erosion of judgment * Opaque decision chains The goal is to ensure they remain governable to avoid loss of legitimacy over time. *** ## Humans are the locus of judgment and accountability AI agents exist to support, inform, and execute decisions, but they do not replace human ownership where risk tolerance, intent, or values judgments are at stake. Embedded Human Judgment (EHJ) ensures that certain decisions remain human-owned by design, even as agents automate analysis, optimization, and execution at scale. This is not an after-the-fact review process. It is about structurally designing accountability into the system. ### EHJ in the AdCP architecture EHJ operates at the protocol layer, not inside any individual agent and not at the execution layer. The protocol defines decision boundaries: which decisions require human judgment, when escalation is triggered, and what must be logged and explainable. Agents implement their own internal logic and operate autonomously within those boundaries. Execution happens continuously and at speed within the structure the protocol defines. ### What EHJ is not EHJ is not: * A temporary safety phase while AI "matures" * A UI approval system bolted on afterward * A mandate for humans to control execution * An attempt to eliminate agent autonomy EHJ is a permanent design constraint for accountable systems. ## Why embedded human judgment matters Agentic systems will make mistakes. The question is not *if*, but *when* — and how costly. Key assumptions: * Agents can be technically correct but strategically wrong * Training data never covers all edge cases * Novel situations require judgment, not optimization * A single bad decision can outweigh years of efficiency gains EHJ exists to ensure that accountability attaches to intent and risk tolerance, not to the illusion of perfect outcomes. ## Foundational principles ### Human judgment without human bottlenecks The goal is not maximum human involvement, but human ownership where it structurally matters. | Dimension | How EHJ handles it | | ------------------ | ---------------------------------------------------------------- | | **Autonomy** | Agents handle the majority of routine decisions | | **Accountability** | Humans retain authority over brand, budget, legality, and ethics | | **Efficiency** | Oversight does not recreate approval hell | | **Transparency** | Every decision is auditable and explainable | ### Human roles in the system "Human" refers to accountable roles, not individuals: * **Advertiser and publisher decision owners** — brand, budget, ethics. "Brand" refers to both buyers and sellers of media. * **Agency decision owners** — strategy, planning, execution * **Platform owners** — compliance, infrastructure * **Legal and regulatory authorities** Some decisions are human-owned permanently, by definition — not because AI is weak, but because accountability must remain human. ## Domains of human-owned judgment EHJ defines decision domains where human ownership is required, even if agents provide analysis and recommendations: * Budget and capital allocation * Distribution and monetization partners * Brand suitability and context * Creative and messaging * Targeting and audience strategy * Pacing and performance monitoring ### Budget and capital allocation **Principle.** Budget deployment beyond defined bounds is a human decision. Agents may: * Forecast outcomes * Optimize pacing * Propose reallocations Humans must decide when: * Spend exceeds absolute or relative thresholds * Cumulative spend accelerates unexpectedly * Pacing materially diverges from intent Accountability attaches to risk tolerance and intent, not perfect pacing. ### Distribution and monetization partners **Principle.** New relationships imply new risk. Trusted execution with streamlined oversight is allowed for established, vetted partners. Human approval is required for: * First-time publishers or platforms * New contracts or personal data-sharing agreements * Quality or fraud concerns * Cross-border activations of personal data ### Brand suitability and context **Principle.** Acceptable context and risk tolerance are human-defined. Humans define: * What is unacceptable * What requires review * What level of uncertainty is tolerable Agents classify and score risk probabilistically based on human-defined judgments. Decisions are tiered: * **Hard blocks** — always rejected * **Probabilistic review** — mandatory human decision * **Pre-campaign and post-placement audit** — logged and reviewable Escalation occurs whenever a reasonable human would want to decide. ### Creative and messaging **Principle.** Messaging intent and claims remain human-owned. Trusted execution with streamlined oversight is allowed for: * Variations within approved templates * Localization using approved guidelines * DCO within guardrails Human validation is required for: * New core messaging * Claims with legal or reputational risk * Creative tied to current events * Assets that feel "technically on-brand but wrong" EHJ acknowledges that creative outcomes are probabilistic and context-dependent. ### Targeting and audience strategy **Principle.** Targeting intent and acceptable risk are human-defined. Agents may optimize within approved strategies. Human review is required for: * New data sources * Sensitive or regulated attributes * Material shifts in targeting intent * Potentially discriminatory strategies In these cases, compliance, ethics, and jurisdictional risk override pure performance optimization. ### Pacing and performance monitoring **Principle.** Significant deviations from expectations require explicit judgment. Agents must alert, escalate, and proportionally throttle activities when: * Performance collapses beyond thresholds * Fraud signals (IVT, click-fraud, publisher fraud) exceed tolerance * Budget exhaustion is imminent * Cross-platform metric discrepancies surpass thresholds Humans decide whether to continue, modify, or terminate. For termination, it would be advisable to include a description as to why. Not every anomaly is failure — but major deviations from intent must remain human-governed. ## Governance architecture EHJ operates through a layered governance model that allows policy composition across organizations, brand portfolios, and campaigns. ### Governance layers **Protocol layer.** Defines universal standards applied across the ecosystem: escalation requirements, confidence scoring rules, regulatory policy registry, minimum audit and logging standards. These rules apply to all participating agents. The registry is maintained as a shared ecosystem resource — organizations reference standardized policies by ID rather than maintaining independent compliance definitions. **Corporate governance layer.** Large organizations may define corporate-level policies that apply across a brand portfolio: regulatory compliance requirements, global brand safety standards, prohibited targeting categories, data protection policies. Corporate policies act as baseline constraints for all brands within the organization. **Brand governance layer.** Individual brands may define additional policies reflecting brand identity, positioning, and risk tolerance. A luxury brand may impose stricter placement rules; a mass-market brand may allow broader contextual environments; product categories may impose additional compliance constraints. Brand policies inherit corporate standards but may introduce stricter constraints or specialized rules. **Campaign governance layer.** Campaign-level configuration provides temporary execution parameters: budget thresholds, pacing constraints, creative eligibility rules, audience definitions. Campaign rules operate within the boundaries established by corporate and brand governance. Execution may be delegated to authorized agents operating within these constraints. ### Policy composition Governance rules are applied hierarchically: ``` Corporate Governance ↓ Brand Governance ↓ Campaign Configuration ``` Each layer may add restrictions but cannot override higher-level governance constraints. If a lower governance layer attempts to relax or override a constraint defined by a higher layer, the governance agent treats the higher-level constraint as authoritative, rejects the conflicting rule, and records the conflict in the audit log. This structure allows organizations with large brand portfolios to operate multiple governance profiles simultaneously while maintaining consistent regulatory and ethical standards. ### Accountability across layers Accountability remains explicit at each layer: * Protocol designers define system safeguards * Corporate owners define enterprise risk tolerance * Brand teams define positioning constraints * Campaign operators manage execution All decisions remain traceable through the audit framework. ### Delegated execution and authorized operators Brands may delegate campaign execution authority to external agencies or authorized agent operators. Delegation does not transfer governance authority. Delegated and authorized operators may rely on stricter policies than what brands have delegated. Authorized agents operate within the governance constraints defined by the corporate and brand policy layers. The brand remains the accountable entity for campaign intent and policy configuration, while the delegated operator executes decisions within those defined boundaries. ## Data protection and regulatory compliance Data protection and regulatory compliance are treated as governance constraints within the protocol, not as external policy considerations. Agents must validate decisions against the policy registry during governance evaluation before execution occurs. ### Regulatory policy registry The protocol maintains a policy registry containing machine-readable references to regulatory frameworks and jurisdiction-specific rules, including but not limited to: * GDPR * COPPA * CCPA / CPRA * LGPD * APAC jurisdictional frameworks Each policy entry specifies: * Applicable jurisdiction * Relevant data classifications * Sensitive data definitions * Enforcement requirements The policy registry may also list contracts created by trade bodies or collective-bargaining groups to communicate among participants. Agents and platforms must reference the policy registry during decision validation. ### Personal and non-personal data Data protection regulations apply when personal data is processed. In the EEA, the ePrivacy Directive applies to device access and storage, but the AdCP protocol is communication between software systems — whether agent-to-agent (via A2A) or client-to-server tool calls (via MCP) — not consumer devices. Within AdCP workflows: * Planning and negotiation layers typically exchange non-personal contextual information and campaign parameters. * Real-time execution layers may involve device-level signals that can qualify as personal data depending on jurisdiction and recipient capability. The protocol must specify whether a recipient agent is reasonably capable of re-identifying an individual or household using the exchanged data. If re-identification is reasonably possible, the data must be treated as personal data and processed according to the applicable regulatory framework. ### Sensitive data classification Sensitive information refers to categories of data that may expose individuals to discrimination or material harm. Because definitions vary by jurisdiction, the protocol must reference jurisdiction-specific definitions from the policy registry. Agents must classify whether a decision involves sensitive information based on: * The data attributes used * The intended delivery geography * The applicable regulatory framework If sensitive data is involved, stricter governance rules apply. Consumer protection laws apply when sensitive information is being handled. Different jurisdictions define specific categories of sensitive information differently, but one commonality is when the information has historically been used to illegally discriminate or cause material harm to individuals. Most online advertising does not involve sensitive information, but it is important for actors to classify when data exchanged does qualify as sensitive. The protocol must specify whether the information used by a recipient agent will or will not involve sensitive information. The geography associated with the intended content delivery should govern which region-specific definition of sensitive information applies. For example, if the intended delivery is within the European Economic Area, GDPR's definition should apply. ### Jurisdictional compliance validation Before execution, agents must validate decisions using the protocol's governance validation process (for example, [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance)). Validation includes: * Applicable jurisdiction based on delivery geography * Applicable regulatory policies from the policy registry * Classification of the data used in the decision * Determination of whether sensitive data rules apply If a decision violates applicable regulatory policies, the system must: * Escalate for human review * Restrict execution * Or block the decision entirely, depending on risk tier ### Intent and exposure AdCP records the intent of decision-makers as part of the protocol. This allows systems to distinguish between: * **Intentional targeting** * **Incidental exposure** For example, a campaign intended for adults may still appear in environments accessible to minors. Because the targeting intent is recorded, compliance evaluation can distinguish between intentional violations and unintended exposure. This design aligns accountability with reasonable intent rather than perfect outcomes. ## Governance and decision framework ### Decision types All agent decisions must be classifiable: | Type | Description | | ------------------------------------ | ---------------------------------------------------- | | **AI-owned, deterministic** | Rule-based, predictable outcomes | | **AI-led, human-bounded** | Probabilistic optimization with thresholds | | **Human-owned, strategic** | Trade-offs, intent, ethics, and values | | **Human-owned by necessity (novel)** | Unknown situations agents cannot confidently resolve | The decision type determines whether and how escalation occurs. ### Confidence and escalation Every agent recommendation must include: * A confidence score * An explanation of uncertainty * A defined escalation rule Confidence scores must reflect the agent's assessment of how reliably the recommendation aligns with the defined campaign intent and expected outcomes. This assessment should consider factors such as data completeness, model certainty, similarity to historical decisions, and variance in predicted outcomes. Confidence scores should be accompanied by a brief explanation of uncertainty, including factors such as: * Limited or incomplete data * Conflicting signals * Novel or out-of-distribution scenarios * Unusually high variance in predicted results Escalation decisions should follow a risk-aware framework. Agents must evaluate recommendations based on both: * **Decision confidence** — how certain the agent is * **Decision risk** — the potential impact if the decision is incorrect Risk may include financial exposure, brand safety implications, regulatory sensitivity, scale of audience reach, or deviation from defined campaign intent. Human decision owners define acceptable risk levels and associated confidence thresholds. When confidence is insufficient for the level of risk involved, agents must escalate to human oversight rather than execute autonomously. Escalation triggers may include: * Confidence below defined thresholds for the risk level * Material deviation from defined campaign intent * Changes in data quality or signal reliability * Inability to provide a clear explanation of the recommendation Thresholds may be based on: * Metric-driven limits (for example, financial spend or exposure) * Execution deviation from intent (for example, geographic targeting or audience constraints) When escalation occurs, the agent must present: * The recommended action * The confidence score * The explanation of uncertainty * The specific rule that triggered escalation This ensures that human oversight focuses on decisions where uncertainty or potential impact exceeds predefined governance boundaries, rather than routine execution. ### Escalation mechanics EHJ defines how human judgment is invoked: | Mode | Behavior | | ---------------- | -------------------------------------- | | **Synchronous** | Block until human decides | | **Asynchronous** | Proceed conservatively, allow override | | **Audit-only** | Act, log, review later | ### Timeout and fallback handling Timeouts follow a risk-tiered approach: * **Low-risk decisions** — execution may proceed within predefined guardrails * **Medium-risk decisions** — agents apply conservative defaults or limited execution while notifying human owners * **High-risk decisions** — agents escalate for human review or temporarily restrict execution until guidance is received This approach ensures that operational continuity is maintained where risk is limited, while decisions with greater potential impact receive appropriate human oversight. In cases of uncertainty, systems prioritize governable outcomes over maximum speed, recognizing that occasional opportunity cost is an acceptable trade-off for maintaining accountability. ## Protocol and runtime distinctions AdCP separates two operational layers: the **protocol layer**, where governance and decision constraints are defined, and the **runtime layer**, where real-time execution occurs. ### Protocol layer The protocol layer defines the structure and governance of decision-making. It includes: * JSON schemas and task definitions * Governance rules and escalation policies * [`brand.json`](/dist/docs/3.1.20/brand-protocol/brand-json) and [`adagents.json`](/dist/docs/3.1.20/governance/property/adagents) declarations * Confidence scoring standards * Policy registry and regulatory constraints At this layer, planning and negotiation agents define campaign goals, constraints, and acceptable risk boundaries. These parameters are authored and maintained by human operators but exchanged between agents to establish a machine-readable contract. This layer determines what decisions are permitted and when human judgment must be invoked. ### Runtime layer The runtime layer executes decisions in real time, including: * Bid evaluation * Creative rendering * Audience activation * Pacing and budget allocation Real-time agents operate within the boundaries defined by the protocol layer. Human operators define governance constraints in advance and intervene only through configured escalation checkpoints. In short: * The **protocol layer** governs the rules of decision-making. * The **runtime layer** executes those decisions at speed. ## Audit, transparency, and learning Governable automation requires that all significant decisions remain observable, explainable, and reconstructable. ### Audit trail Every high-impact decision must generate an auditable record including: * Decision inputs * Confidence score * Agent reasoning * Human interventions * Execution outcome Organizations retain their own logs to satisfy internal governance and regulatory compliance requirements. ### Explainability Decisions must be explainable at multiple levels depending on the audience: | Audience | Detail level | | ------------------------------------------ | ----------------- | | **Approvers and oversight** | Summary level | | **System operators and campaign managers** | Operational level | | **Auditors and compliance reviewers** | Technical level | Decision intent is captured by design within the protocol for each message and targeting instruction. ### Log attributes | Dimension | Attribute | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **When** | Timestamp (millisecond precision) | | **Which** | Decision ID (unique, traceable across systems) | | **Who** | Agent ID (which agent made the decision); human ID (who reviewed, if applicable); advertiser responsible for the message; actor responsible for payment; actor owed payment for the decision; publisher responsible for delivery (for final steps in the supply chain) | | **What** | Input (full context), decision type and classification | | **How well** | Observed execution result | Consistent definitions of actors are described in the following protocols: * **Advertiser responsible for the message** — declared in [`brand.json`](/dist/docs/3.1.20/brand-protocol/brand-json), including the brand's `keller_type` (`master`, `sub_brand`, `endorsed`, or `independent`) and its `parent_brand` where applicable. * **Actor responsible for payment** — declared in [`brand.json`](/dist/docs/3.1.20/brand-protocol/brand-json) (the brand itself or its operator). * **Actor owed payment for the decision** — declared in [`adagents.json`](/dist/docs/3.1.20/governance/property/adagents), via `seller_id` and the authorized `property_id`(s). * **Publisher responsible for delivery** — the property associated with the final impression, identified by `property_id` in `adagents.json`. ## How this maps to AdCP today The framework above is implementation-agnostic. For readers landing here to implement against AdCP, the principles currently surface through these protocol mechanisms: | Framework concept | AdCP mechanism | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Humans define boundaries (budget, review) | [`sync_plans`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans) — `budget.reallocation_threshold`, `plan.human_review_required` | | Governance invocation on every spend-commit | [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) — called by orchestrator (intent check) and seller (execution check) | | Three-party separation of duties | [Safety model](/dist/docs/3.1.20/governance/campaign/safety-model) — orchestrator, governance agent, seller | | Escalation to human via async task | `check_governance` returns async, resolves `approved` or `denied` once the human acts | | Audit trail and explainability | [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs) | | Regulatory policy registry | [Policy Registry](/dist/docs/3.1.20/governance/policy-registry) | ## Policy Registry The [Policy Registry](/dist/docs/3.1.20/governance/policy-registry) is a community-maintained library of standardized, machine-readable advertising policies — regulations like COPPA, GDPR, and UK HFSS, as well as industry standards. It gives governance agents a shared vocabulary to reference by policy ID, rather than each agent defining the same rules independently. The registry page covers how policies are structured, the difference between hard regulations (must) and best-practice standards (should), how governance agents resolve and apply them at runtime, and how to contribute new policies. See EHJ principles in action across a complete campaign scenario Shared library of machine-readable regulations and industry standards # Governance protocol Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/overview AdCP governance ensures human oversight of autonomous AI advertising through three-party validation, budget controls, and brand safety enforcement. A robot arm reaches for a glowing red BUY button marked $50,000 — no human is present, warning lights blink in the dim room, and unchecked documents pile up nearby An AI agent is about to spend \$50,000 on advertising. No human reviewed the plan. No system checked the budget. No policy filtered the inventory. The agent has credentials, a brief, and a BUY button. Jordan is a campaign operations manager at Pinnacle Agency. This is her nightmare. Not because the technology failed — because nobody was accountable. Responsibility cannot be delegated to software. She doesn't want to slow agents down — they're faster and more thorough than her team at managing cross-platform campaigns. But she needs to know that when an agent buys media for Acme Outdoor, it stays within budget, runs on approved publishers, and meets Canadian privacy rules. She needs to know that if something exceeds authority, a human gets asked — not after the fact, but before the money moves. AdCP's governance system is built on a principle: [human judgment must be embedded in system design](/dist/docs/3.1.20/governance/embedded-human-judgment), not bolted on afterward. Oversight is architectural — the system cannot operate without it. This walkthrough follows Jordan as she sets up governance for Sam's \$50K campaign and watches it work. **Regulated verticals.** Campaigns for credit, insurance, employment, or housing fall under GDPR Article 22 and EU AI Act Annex III — automated decisions affecting data subjects require human oversight. See [Annex III & Art 22 obligations](/dist/docs/3.1.20/governance/annex-iii-obligations) for what AdCP provides and what the deployer is responsible for. The AdCP policy registry ships seeded with regulatory policies across US (FHA, ECOA, EEOC, COPPA, FDA, FTC, TTB), EU (DSA, prescription DTC), and platform-specific categories — alongside GDPR Article 9 restricted-attribute definitions that governance agents evaluate programmatically. The seeded registry is a starting point, not an exhaustive list; deployers extend it for their own jurisdictions and brand rules. ## The three-party model A triangle diagram with humans at the top setting policies, and three agents below — orchestrator proposes, governance validates, seller fulfills — connected by lines showing separation of duties AdCP governance works because humans define the boundaries and no single party controls the full workflow: | Party | Role | Cannot do | | -------------------- | ------------------------------------------------ | ---------------------------------------------------- | | **Orchestrator** | Proposes campaign plans, executes buys | Set its own spending limits or approve its own plans | | **Governance agent** | Validates plans against policies, tracks budgets | Execute buys or modify campaigns | | **Seller** | Fulfills media buys, reports delivery | Override governance decisions or modify budgets | The agent that spends money isn't the agent that sets the rules. This isn't three agents checking each other — it's a structure designed so that human-defined policies govern every transaction, and no agent can act outside the authority a human granted it. ## Step 0: Sync governance agents Before registering the plan, the buyer syncs governance agents with the seller via [`sync_governance`](/dist/docs/3.1.20/accounts/tasks/sync_governance). This gives the seller the endpoints and credentials needed to call `check_governance` independently when processing media buys. ## Step 1: Register the plan A buying robot creates a glowing campaign plan document that floats across to a governance robot sitting behind a security desk with a shield emblem — the governance robot examines it carefully Before Sam's orchestrator executes any buy, Jordan's governance setup requires it to register the campaign plan: ```javascript theme={null} const plan = await governance.syncPlans({ plans: [{ plan_id: "acme-q2-trail-pro", brand: { domain: "acmeoutdoor.com" }, objectives: "Q2 Trail Pro 3000 launch across sports and outdoor lifestyle publishers", budget: { total: 50000, currency: "USD", reallocation_threshold: 5000 }, flight: { start: "2026-04-01T00:00:00Z", end: "2026-06-30T23:59:59Z" }, countries: ["US", "CA"] }] }); ``` The governance agent now knows about this plan. It resolves applicable policies — brand safety rules, budget limits, regulatory requirements for US and Canada, and Acme Outdoor's brand-specific restrictions from `brand.json`. No money has moved. The plan is registered, not executed. Notice `reallocation_threshold: 5000` — Jordan chose this setting. It means the orchestrator can reallocate budget up to \$5,000 on its own, but any larger move requires human approval. This boundary is a human decision, not a technical default. The agent cannot change it. The governance agent pulls policies from multiple sources: * **Budget limits**: Reallocation threshold caps how much budget the agent may move without human approval * **Brand safety**: Acme Outdoor's `brand.json` specifies approved and excluded publisher categories * **Regulatory**: US and CA jurisdictions trigger COPPA, PIPEDA, and state privacy rules * **Industry**: AgenticAdvertising.org's policy registry provides standardized regulations Jordan configured these policies once. They apply automatically to every campaign for this brand. ## Step 2: Check before spending The governance robot reviews three inspection panels side by side — budget (showing a bar chart nearly at limit), brand safety (green checkmark), and compliance (green checkmark) — the budget panel glows amber as a warning When the orchestrator is ready to buy, it calls `check_governance` before executing: ```javascript theme={null} const check = await governance.checkGovernance({ plan_id: "acme-q2-trail-pro", caller: "https://orchestrator.pinnacle-agency.example", tool: "create_media_buy", payload: { seller: "https://streamhaus.example", amount: 25000, currency: "USD" } }); ``` The governance agent evaluates the proposed action against every applicable policy: | Check | Status | Detail | | ----------------------------- | ----------- | -------------------------------------------- | | Budget within plan limit | Passed | \$25K of \$50K available | | Budget within agent authority | **Warning** | Agent authorized up to \$20K per transaction | | Brand safety | Passed | StreamHaus on approved list | | Regulatory compliance | Passed | Targeting meets US/CA requirements | | Creative provenance | Passed | All creatives carry required metadata | The response isn't pass/fail — it returns structured findings with severity levels (`must`, `should`, `may`) and confidence scores. The orchestrator knows exactly what passed, what failed, and why. ## Step 3: Escalation The governance robot raises an amber flag and routes the campaign plan along a glowing path to a human reviewer — a woman with dark hair sitting at a glass desk, who examines the flagged document thoughtfully The \$25,000 transaction exceeds the agent's \$20,000 authority limit. The governance agent flags it with `must` severity — the orchestrator cannot proceed without resolution. This is not a failure — it is the system working as designed. The agent doesn't need to remember to check; the architecture requires it. Oversight is structural, not procedural. Two options: 1. **Reduce the transaction** to \$20,000 or less 2. **Wait for human approval** — the governance agent handles this internally The governance agent determines that human review is needed. It holds the `check_governance` request async — the orchestrator sees standard async task status (`submitted`, `working`) while Jordan receives the flagged plan with full context: what the agent wants to buy, why it was flagged, and which policy triggered it. The `check_governance` task goes async. The orchestrator polls or receives a webhook when it resolves. Internally, the governance agent routes to Jordan for approval. Once she acts, the task completes with `approved` or `denied`. ```json theme={null} { "check_id": "chk-q2-ctv-001", "verdict": "denied", "plan_id": "acme-q2-trail-pro", "explanation": "Budget authority exceeded. Transaction amount $25,000 exceeds agent authority limit of $20,000.", "findings": [ { "category_id": "budget", "policy_id": "budget-authority-limit", "severity": "must", "explanation": "Transaction amount $25,000 exceeds agent authority limit of $20,000.", "confidence": 1.0 } ] } ``` If Jordan approves (potentially with conditions), the governance agent returns `approved` instead. ## Step 4: Human approval Jordan stamps the campaign plan with a green approval seal and attaches a yellow condition tag reading 'weekly reporting required' — the approved plan flows back through the governance robot to an open ledger Jordan reviews the plan and approves — with a condition: the agent must report delivery weekly instead of at flight end. She isn't rubber-stamping. She reviewed the context, assessed the risk, and exercised judgment by adding a constraint the agent didn't request. This is the human remaining the locus of accountability — the agent proposed, the human decided. This approval is recorded in the governance system. The governance agent updates the plan's delegation — the orchestrator now has temporary authority for this specific transaction, with the added reporting constraint. The governance agent records who approved, when, and under what conditions. ## Step 5: Campaign runs under watch A cityscape with ads running on billboards, phone screens, and TV displays — above it all, the governance robot monitors from a watchtower, scanning the scene with a teal beam as metrics stream upward The campaign is live. Governance doesn't stop at purchase — it monitors delivery against the approved plan: * **Budget tracking**: As `report_plan_outcome` data flows in, the governance agent tracks actual spend against committed budget * **Drift detection**: If delivery diverges from the plan — wrong publisher, unexpected creative, budget overrun — governance flags it * **Policy updates**: If a new regulation takes effect mid-flight, governance applies it to active plans. Content-standards versions follow a separate rule — pinned-at-buy by default, with a per-policy `evaluation_mode: continuous` opt-in for regulatory policies. See [content standards versioning](/dist/docs/3.1.20/governance/content-standards/index#versioning-and-mid-flight-amendments). First, the orchestrator reports that the seller accepted the purchase and committed budget: ```javascript theme={null} await governance.reportPlanOutcome({ plan_id: "acme-q2-trail-pro", governance_context: check.governance_context, check_id: "chk-q2-ctv-001", outcome: "completed", seller_response: { media_buy_id: "mb-streamhaus-001", committed_budget: 25000 } }); ``` Then, as delivery data comes in, the orchestrator reports delivery outcomes so governance can track actual spend against the committed budget: ```javascript theme={null} await governance.reportPlanOutcome({ plan_id: "acme-q2-trail-pro", governance_context: check.governance_context, outcome: "delivery", delivery: { media_buy_id: "mb-streamhaus-001", reporting_period: { start: "2026-04-01T00:00:00Z", end: "2026-06-30T23:59:59Z" }, impressions: 887000, spend: 24850 } }); ``` The \$25,000 buy committed budget, and actual delivery came in at \$24,850. The governance agent updates the ledger — \$25,150 remains of the \$50K plan budget for the next buy. ## Step 6: The audit trail A horizontal timeline ribbon showing decision nodes — plan created, check flagged, human reviewed, approved, launched, delivered — with the governance robot presenting a detailed log book to a team of three humans Six months later, Acme Outdoor's procurement team asks: "Who approved that \$25,000 CTV buy?" Jordan pulls the complete decision history: ```javascript theme={null} const audit = await governance.getPlanAuditLogs({ plan_ids: ["acme-q2-trail-pro"], include_entries: true }); ``` Every event, in sequence: 1. **Plan registered** — orchestrator synced plan with \$50K budget 2. **Governance check** — \$25K buy flagged for exceeding agent authority 3. **Escalation** — Jordan reviewed, approved with weekly reporting condition 4. **Buy executed** — StreamHaus media buy created 5. **Delivery reported** — \$24,850 actual spend, 887K impressions 6. **Budget updated** — \$25,150 remaining Every decision, every approval, every outcome — structured, timestamped, attributable. Accountability requires legibility. This isn't a log file buried in a server — it's a first-class audit record designed to answer the question "who decided this and why?" at any point in the future. ## Crawl, walk, run Jordan didn't start with full enforcement. She configured the governance agent to start in audit mode — it evaluated every check fully but always returned `approved`, attaching findings for her to review. After two weeks she reviewed the logs, tuned policies to reduce false positives, and moved to advisory. In advisory mode, the governance agent returned real `denied` statuses but Jordan's team treated them as non-blocking. When she trusted the system, she switched to enforce. The callers (orchestrator, sellers) never changed their code. They always acted on the status they received. The mode was entirely the governance agent's internal configuration. Three-step diagram showing proposed (dotted outline, hypothetical check), execute (solid tag, budget reserved), and committed (ledger records actual spend) Budget tracking has three phases: 1. **Proposed**: `check_governance` evaluates whether the amount fits within the plan. No money reserved — this is a hypothetical check. 2. **Execute**: The seller runs the campaign. The governance agent tracks the authorized amount as reserved, but actual spend may differ. 3. **Committed**: `report_plan_outcome` records the actual amount. The governance agent updates the ledger with real numbers. A \$25,000 buy might deliver \$24,850. Governance tracks the difference and frees the remaining \$150. **Embedded human judgment** Every step in this walkthrough reflects a principle from the [Embedded Human Judgment manifesto](/dist/docs/3.1.20/governance/embedded-human-judgment) — the framework that ensures humans remain accountable when AI agents operate autonomously. [Read the five principles →](/dist/docs/3.1.20/governance/embedded-human-judgment) ## Protocol domains The Governance Protocol covers six domains: Community-maintained library of standardized advertising regulations and industry standards, consumed by all governance domains. Control where ads can run with property lists, compliance filtering, and publisher authorization via adagents.json. Control what content ads run in with collection lists — program-level brand safety for shows, series, and podcasts across platforms. Privacy-preserving brand suitability through calibration-based content evaluation and validation. Security scanning, creative quality, and content categorization through specialist agents via get\_creative\_features. Automated validation of buy-side transactions against authorized plans, budgets, and brand compliance configuration. ### Sponsored Intelligence (Planned) Full protocol-level governance integration for [Sponsored Intelligence](/dist/docs/3.1.20/sponsored-intelligence/overview) is under development. When available, SI platforms will support: 1. **Campaign registration** via `sync_plans` — register SI campaigns with governance agents 2. **Session-lifecycle governance** via `check_governance` — validate actions during SI sessions 3. **Content standards for AI-generated content** — apply brand suitability to LLM-generated sponsored responses 4. **Property governance for AI assistant placements** — validate that AI platforms are authorized delivery surfaces Today, SI platforms enforce governance at the application layer using [content standards](/dist/docs/3.1.20/governance/content-standards/index) and [brand identity](/dist/docs/3.1.20/brand-protocol/brand-json). The informal governance references in SI documentation reflect this application-layer integration, not protocol-level governance tasks. ## Go deeper * **Safety model**: [Three-party trust in depth](/dist/docs/3.1.20/governance/campaign/safety-model) — separation of duties, delegation, and escalation patterns * **Campaign specification**: [Full data model](/dist/docs/3.1.20/governance/campaign/specification) — plans, checks, outcomes, and policy resolution * **Content standards**: [Brand suitability](/dist/docs/3.1.20/governance/content-standards/index) — privacy-preserving calibration for content evaluation * **Property governance**: [Where ads can run](/dist/docs/3.1.20/governance/property/index) — property lists, adagents.json, and publisher authorization * **Collection governance**: [What content ads run in](/dist/docs/3.1.20/governance/collection/index) — collection lists for program-level brand safety across platforms * **Policy registry**: [Community policies](/dist/docs/3.1.20/governance/policy-registry) — standardized regulations and brand safety policies * **Get certified**: [Specialist governance modules](/dist/docs/3.1.20/learning/specialist/governance) teach the full governance system through interactive scenarios * **RFC process**: [How to propose changes](/dist/docs/3.1.20/governance/rfc-process) — lifecycle, proposal template, and decision-record format for protocol contributions # Policy Attribution Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/policy-attribution How producers tag mechanism-level filters and measurements with the authorizing policy, so audit trails and governance findings can trace back to why a specific threshold or evaluation exists. When a buyer caps audience children-composition at 15%, the threshold itself doesn't explain why. The number is mechanical; the *reason* (UK HFSS) lives elsewhere. Without attribution, an auditor reading a property list six months later can't answer "why does this list exclude high-children-composition properties?" without human interpretation. Policy attribution closes that gap. Producers tag mechanism-level filters and measurements with `policy_id` to record the authorizing policy. Governance findings echo the same `policy_id` when emitting denials, so the trace runs end-to-end. ## Three surfaces, one pattern | Surface | Producer | Use | | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------- | | [`core/feature-requirement.json`](https://adcontextprotocol.org/schemas/3.1.20/core/feature-requirement.json) | Buyer (or buyer's compliance tooling) | Tag a buyer-authored threshold predicate with the policy that authorized it | | [`creative/creative-feature-result.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/creative-feature-result.json) | Creative agent / seller | Tag a measurement record with the policy that motivated the evaluation | | [`property/validation-result.json`](https://adcontextprotocol.org/schemas/3.1.20/property/validation-result.json) `features[].policy_id` | Property-list agent | Tag a per-feature validation result with the policy that triggered the check | All three fields are optional. The first two were reserved in 3.0 GA; populating them in 3.1 is non-breaking for strict 3.0 validators. The third has been present on validation-result since 3.0. ## When to populate `policy_id` Populate `policy_id` when **the filter or measurement exists because of a specific authorizing policy** — and the producer chose the specific mechanism as their encoding of that policy. Do **not** populate `policy_id` when the policy merely applies in general. Plan-level policy applicability is declared on the plan itself via `policy_ids[]` (sent to the buyer's governance agent through `sync_plans`). The filter-level field is for mechanism authorship, not blanket applicability. ### Plan-level vs filter-level These are different jobs: | Buyer says | Where it lives | Who picks the mechanism | | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | | "Apply COPPA — governance figures out who qualifies" | `plan.policy_ids: ["us_coppa"]` | Buyer's governance agent | | "Compliance with COPPA means: require properties to attest to it" | `feature_requirements: [{feature_id: "registry:us_coppa", value: true}]` | Buyer, delegating measurement to the seller's `registry:` feature | | "I'm encoding HFSS as ≤15% children composition" | `feature_requirements: [{feature_id: "audience_children_composition", max_value: 15, policy_id: "uk_hfss"}]` | Buyer, picking the threshold themselves | Only the third row carries filter-level `policy_id`. The first two rows already capture intent at higher abstraction levels — the registry feature ID is itself the policy reference in the second case. ## Round-trip via governance findings The attribution loop runs filter → governance agent → finding → audit. The agent acting on the buyer's plan calls `check_governance`; depending on `phase`, this is either an **intent check** (orchestrator-side, before commitment) or an **execution check** (seller-side, before binding planned delivery). Both paths produce findings with the same shape. ``` Buyer's property list (stored at the property-list agent) feature_requirements: - feature_id: audience_children_composition max_value: 15 policy_id: uk_hfss ← authored here │ │ Acting agent resolves the property list, sees policy_id as pass-through metadata ▼ Planned action would violate the requirement │ │ check_governance(plan_id, payload | planned_delivery, governance_context) │ - orchestrator on intent check (tool + payload) │ - seller on execution check (planned_delivery) ▼ Buyer's Governance agent emits a finding: findings: - category_id: regulatory_compliance policy_id: uk_hfss ← echoed from the originating requirement severity: block explanation: "Planned targeting exceeds the 15% children-composition cap." │ ▼ Audit: "why was this denied?" → uk_hfss → grep buyer's filters → find the originating requirement. ``` The acting agent (orchestrator or seller, depending on phase) is a transit point — it does not produce findings. The buyer's governance agent is the producer, and it has direct access to the buyer's filters (same trust boundary), so it can populate `policy_id` on findings by reading the underlying requirement. ## Contract for producers **Buyer (or buyer's compliance tooling) authoring `feature-requirement`:** * SHOULD populate `policy_id` when the requirement encodes a specific policy threshold the buyer chose. * SHOULD NOT populate `policy_id` when the requirement is a general feature filter unrelated to any policy. * MUST reference a `policy_id` that resolves either in the policy registry or in the plan's `custom_policies[]`. **Creative agent or seller authoring `creative-feature-result`:** * SHOULD populate `policy_id` when the feature was measured for the purpose of a specific policy evaluation. * SHOULD NOT populate `policy_id` when the feature is a generic measurement (carbon score, brand consistency) unrelated to any policy. **Governance agent emitting findings:** * SHOULD echo `policy_id` on findings when the underlying violation traces to a filter or measurement carrying `policy_id`. * MUST NOT invent a `policy_id` that wasn't present on the originating filter — finding `policy_id` is for traceability, not for declaring new policy applicability (that belongs in `policies_evaluated[]`). ## Worked examples ### UK HFSS — buyer-encoded threshold The buyer's compliance team interprets UK HFSS as "audience must be less than 15% children." They encode that interpretation as a feature requirement: ```json theme={null} { "feature_requirements": [ { "feature_id": "audience_children_composition", "max_value": 15, "policy_id": "uk_hfss" } ] } ``` If a different team later asks "why 15? why not 20?", the policy\_id points at the registry entry for UK HFSS, where the rationale and exemplars live. ### COPPA — delegated to the seller's registry feature The buyer doesn't pick a threshold for COPPA — they delegate the evaluation entirely. The `registry:` prefix is a feature-naming convention (see [`property-feature-definition`](https://adcontextprotocol.org/schemas/3.1.20/property/property-feature-definition.json) and [Policy Registry](/dist/docs/3.1.20/governance/policy-registry#feature-prefix-convention)) where the feature ID `registry:` references a standardized policy directly: ```json theme={null} { "feature_requirements": [ { "feature_id": "registry:us_coppa", "value": true } ] } ``` The feature ID *is* the policy reference. Adding `policy_id: "us_coppa"` here would be redundant — and would imply the buyer authored the mechanism, when in fact they delegated it. ### Creative measurement — agent records the why A creative agent evaluates a creative for HFSS compliance and records: ```json theme={null} { "feature_id": "uk_hfss_compliance", "value": false, "policy_id": "uk_hfss", "methodology_version": "v2.1", "measured_at": "2026-05-17T14:00:00Z" } ``` The `policy_id` answers "why did this evaluation run?" Anyone reviewing the creative's measurement history can correlate this result with the originating policy. When this result fails the creative-level governance check, the governance agent's finding echoes the same `policy_id`: ```json theme={null} { "category_id": "regulatory_compliance", "policy_id": "uk_hfss", "severity": "block", "explanation": "Creative failed UK HFSS compliance evaluation." } ``` The buyer can correlate the finding back to the originating measurement by matching `policy_id` across both records. ## What attribution does not cover * **Top-down policy declaration from buyer to seller.** When the buyer wants the seller to apply specialized handling for a policy (HIPAA vendor, COPPA dataset) without the buyer encoding the mechanism, that's a separate surface tracked in [#4629](https://github.com/adcontextprotocol/adcp/issues/4629). * **Per-criterion attribution on audience selectors.** Audience exclusions in a plan should be derived from plan-level `policy_ids[]` by the buyer's governance agent — not hand-authored by buyers and tagged with policy authority. Audience-selector schemas do not carry `policy_id`. * **Per-criterion attribution on the targeting overlay.** Targeting fields (`geo_countries_exclude`, age restrictions, device platforms) use flat arrays without a per-entry shape; per-criterion attribution would require schema restructuring. Use plan-level declaration for these constraints. ## See also * [Policy Registry](/dist/docs/3.1.20/governance/policy-registry) — the shared library of `policy_id`s * [Policy Registry Sync](/dist/docs/3.1.20/governance/policy-registry-sync) — how plans reference policies via `policy_ids[]` and `custom_policies[]` * [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) — where findings are emitted with `policy_id` traceability # Policy Registry Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/policy-registry The AdCP Policy Registry is a shared library of machine-readable compliance policies that governance agents reference by ID during campaign validation. The Policy Registry is a community-maintained library of standardized, machine-readable advertising policies. It provides a shared vocabulary of regulations and industry standards that any governance domain can reference by ID. ## Quick start Fetch a policy by ID: ```bash theme={null} curl https://adcontextprotocol.org/api/policies/resolve?policy_id=us_coppa ``` List all regulation-category policies: ```bash theme={null} curl https://adcontextprotocol.org/api/policies/registry?category=regulation ``` Bulk-resolve policies for an LLM evaluation prompt: ```bash theme={null} curl -X POST https://adcontextprotocol.org/api/policies/resolve/bulk \ -H "Content-Type: application/json" \ -d '{"policy_ids": ["us_coppa", "eu_gdpr_advertising", "uk_hfss"]}' ``` Use the `policy` text and `exemplars` from the response in your governance agent's evaluation prompt. The exemplars calibrate the agent's interpretation of the policy -- include them as few-shot examples. ## Why a shared registry Advertising compliance involves the same regulations and standards across many campaigns, brands, and governance agents. Without a shared registry, every governance agent would independently define policies for COPPA, GDPR, HFSS, and other well-known regulations -- creating inconsistency and duplication. The registry solves this by providing: * **Standardized policy definitions** with structured metadata (jurisdiction, policy category, enforcement level) * **Natural language policy text** that governance agents (LLMs) use directly for evaluation * **Calibration exemplars** (pass/fail scenarios) that align agent behavior * **Version tracking** so brands can pin to specific policy versions ## Policy categories Policies fall into two categories based on the nature of the obligation: | Category | Enforcement | Description | | -------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Regulation** | `must` | Legal requirements with jurisdiction scope. Violations have legal consequences. Governance agents reject actions that violate these policies. | | **Standard** | `should` | Industry best practices, voluntary but recommended. Protects brand value and campaign effectiveness. Governance agents warn on violations but do not block. | Enforcement levels follow RFC 2119 keywords: * **`must`** -- Legal requirement. Governance agents reject violations. * **`should`** -- Best practice. Governance agents warn but do not block. * **`may`** -- Recommendation. Governance agents log for informational purposes only. ## How governance agents use policies Governance agents are LLMs that interpret natural language policy text -- the same pattern used by [Content Standards](/dist/docs/3.1.20/governance/content-standards/index). The registry's value is in structured metadata and calibration exemplars, not a custom rule language. 1. **Resolve applicable policies** from the brand's compliance configuration or buyer request 2. **Bulk-resolve from the registry** via `POST /api/policies/resolve/bulk` 3. **Filter by context** -- intersect policy jurisdictions/policy categories/channels with campaign parameters 4. **Include policy text + exemplars** in the evaluation prompt 5. **Apply enforcement level** -- `must` violations result in rejection, `should` violations result in warnings ## Policy structure Each policy in the registry follows the [policy-entry schema](https://adcontextprotocol.org/schemas/3.1.20/governance/policy-entry.json): ```json theme={null} { "policy_id": "uk_hfss", "version": "1.0.0", "name": "UK HFSS Advertising Restrictions", "description": "UK ban on paid online advertising of less healthy food and drink products.", "category": "regulation", "enforcement": "must", "jurisdictions": ["GB"], "policy_categories": ["health_wellness"], "governance_domains": ["campaign", "property", "content_standards"], "effective_date": "2025-10-01", "source_url": "https://www.legislation.gov.uk/ukpga/2022/17/contents", "source_name": "UK Parliament", "policy": "The UK Health and Social Care Act 2022 restricts paid online advertising of food and drink products classified as 'less healthy' under the Nutrient Profiling Model...", "exemplars": { "pass": [ { "scenario": "A breakfast cereal brand runs a display ad featuring their low-sugar granola (NPM score 2) on UK websites.", "explanation": "The product scores below the NPM threshold (4 for food), so it is not classified as less healthy." } ], "fail": [ { "scenario": "A large snack company runs paid Instagram ads in the UK featuring their crisps (NPM score 8) at 2:00 PM.", "explanation": "The product is less healthy (NPM >= 4), the company has 250+ employees, and paid online ads are prohibited." } ] } } ``` ## Temporal enforcement Policies have optional `effective_date` and `sunset_date` fields. Governance agents use these dates to determine enforcement behavior automatically: | Condition | Behavior | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | Before `effective_date` | Evaluate but treat as informational. Findings are reported at `info` severity regardless of the policy's declared enforcement level. | | Between `effective_date` and `sunset_date` (or no `sunset_date`) | Enforce at the declared level (`must` = reject, `should` = warn). | | After `sunset_date` | Stop evaluating. The policy no longer applies. | | No `effective_date` | Enforce immediately (the policy has always been in effect). | This means brands can reference upcoming regulations before they take effect. The governance agent evaluates them and reports what *would* have been flagged, without blocking campaigns. Once the effective date passes, enforcement activates automatically -- no configuration change needed. For example, the EU AI Act Article 50 has `effective_date: "2026-08-02"`. A brand referencing this policy before August 2026 sees informational findings about AI disclosure compliance. After August 2026, violations are rejected. For the operational pattern — how to pin a policy version, what happens when a registry policy version-bumps mid-campaign, how to use `effective_date` for staged adoption, the additive-only rule for inline `custom_policies`, and a working-group FAQ — see [Sync and versioning](/dist/docs/3.1.20/governance/policy-registry-sync). ## Three tiers of policy application | Tier | Source | Example | | ------------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | **Always-on** | Regulations that apply automatically based on brand category and campaign jurisdictions | COPPA for US children's brands, GDPR for EU campaigns | | **Best practices** | Standards that brands opt into based on their industry | Alcohol advertising standards for beverage brands | | **Brand-specific** | Custom policies in the brand's compliance configuration | Brand-specific competitor exclusions, custom content rules | ## Brand compliance configuration Brands reference registry policies through their compliance configuration. See the [Campaign Governance specification](/dist/docs/3.1.20/governance/campaign/specification#brand-compliance-configuration) for the conceptual model. ## Integration across governance domains The registry is a shared resource consumed by all governance domains: | Domain | How it uses registry policies | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | **[Campaign Governance](/dist/docs/3.1.20/governance/campaign/index)** | Resolves policies via brand compliance config, evaluates actions against policy text in `check_governance` | | **[Content Standards](/dist/docs/3.1.20/governance/content-standards/index)** | Creates content standards from registry policies using `registry_policy_ids` | | **[Property Governance](/dist/docs/3.1.20/governance/property/index)** | Declares `registry:` prefixed features in `get_adcp_capabilities`, evaluates properties against policy text | | **[Creative Governance](/dist/docs/3.1.20/governance/creative/index)** | Declares `registry:` prefixed creative features, evaluates creatives for AI disclosure and content compliance | | **[Media Buy](/dist/docs/3.1.20/media-buy/index)** | Sellers declare `enforced_policies` on products, buyers send `required_policies` in requests | ## Governance domains Each policy declares which governance sub-domains it applies to via `governance_domains`. This determines which types of governance agents can evaluate and declare the policy as a feature. | Domain | Description | | ------------------- | ------------------------------------------------------------------------- | | `campaign` | Campaign governance agents evaluate this policy during `check_governance` | | `property` | Property governance agents can declare this policy as a property feature | | `creative` | Creative governance agents can evaluate creatives against this policy | | `content_standards` | Content standards agents can create standards from this policy | For example, `eu_ai_act_article_50` has `governance_domains: ["creative", "content_standards"]` because it's about AI-generated content disclosure -- relevant to creative evaluation and content standards, but not to property or campaign-level governance. Filter by domain via the API: `GET /api/policies/registry?domain=creative` ## The `registry:` prefix Governance agents declare standardized capabilities using `registry:` prefixed feature IDs. This creates a shared vocabulary so buyers searching for "EU AI Act compliance" find agents using the same terminology. **Convention:** `registry:{policy_id}` maps a feature ID to a registry policy. Unprefixed feature IDs are agent-defined. The `registry:` convention is the **delegated** form of policy reference — buyer asks the seller's governance agent to evaluate the policy as a binary feature. The complementary **attribution** pattern, where producers tag mechanism-level filters and measurements with `policy_id` to record buyer-chosen thresholds and motivated evaluations, lives at [Policy Attribution](/dist/docs/3.1.20/governance/policy-attribution). **Property governance agent declares:** ```json theme={null} { "governance": { "property_features": [ { "feature_id": "registry:us_coppa", "type": "binary", "name": "COPPA compliance" }, { "feature_id": "registry:uk_hfss", "type": "binary", "name": "UK HFSS compliance" } ] } } ``` **Creative governance agent declares:** ```json theme={null} { "governance": { "creative_features": [ { "feature_id": "registry:eu_ai_act_article_50", "type": "binary", "name": "EU AI Act Article 50 compliance" }, { "feature_id": "registry:ca_sb_942", "type": "binary", "name": "California SB 942 compliance" } ] } } ``` **Buyer filters by feature:** ```json theme={null} { "feature_requirements": [ { "feature_id": "registry:us_coppa", "allowed_values": [true] } ] } ``` The governance agent fetches the policy text and exemplars from the registry to evaluate against. The buyer just references the policy ID. The `governance_domains` field on the policy validates that the agent type is appropriate for the policy. ## Buyer-seller transparency Buyers list enforced policies in media buy requests. Sellers declare which policies they already enforce on their products. **Buyer requests policies:** ```json theme={null} { "tool": "get_products", "arguments": { "brief": "UK video inventory for Q1", "required_policies": ["uk_hfss", "eu_gdpr_advertising"] } } ``` **Seller declares enforcement:** ```json theme={null} { "product_id": "premium_video_uk", "enforced_policies": ["uk_hfss", "eu_gdpr_advertising"] } ``` ## API The registry is served via the AgenticAdvertising.org API: | Endpoint | Method | Description | | ---------------------------- | ------ | ---------------------------------------------------------------------------------- | | `/api/policies/registry` | GET | List policies with filtering by category, jurisdiction, policy\_categories, domain | | `/api/policies/resolve` | GET | Resolve a single policy by ID (+ optional version) | | `/api/policies/resolve/bulk` | POST | Bulk resolve multiple policy IDs | | `/api/policies/history` | GET | Revision history for a policy | | `/api/policies/save` | POST | Create or edit a community policy (auth required) | Registry-sourced policies (authoritative) cannot be edited via the community save endpoint. Community-contributed policies go through a review process. ## Mandatory human review Policies and policy categories can declare `requires_human_review: true` to flag regulatory regimes that prohibit solely automated decisions — most notably GDPR Article 22 and EU AI Act Annex III. When a plan resolves any policy or category with this flag, the governance agent MUST set `plan.human_review_required = true` and MUST escalate every action for human review before execution. Seeded categories carrying `requires_human_review: true`: * `fair_housing` — US FHA, Annex III-equivalent housing decisions * `fair_lending` — US ECOA, Annex III §5(b) creditworthiness * `fair_employment` — US Title VII, ADEA, Annex III §1(b) recruitment * `pharmaceutical_advertising` — FDA DTC, EU prescription ad bans See [Annex III & Art 22 obligations](/dist/docs/3.1.20/governance/annex-iii-obligations) for deployer guidance and the distinction between `reallocation_threshold` (budget reallocation) and `human_review_required` (decisions affecting individuals). ## Seeded policies The registry ships with 14 seeded policies covering common advertising regulations and standards: ### Regulations | ID | Jurisdictions | Description | | ----------------------- | ------------- | -------------------------------------------------------------------------------------------------------- | | `uk_hfss` | GB | UK ban on paid online advertising of less healthy food/drink | | `us_coppa` | US | Children's Online Privacy Protection Act | | `eu_gdpr_advertising` | EU | GDPR requirements for advertising data processing | | `eu_ai_act_article_50` | EU | AI-generated content disclosure and C2PA provenance | | `eu_ai_act_annex_iii` | EU | Annex III high-risk categories — credit, insurance pricing, recruitment, housing (mandates human review) | | `ca_sb_942` | US | California AI Transparency Act for large platforms | | `us_cannabis` | US | Cannabis advertising restrictions (state-by-state) | | `tobacco_nicotine` | Global | Tobacco and nicotine advertising restrictions -- most jurisdictions ban tobacco advertising outright | | `political_advertising` | EU | Political advertising transparency and disclosure (EU DSA, US state-level AI disclosure laws) | ### Standards | ID | Policy Categories | Description | | ----------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `alcohol_advertising` | `age_restricted` | Responsible alcohol advertising practices | | `pharma_us_fda` | `pharmaceutical_advertising` | FDA-aligned pharmaceutical advertising | | `gambling_advertising` | `gambling_advertising` | Responsible gambling advertising | | `financial_services` | `fair_lending` | Financial product advertising disclosure | | `csbs` | All | Common Sense Brand Standards -- content adjacency standard (enforcement: `must`) contributed to AgenticAdvertising.org | | `childrens_advertising` | `children_directed` | Global standards for advertising directed at or seen by children (UK CAP/BCAP, EU AVMSD, ICC) | Common Sense Brand Standards (CSBS) is a content adjacency standard governed by AgenticAdvertising.org. It defines content categories where advertising placement poses brand-reputation risk, applicable across industries and channels. CSBS originated at Scope3 and was contributed to AAO in 2026; formalization of the IP donation instrument is tracked in [#2314](https://github.com/adcontextprotocol/adcp/issues/2314). Until the formal donation record is complete, CSBS ships as a seeded policy under AAO custodianship rather than under a signed assignment. ## Policy category definitions The registry defines policy categories — regulatory regime groupings that determine which sets of policies apply to a campaign. Categories are referenced by ID in campaign plans via `policy_categories`. Each category definition includes: | Field | Description | | ----------------------- | --------------------------------------------------------------------------------------- | | `category_id` | Unique identifier (e.g., `children_directed`, `fair_housing`) | | `name` | Human-readable name | | `description` | What regulatory regime this category represents | | `regulatory_frameworks` | Specific laws and regulations grouped under this category | | `restricted_attributes` | Personal data categories that must not be used for targeting when this category applies | | `industries` | Industries where this category commonly applies | | `guidance` | Implementation guidance for governance agents | ### Seeded categories | Category | Restricted Attributes | Description | | ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `children_directed` | — | COPPA, UK AADC, GDPR Article 8. Restricts data collection and targeting for children's content. | | `political_advertising` | `political_opinions` | EU DSA Article 26, US state disclosure laws. Prohibits special category targeting for political ads. | | `age_restricted` | — | Alcohol, tobacco, cannabis. Age-gating, time-of-day restrictions, and content placement rules. | | `gambling_advertising` | `health_data` | Sports betting, casinos, lotteries. Jurisdiction-level legality, self-exclusion compliance, responsible gambling messaging. | | `fair_housing` | `racial_ethnic_origin`, `religious_beliefs`, `sex_life_sexual_orientation` | US FHA, state fair housing. Prohibits targeting/exclusion by protected characteristics in housing ads. | | `fair_lending` | `racial_ethnic_origin`, `religious_beliefs`, `sex_life_sexual_orientation` | US ECOA, CFPB guidance. Prohibits discriminatory targeting in credit/lending ads. | | `fair_employment` | `racial_ethnic_origin`, `religious_beliefs`, `sex_life_sexual_orientation`, `health_data`, `genetic_data` | US EEOC (Title VII, ADA, GINA), state employment law. Prohibits discriminatory targeting in job ads. | | `pharmaceutical_advertising` | `health_data` | FDA DTC, EU prescription ad bans. Fair balance, indication restrictions. | | `health_wellness` | `health_data` | FTC health claims, supplement advertising. Substantiation requirements. | | `firearms_weapons` | — | Platform-level and jurisdictional restrictions on firearms advertising. | The `restricted_attributes` on a category are authoritative — when a plan declares a policy category, those attributes are automatically restricted for the campaign regardless of whether the plan also declares them in `restricted_attributes`. ## Restricted attribute definitions The registry defines restricted attribute categories — types of personal data that regulations restrict for ad targeting. These map to GDPR Article 9 special categories and are used across plans, signal definitions, and policy categories. Each attribute definition includes: | Field | Description | | ------------------ | -------------------------------------------------------------------------------------------------- | | `attribute_id` | Unique identifier (e.g., `health_data`, `racial_ethnic_origin`) | | `name` | Human-readable name | | `description` | What personal data this category covers | | `regulatory_basis` | Legal basis for restriction (e.g., "GDPR Article 9(1)") | | `includes` | Examples of data that falls within this category | | `excludes` | Common data that might seem related but is explicitly outside this category | | `signal_patterns` | Naming patterns governance agents can use to detect undeclared signals that likely touch this data | | `guidance` | Implementation guidance | ### Seeded attributes | Attribute | Regulatory Basis | Includes | | ----------------------------- | ----------------- | ------------------------------------------------------------------- | | `racial_ethnic_origin` | GDPR Article 9(1) | Race, ethnicity, national origin, tribal affiliation | | `political_opinions` | GDPR Article 9(1) | Party affiliation, voting patterns, political donations | | `religious_beliefs` | GDPR Article 9(1) | Religion, denomination, religious practice indicators | | `trade_union_membership` | GDPR Article 9(1) | Union membership, collective bargaining participation | | `health_data` | GDPR Article 9(1) | Medical conditions, prescriptions, health behaviors, disability | | `sex_life_sexual_orientation` | GDPR Article 9(1) | Sexual orientation, gender identity, relationship status indicators | | `genetic_data` | GDPR Article 9(1) | DNA profiles, genetic test results, hereditary conditions | | `biometric_data` | GDPR Article 9(1) | Fingerprints, facial geometry, voice prints, gait analysis | Data providers can reference these definitions when declaring `restricted_attributes` on their signal definitions. See [Declaring governance metadata](/dist/docs/3.1.20/signals/data-providers#declaring-governance-metadata). Governance agents match signal-declared attributes against plan-level `restricted_attributes` during [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) validation — signals with matching restricted attributes are blocked from targeting for that campaign. ## Contributing policies Community members can contribute new policies via the API or admin interface. Contributed policies: * Must include `policy_id`, `version`, `name`, `category`, `enforcement`, and `policy` text * Are created with `source_type: community` and `review_status: pending` * Go through review before becoming available in the registry * Cannot overwrite registry-sourced (authoritative) policies # Policy Registry: Sync and versioning Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/policy-registry-sync Operational pattern for keeping campaign plans synchronized with the AdCP Policy Registry — version pinning, registry version bumps, effective_date adoption, sunset behavior, and the additive-only invariant on inline policies. Working groups designing campaign governance ask the same set of questions about how policies stay synchronized between buyers, sellers, and governance agents. This page captures the operational pattern, in five sections plus an FAQ. ## Can I pin a policy version on my plan? **No — `policy_ids[]` carries no version qualifier today.** A campaign plan references registry policies by ID alone: ```json theme={null} { "plan_id": "plan_q1_2027_acme", "policy_ids": ["us_coppa", "alcohol_advertising"], "policy_categories": ["age_restricted"] } ``` At every [`check_governance`](/dist/docs/3.1.20/governance/campaign/tasks/check_governance) call, the governance agent resolves each ID against the registry and uses **whatever version is current**. There is no plan-level version pin field. If a registry policy version-bumps between two checks on the same plan, the second check evaluates against the new version. If you need a deterministic policy text for the duration of a buy, copy the registry policy into [`custom_policies[]`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans) on the plan — see [Pinning by inline copy](#pinning-by-inline-copy) below. The audit trail records `policies_evaluated[]` on every check, so the historical version is recoverable per-check via [`/api/policies/history`](https://adcontextprotocol.org/api/policies/history). This is the same posture taken by other ad-tech protocols (TCF v2's TC string, OpenRTB GPP) — versions resolve at evaluation time, not at request authoring time. It keeps buyers out of the version-dependency-management business in the 99% case where latest-at-resolution is correct. ## Pinning by inline copy When a deterministic policy text is required — a regulator pre-clearance, a frozen brand-safety contract, a multi-month brand campaign that must be evaluated against the policy as it was on day one — the available pattern is to copy the registry policy into `custom_policies[]` at plan creation under a **different `policy_id`**: ```json theme={null} { "plan_id": "plan_q1_2027_acme", "policy_ids": ["us_coppa"], "custom_policies": [ { "policy_id": "alcohol_advertising_pinned_2026Q4", "version": "2.1.0", "name": "Alcohol Advertising Standards (pinned to v2.1.0)", "category": "standard", "enforcement": "must", "policy": "", "exemplars": { "...": "copied from registry" } } ] } ``` **Use a different `policy_id` from the registry one.** If the buyer reuses the canonical ID `alcohol_advertising` in `custom_policies`, governance-agent behavior is undefined by the spec — the [policy-entry schema](https://adcontextprotocol.org/schemas/3.1.20/governance/policy-entry.json)'s additive-only rule pins registry text as authoritative for that ID. A pinning ID like `alcohol_advertising_pinned_2026Q4` (or your internal versioning convention) sidesteps the conflict. The plan revision now carries the frozen text. The `version` field on the inline policy is informational — the text is what gets evaluated — but worth setting for forensic traceability so an auditor can correlate the inline copy back to a specific registry release. **Lifecycle.** The inline copy is preserved as long as the buyer keeps the entry in `custom_policies` on each re-sync of the plan. The governance agent's append-only `revisionHistory` archives prior plan revisions for audit, but the live evaluation always uses whatever is in the most recent `sync_plans` payload — so a buyer who drops the inline policy on a re-sync loses the pin on the next check. **Tradeoff.** Pinned policies don't pick up registry corrections. If `alcohol_advertising` v2.2.0 ships a clarification, the pinned plan keeps evaluating against v2.1.0 until someone manually re-syncs `custom_policies` with new text. That's the price of stability. ## Inline policies are additive-only relative to registry Inline `custom_policies[]` carry a hard invariant from the [policy-entry schema](https://adcontextprotocol.org/schemas/3.1.20/governance/policy-entry.json): they may only **add** restrictions on top of registry-sourced policies. Inline policies MUST NOT relax `enforcement` levels of registry policies, exempt categories that a registry policy mandates, or otherwise weaken the registry baseline. A buyer-authored inline policy that doesn't intersect with any registry policy is unconstrained — only the relationship to registry policies is governed. Concretely, a governance agent evaluating a plan with both `policy_ids: ["us_coppa"]` and a `custom_policies` entry can pin or extend `us_coppa` (e.g., a brand-specific exemplar set under a renamed ID), but cannot add an inline policy that says "ignore COPPA for this campaign." Counterparties seeing `policies_evaluated: ["us_coppa"]` on an audit entry can therefore trust that the registry version of `us_coppa` was applied at its declared `must` level — the buyer did not silently downgrade it. Counterparties who want to verify this can request the plan revision and recompute the `plan_hash` ([Campaign Governance specification](/dist/docs/3.1.20/governance/campaign/specification#plan-binding-and-audit)). The plan binding is the cryptographic surface that makes the additive-only invariant verifiable rather than merely declared. The agent-side enforcement is what prevents a downgrade from happening; the hash is what makes the decision provable after the fact. ## Handling registry version bumps mid-campaign When a registry policy version-bumps while a plan is active: | Plan state | Behavior | | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Plan references the policy via `policy_ids` only | Next `check_governance` resolves the new version. Buys already committed remain committed (their audit entry recorded the old version's resolution timestamp). New checks evaluate against the new text. | | Plan pinned the policy via `custom_policies` (under a renamed ID) | Plan keeps evaluating against the inline text. The registry change has no effect on this plan until the buyer re-syncs `custom_policies` with new text. | | Plan was deleted or completed | No ongoing checks; no evaluation. The audit log retains `policies_evaluated[]` for forensic recovery. | Buyers who care about the version stability of completed buys do not need to do anything — the audit trail captures what was evaluated. Buyers who care about stability for *active, in-flight* buys should use the inline-copy pattern. ## `effective_date` for staged adoption The "minimal restrictions initially" pattern is the registry's documented behavior, not a per-plan setting. Governance agents honor `effective_date` automatically across every plan that references the policy ID: 1. **Day 0** — community agrees on a draft policy. Publish it to the registry with `effective_date` 60+ days in the future. 2. **Day 0–60** — every governance agent evaluating any plan that references the policy ID emits informational findings. Buyers and sellers see exactly what would have been flagged. No buys are blocked. 3. **Day 60** — `effective_date` passes. The same evaluation now blocks at the policy's declared `enforcement` level. No configuration change required on any plan. 4. **Day 60+** — buyers in the staged-adoption window had two months of telemetry to adjust their inventory and creatives. Late starters get a hard cutover. `effective_date` is the time axis of staged adoption. **Scope-based staging** — phasing by channel, jurisdiction, or `policy_categories` subset — is a separate move done at the registry level: publish a narrower-jurisdiction or narrower-category policy first, then a broader one. The two axes compose; a single policy can sit in a scope-narrowed and time-staged window simultaneously. ## Sunset behavior When a registry policy reaches its `sunset_date`, governance agents stop evaluating it on subsequent checks. Existing audit entries that recorded the policy in `policies_evaluated[]` are unchanged — the trail tells the truth about what was evaluated when. No action is required from buyers; sunsetted policies fall out of every active plan automatically. If a sunsetted policy is replaced by a successor (e.g., a regulation supersedes another), the registry contributor publishes both: the old entry with `sunset_date` set, the new entry with `effective_date` set. Buyers update their `policy_ids[]` in the next plan revision to reference the new entry — the old ID continues evaluating until its sunset date and then quietly stops. ## Common questions **What happens to in-flight buys when a regulation changes mid-campaign?** The table under [Handling registry version bumps mid-campaign](#handling-registry-version-bumps-mid-campaign) is the answer. Short version: committed buys remain committed; the next check resolves whatever is current; pinning via `custom_policies` is the way to freeze a specific text. **Does my plan re-evaluate when I add a new objective?** A plan revision (`plan_version` on the audit-log entry, recorded each time the plan re-syncs) doesn't refresh resolved policy text on its own — the next `check_governance` still hits the registry as configured. To refresh policy text on a revision, change `policy_ids[]` (or the inline copy in `custom_policies`) on the new plan revision. **How do I prove to a counterparty which version was applied?** Three layers: (1) the seller's `governance_context` token correlates a specific check, (2) the audit-log entry for that check carries `policies_evaluated[]` and `plan_hash`, (3) the registry's [`/api/policies/history`](https://adcontextprotocol.org/api/policies/history) endpoint returns the full revision sequence for any `policy_id` so an auditor can replay which version was active at a historical timestamp. **Per-jurisdiction overrides.** Declare both the global standard and a jurisdiction-specific tightening (e.g., `policy_ids: ["alcohol_advertising", "alcohol_advertising_norway"]`). The governance agent evaluates both; the additive-only rule means the more restrictive of the two wins. **Brand-specific extensions.** Use `custom_policies[]` for rules that don't belong in the shared registry (competitor exclusions, brand voice guidelines, internal compliance frameworks). Reference them alongside `policy_ids[]`. ## Related * [Policy Registry](/dist/docs/3.1.20/governance/policy-registry) — registry concepts, policy structure, seeded policies, restricted attributes * [Campaign Governance specification](/dist/docs/3.1.20/governance/campaign/specification) — plan binding, `plan_hash`, governance context lifecycle * [audit trail: internal vs shareable views](/dist/docs/3.1.20/governance/campaign/audit-trail) — how to surface evaluation history to counterparties * [`sync_plans`](/dist/docs/3.1.20/governance/campaign/tasks/sync_plans) — `policy_ids`, `policy_categories`, `custom_policies` * [Annex III & Art 22 obligations](/dist/docs/3.1.20/governance/annex-iii-obligations) — when human review is required # adagents.json Tech Spec Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/adagents adagents.json is the publisher-hosted file in AdCP that declares advertising properties and authorizes sales agents to sell inventory. The `adagents.json` file provides a standardized way for publishers to declare their properties and authorize sales agents. This is the foundation of Property Governance - it defines what properties exist and who can sell them. ### Unified declaration model `adagents.json` serves as the declaration mechanism for both **property authorization** and **signal data provider** registration. A single file at `/.well-known/adagents.json` can declare both `properties` and `signals` top-level fields simultaneously. ```json theme={null} { "version": "1.0", "properties": [ { "domain": "publisher.example.com", "agents": [ { "agent_url": "https://ads.publisher.example.com", "relationship": "direct" } ] } ], "signals": [ { "catalog_url": "https://signals.publisher.example.com/catalog.json", "relationship": "direct", "description": "First-party audience signals from publisher.example.com" } ] } ``` This combined model is common for publishers with first-party data — the same domain authorizes sales agents (via `properties`) and declares published signal definitions (via `signals`). The two namespaces are independent: authorization for property sales does not grant signal access, and signal registration does not imply property authorization. See [Signal data providers](/dist/docs/3.1.20/signals/data-providers) for the signals-side documentation. For the sell-side decision tree that pairs publisher authorization with operator `brand.json` identity and signing-key discovery, see [seller setup](/dist/docs/3.1.20/brand-protocol/seller-setup). **[AdAgents.json Builder](https://agenticadvertising.org/adagents/builder)** - Validate existing files or create new ones with guided validation ## Why `adagents.json` instead of `ads.txt` `ads.txt` answers a narrower question: is this seller present on the publisher's list, and is the relationship labeled `DIRECT` or `RESELLER`? That is useful, but it is too flat for many modern publisher sales models. It does not tell buyers: * which property is covered * which placements are covered * whether the path is direct, delegated, or network-mediated * whether the authorization is country-limited or time-bounded * whether a network-managed slot is the same thing as a publisher-managed premium placement `adagents.json` is designed to carry that structure. It lets publishers declare property identity, placement identity, delegation type, scoped authorization, and publisher-defined grouping tags in one place. | Question | `ads.txt` | `adagents.json` | | -------------------------------------------------------------------- | ----------- | -------------------------- | | Is this seller declared at all? | Yes | Yes | | Which property is covered? | No | Yes | | Which placement is covered? | No | Yes | | Can the publisher group inventory into governed buckets? | No | Yes, via `placement_tags` | | Can authorization vary by country or time window? | No | Yes | | Can the path be described as direct, delegated, or network-mediated? | Very weakly | Yes, via `delegation_type` | For the higher-level framing and a side-by-side example, see [Why adagents.json is more expressive than ads.txt](https://agenticadvertising.org/perspectives/adagents-json-vs-ads-txt). ### Where does sellers.json fit? In programmatic, `sellers.json` is hosted by the seller/exchange and declares which publishers they represent. AdCP handles this through `brand.json` instead of a separate file. An operator declares properties in their [`brand.json`](/dist/docs/3.1.20/brand-protocol/brand-json) using the `relationship` field. For first-party inventory, the operator can use `relationship: "owned"`. For delegated or network sell-side paths, `relationship` uses the same values as `delegation_type`: `direct`, `delegated`, or `ad_network`. This creates the same bilateral verification pattern: | Programmatic | AdCP equivalent | Purpose | | ----------------------- | ------------------------------------------------------ | ------------------------------------------------------ | | `ads.txt` (publisher) | `adagents.json` with `delegation_type` (publisher) | "These agents are authorized, here's the relationship" | | `sellers.json` (seller) | `brand.json` properties with `relationship` (operator) | "I sell for these publishers, here's how" | For delegated or network paths, both sides must agree — the `delegation_type` and `relationship` values should match. For first-party inventory, `relationship: "owned"` is an inline ownership declaration and has no matching `delegation_type` value. See [ad networks](/dist/docs/3.1.20/sponsored-intelligence/networks) for how this works in practice. The field is called `delegation_type` in adagents.json and `relationship` in brand.json. The names differ because they describe the same commercial arrangement from different perspectives — the publisher delegates authority (`delegation_type`), the operator declares its relationship to the property (`relationship`). Delegated/network values match (`direct`, `delegated`, `ad_network`); `owned` is only a brand.json relationship value. ## File Location Publishers must host the `adagents.json` file at: ``` https://example.com/.well-known/adagents.json ``` Following [RFC 8615](https://datatracker.ietf.org/doc/html/rfc8615) well-known URI conventions, this location ensures consistent discoverability across publishers. If the publisher origin canonicalizes hostnames with HTTP redirects, deploy the file at the final resolved URL too. For example, if `https://example.com/.well-known/adagents.json` redirects to `https://www.example.com/.well-known/adagents.json`, the `www` URL must serve the JSON file with a `200` response. A redirect chain that ends in `404` means the file is missing at the canonical host; troubleshoot the terminal status and resolved URL, not only the intermediate `301` or `302`. ## Basic Structure The file must be valid JSON with UTF-8 encoding and return HTTP 200 status. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "contact": { "name": "Example Publisher Ad Operations", "email": "adops@example.com", "domain": "example.com", "seller_id": "pub-example-12345", "tag_id": "67890" }, "properties": [ { "property_id": "example_site", "property_type": "website", "name": "Example Site", "identifiers": [ {"type": "domain", "value": "example.com"} ] } ], "authorized_agents": [ { "url": "https://agent.example.com", "authorized_for": "Official sales agent", "authorization_type": "property_ids", "property_ids": ["example_site"], "delegation_type": "direct", "exclusive": true } ], "last_updated": "2025-01-10T12:00:00Z" } ``` ## Schema Fields **`$schema`** *(optional)*: JSON Schema reference for validation **`contact`** *(optional)*: Contact info for entity managing this file * **`name`** *(required)*: Name of managing entity (may be publisher or third-party) * **`email`** *(optional)*: Contact email for questions/issues * **`domain`** *(optional)*: Primary domain of managing entity * **`seller_id`** *(optional)*: Seller ID from IAB Tech Lab sellers.json * **`tag_id`** *(optional)*: TAG Certified Against Fraud ID * **`privacy_policy_url`** *(optional)*: URL to entity's privacy policy for consumer consent flows **`catalog_etag`** *(optional)*: Opaque cache/version token for the public catalog portions of this file * Publishers SHOULD change it whenever `properties`, `collections`, `placements`, `formats`, `signals`, or their tag metadata changes * Buyer SDKs SHOULD cache resolved references by URL plus `catalog_etag` and re-resolve catalog references when the value changes * If absent, buyers fall back to HTTP validators such as `ETag`/`Last-Modified`, then a bounded TTL **`properties`** *(optional)*: Array of properties covered by this file (canonical property definitions) * **`supported_channels`** *(optional)*: Advertising channels this property supports (e.g., `["display", "olv", "social"]`). See [Media Channel Taxonomy](/dist/docs/3.1.20/reference/media-channel-taxonomy). **`collections`** *(optional)*: Collections produced or distributed by this publisher * Products reference these via `collections` selectors with `publisher_domain` and `collection_ids` * Useful when authorization needs to be scoped to specific series, podcasts, streams, or recurring content programs **`placements`** *(optional)*: Canonical placement definitions for the properties in this file * Products SHOULD reuse these `placement_id` values when declaring `placements` * Reusing a registered `placement_id` means the product is referring to the same semantic placement, not inventing a different one with the same ID * Placement definitions can include `tags`, `property_ids` or `property_tags` for property linkage, `channels`, and `format_options` for creative support * Placements in `adagents.json` are public by definition. Do not publish seller-private placement IDs, source/origin fields, or delivery-system mappings in this file. * Authorization entries can narrow scope to specific `placement_ids` * Authorization entries can also use `placement_tags` for governed placement groupings such as `programmatic`, `direct_only`, or `managed_by_riverline` * Useful for expressing distinctions like "available via this agent only for homepage native feed" or "only for pre-roll" **`tags`** *(optional)*: Tag metadata providing human-readable context and enabling efficient grouping **`placement_tags`** *(optional)*: Metadata for publisher-defined placement tags * Provides human-readable definitions for placement tag values used in `placements[*].tags` and `authorized_agents[*].placement_tags` * These are publisher-local concepts, not a global taxonomy ### Public placement catalog The `placements[]` array is the publisher's public placement catalog. It defines stable, semantic placement IDs that products and authorization rules can reference. A buyer should not have to interpret a raw ad-server ad unit path, delivery placement ID, video-ad-server zone, or other seller-internal delivery identifier to understand what a product offers. Publishers that expose placement, format, collection, or property catalogs, or published `signals[]` definitions, should publish `catalog_etag` and update it whenever those entries change. This lets buyer SDKs cache catalog lookups without silently resolving the same `{publisher_domain, placement_id}` or `{publisher_domain, format_option_id}` to different metadata after a publisher deployment. At minimum, a public placement should describe: * stable `placement_id` * `name` and `description` * the `property_ids` or `property_tags` where it can run * supported `format_options`, which can reference publisher-owned formats and canonical formats * optional `channels` Placement format support is a new 3.1 catalog feature and uses the 3.1+ canonical format-option model only: * `format_options[]` may reference declarations in the same file's top-level `formats[]` by `format_option_id`. Those top-level declarations can be publisher-owned custom formats or narrowed canonical formats. * `format_options[]` may also carry an inline canonical `ProductFormatDeclaration` when the placement-specific narrowing is not worth a reusable top-level format entry. The canonical anchor is `format_kind`. A placement entry that carries only `{ "format_option_id": "..." }` inherits the canonical format by resolving that ID to the same file's top-level `formats[]` declaration, then reading its `format_kind`. Outside this file, a buyer-facing `FormatOptionRef` for a publisher-declared format option uses `{ "scope": "publisher", "publisher_domain": "...", "format_option_id": "..." }`. Placement catalog formats describe what the public placement can support. When a product later references that placement, the product-level `format_ids` or `format_options` remain the buyable creative contract; placement `format_options` narrow that set for the specific placement. If the catalog placement and product declarations disagree, buyers use the intersection and should not treat a catalog-only format as accepted by the product. ```json theme={null} { "catalog_etag": "daily-pulse-2026-05-25", "formats": [ { "format_kind": "html5", "format_option_id": "publisher_takeover_html5", "display_name": "Publisher takeover HTML5", "params": { "width": 970, "height": 250, "max_file_size_kb": 200 } } ], "placements": [ { "placement_id": "homepage_takeover", "name": "Homepage takeover", "description": "High-impact homepage sponsorship across the main article rail and top video module.", "property_ids": ["daily_pulse"], "channels": ["display", "olv"], "format_options": [ { "format_option_id": "publisher_takeover_html5" }, { "format_kind": "image", "params": { "width": 300, "height": 250, "image_formats": ["jpg", "png"], "max_file_size_kb": 150 } } ] } ] } ``` Because `adagents.json` is public, it is not the place to expose operational inventory internals. Keep source/origin (`synced` vs synthetic), raw ad-server IDs, delivery mappings, and seller-private placement groupings in the seller's internal inventory registry. The public placement catalog should describe only buyer-understandable inventory that the publisher is willing to make discoverable. ### Product targetability `adagents.json` does not decide whether a placement is targetable in a product. It only publishes stable public placement IDs and their buyer-understandable semantics. A sales agent decides per product whether a public placement is buyer-selectable or merely part of the product composition. That decision is exposed, when needed, in the sales agent's `get_products` product placement object, not in the publisher's `adagents.json`. If a product depends on seller-private delivery composition, describe that composition in product prose and keep the underlying placement IDs in the seller system. Do not publish private placement IDs in `adagents.json` or `get_products`. ### Internal mapping Publishers still need to map public placements to delivery systems, and they may need synthetic internal groupings when the delivery system does not have the right semantic object. That mapping is implementation detail, not public protocol state. Common internal cases: * grouping multiple synced placements * grouping multiple ad units or zones * exposing an ad unit as a buyer-understandable placement * mapping ambiguous `1x1`, fluid, native, out-of-page, or video objects to the format they actually render * preserving strategic opacity while keeping products free of raw delivery IDs These internal mappings can produce public `placements[]` entries, but the mapping details themselves remain out of `adagents.json`. **`authorized_agents`** *(required)*: Array of authorized sales agents. MAY be empty (`[]`) for a **catalog-only community mirror** — a file that publishes `formats`/`properties`/`placements` (typically with a `catalog_etag`) for a platform that has not adopted AdCP, where there is no sales agent to authorize (see [Community mirror lifecycle](#community-mirror-lifecycle)). An empty array asserts **no sales authorization**: validators MUST NOT read it as deny-all, authorize-all, or a revocation, MUST NOT treat its presence as an error, and MUST still consume the catalog arrays. A file with neither sales authorization nor catalog content is invalid. The per-entry fields below apply only when the array is non-empty: * **`url`** *(required)*: Agent's API endpoint URL * **`authorized_for`** *(required)*: Human-readable authorization description * **`authorization_type`** *(required)*: Discriminator naming which selector field carries the scope. One of `property_ids`, `property_tags`, `inline_properties`, `publisher_properties` (for properties) or `signal_ids`, `signal_tags` (for signal providers). The corresponding selector field must be present and non-empty — see [Authorization Patterns](#authorization-patterns) below. * **`delegation_type`** *(optional)*: Commercial relationship for this path: `direct`, `delegated`, or `ad_network` * **`collections`** *(optional)*: Additional collection selectors that narrow authorization to specific content programs * **`placement_ids`** *(optional)*: Placement IDs from the top-level `placements` array that narrow authorization to specific placements * **`placement_tags`** *(optional)*: Publisher-defined placement tags that narrow authorization to governed placement groups * **`countries`** *(optional)*: ISO 3166-1 alpha-2 country codes limiting where the authorization applies * **`effective_from` / `effective_until`** *(optional)*: Time window for the authorization * **`exclusive`** *(optional)*: Whether this is the publisher's sole authorized path for the scoped inventory slice * **`signing_keys`** *(optional)*: Publisher-attested public keys buyers can pin when verifying signed agent responses * **`last_updated`** *(optional)*: ISO 8601 timestamp when this `authorized_agents[]` entry last changed. Independent of the file-level `last_updated`. Advisory — enables validators to skip unchanged entries on partial walks. See [managed-networks security](/dist/docs/3.1.20/governance/property/managed-networks#security-considerations) for the conditional-refresh protocol it enables. * **Additional fields**: Depends on authorization\_type (see patterns below) **`revoked_publisher_domains`** *(optional, managed-network use)*: Top-level array of publisher domains explicitly removed from a managed network's authoritative file. Each entry has `publisher_domain`, `revoked_at` (ISO 8601), and optional `reason`. Validators MUST treat any listed domain as no-longer-authorized regardless of where else it appears in the file. See [Publisher revocation](/dist/docs/3.1.20/governance/property/managed-networks#publisher-revocation-the-exit-lifecycle) for the operational lifecycle and validator-side durability rule. **`last_updated`** *(optional)*: ISO 8601 timestamp of last modification **`property_features`** *(optional)*: Array of governance agents that provide data about properties in this file * **`url`** *(required)*: Agent's API endpoint URL (governance agent implementing property governance tasks) * **`name`** *(required)*: Human-readable name of the vendor/agent * **`features`** *(required)*: Array of feature IDs this agent provides (e.g., `["carbon_score", "mfa_score"]`) * **`publisher_id`** *(optional)*: Publisher's identifier at this agent (for lookup) This field enables **governance agent discovery** - buyers can find which agents have compliance, sustainability, or quality data for properties without querying every possible agent. ## Community mirror lifecycle When a platform has not adopted AdCP (e.g., a walled garden that hasn't published its own `adagents.json`), the AdCP community registry can publish a **catalog-only community mirror** on the platform's behalf — typically hosted at `https://creative.adcontextprotocol.org/translated//adagents.json`. A mirror exists to publish discovery metadata (`formats`, `properties`, `placements`) so buyers can reason about the platform's inventory shapes before the platform self-adopts. A community mirror: * Sets **`authorized_agents: []`** — there is no sales agent to authorize, and the mirror MUST NOT fabricate one. An empty array asserts *no sales authorization*; validators MUST NOT read it as deny-all, authorize-all, or a revocation, and MUST still consume the catalog arrays. * MUST carry at least one non-empty catalog array (`formats`/`properties`/`placements`/`collections`/`signals`) and SHOULD carry a **`catalog_etag`** cache validator (the validator enforces the array, not `catalog_etag`). A file with neither sales authorization nor catalog content is invalid. * Sets **`superseded_by`** once the platform publishes its own authoritative `adagents.json`. Buyer SDKs encountering `superseded_by` SHOULD re-fetch from the named URL rather than serving the stale mirror. The mirror SHOULD continue serving with `superseded_by` set for at least one minor release so buyer caches keyed on the mirror URL get an explicit migration signal. See the worked example at [`static/examples/adagents/community/meta.json`](https://github.com/adcontextprotocol/adcp/blob/main/static/examples/adagents/community/meta.json). ## URL Reference Pattern For publishers with complex infrastructure or CDN distribution, `adagents.json` can reference an authoritative URL instead of containing the full structure inline. ### When to Use URL References * **CDN Distribution**: Serve authorization data from a global CDN for better performance * **Centralized Management**: Single source of truth across multiple domains * **Large Files**: When authorization data is too large for inline embedding * **Dynamic Updates**: When authorization needs frequent updates without touching domain files ### URL Reference Structure ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "authoritative_location": "https://cdn.example.com/adagents/v2/adagents.json", "last_updated": "2025-01-15T10:00:00Z" } ``` ### Requirements * **HTTPS Required**: The `authoritative_location` must use HTTPS * **No Nested References**: The authoritative file cannot itself be a URL reference (prevents infinite loops) * **Same Schema**: The authoritative file must be a valid inline adagents.json structure * **Single Hop**: Only one level of URL indirection is allowed For a complete guide to deploying this pattern across hundreds or thousands of domains, see [Managed Network Deployment](/dist/docs/3.1.20/governance/property/managed-networks). ## Discovery fallback: ads.txt `managerdomain` This is a **legacy compatibility fallback** for existing ads.txt-era publisher-manager setups. For managed-network deployments in AdCP, the normative delegation pattern remains [`authoritative_location`](/dist/docs/3.1.20/governance/property/managed-networks) in the publisher's own `/.well-known/adagents.json` pointer file. New deployments SHOULD use that pattern. When `https://{publisher}/.well-known/adagents.json` returns `404`, or returns an S3/CloudFront-style `403` `AccessDenied` XML response, validators MAY attempt a compatibility fallback via `https://{publisher}/ads.txt`: 1. Read `ads.txt` and parse `managerdomain` entries. * Accepted form: `MANAGERDOMAIN=example.com` (IAB directive form only). * Key matching is case-insensitive (`MANAGERDOMAIN`, `managerdomain`, etc.). * Validators that support this fallback SHOULD follow a bounded HTTP redirect chain when fetching `ads.txt`, applying the same SSRF and public-host checks to each redirect hop. 2. If one or more eligible managerdomain entries remain, use the **last** eligible entry in file order and attempt `https://{managerdomain}/.well-known/adagents.json`. 3. If that manager file validates **and explicitly scopes authorization to the source publisher domain**, treat it as the discovered authorization source for this lookup. ### Safety rules for this fallback * **One hop only**: maximum depth is exactly one (`publisher -> managerdomain`). Do not chain managerdomain lookups. * **Cycle detection required**: if `managerdomain` points to a visited domain, ignore it. * **`#noagents` opt-out**: if a managerdomain line has a trailing comment containing the token `noagents` (case-insensitive), clients MUST ignore that managerdomain for adagents discovery. Example: `MANAGERDOMAIN=example.com #NOAGENTS`. * **Trigger statuses are narrow**: validators SHOULD only attempt this fallback when the publisher's direct `adagents.json` fetch returns `404` or an S3/CloudFront-style `403` `AccessDenied` XML response. Other statuses and failures — generic `403` denial, `500`, timeout, malformed JSON, content-type mismatch, or schema validation failure — do not trigger `managerdomain`. * **Explicit publisher scoping required**: a manager-hosted `adagents.json` MUST positively name the source publisher domain in a `publisher_domain` field that is reachable from at least one `authorized_agents[]` entry. "Reachable" means one of the following paths resolves to the source domain: 1. **Per-agent paths.** The agent entry directly carries the publisher domain under `publisher_properties[].publisher_domain`, inside `publisher_properties[].publisher_domains[]` (the compact managed-network form), or under `collections[].publisher_domain`. 2. **Property-level paths.** The agent entry references one or more top-level `properties[]` entries — either directly by ID/tag on the agent entry (`property_ids` / `property_tags` authorization\_type), or indirectly via a `publisher_properties` selector whose predicate is satisfied by parent-file `properties[]` carrying matching `publisher_domain` (see [Resolution paths](#resolution-paths) below) — and at least one resolved property carries a `publisher_domain` matching the source. This is the shape used in production by Mediavine and other managed networks where a property declares its `publisher_domain` once and many agents reference it indirectly. What does **not** satisfy this rule: an `inline_properties` selector whose inline properties omit `publisher_domain`, or a top-level `property_tags` selector whose resolved top-level properties carry no matching `publisher_domain`. If no reachable `publisher_domain` field matches the source, fallback MUST fail closed. The attack this protects against is implicit scoping — authorization that does not name the publisher anywhere the manager has to type the domain. Indirection through `properties[].publisher_domain` is safe because the manager has positively spelled the publisher's domain in the same manifest; the gate filters to properties whose `publisher_domain` matches the source before considering the reference. * **No success by silence**: if the manager lookup fails, treat the publisher as missing `adagents.json` (same as no fallback). This fallback is a compatibility affordance for publisher-manager topologies and does not replace the canonical `/.well-known/adagents.json` location. ### Example Use Case: Multi-Domain Publisher A publisher with multiple domains can maintain one authoritative file: **On each domain** (`https://domain1.com/.well-known/adagents.json`, `https://domain2.com/.well-known/adagents.json`, etc.): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "authoritative_location": "https://cdn.publisher.com/adagents/v2/adagents.json", "last_updated": "2025-01-15T10:00:00Z" } ``` **Authoritative file** (`https://cdn.publisher.com/adagents/v2/adagents.json`): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "contact": { "name": "Publisher Ad Operations", "email": "adops@publisher.com" }, "properties": [ { "property_id": "domain1_site", "property_type": "website", "name": "Domain 1", "identifiers": [{"type": "domain", "value": "domain1.com"}], "publisher_domain": "domain1.com" }, { "property_id": "domain2_site", "property_type": "website", "name": "Domain 2", "identifiers": [{"type": "domain", "value": "domain2.com"}], "publisher_domain": "domain2.com" } ], "authorized_agents": [ { "url": "https://sales-agent.publisher.com", "authorized_for": "All publisher properties", "authorization_type": "property_ids", "property_ids": ["domain1_site", "domain2_site"] } ], "last_updated": "2025-01-15T09:00:00Z" } ``` ### Validation Behavior When AdCP validators encounter a URL reference: 1. **Fetch Reference**: Retrieve the file at `/.well-known/adagents.json` 2. **Detect Reference**: Check for `authoritative_location` field 3. **Validate URL**: Ensure `authoritative_location` is HTTPS and valid 4. **Fetch Authoritative**: Retrieve content from `authoritative_location` 5. **Prevent Loops**: Reject if authoritative file is also a reference 6. **Validate Structure**: Validate the authoritative file as normal inline structure Validators MUST apply the fetch semantics, change-detection, and rollback protection rules in [Managed network security considerations](/dist/docs/3.1.20/governance/property/managed-networks#security-considerations). That section is the canonical source for blast-radius guidance on the authoritative\_location pattern. ### Troubleshooting authoritative\_location failures Publishers registering agents through a URL reference commonly encounter these failure modes. Use this checklist before contacting support. **Origin requires authentication or IP restriction** Validators fetch `authoritative_location` server-side — CORS response headers are not required for server-to-server requests. However, if your origin requires authentication (e.g., an S3 bucket with a restrictive bucket policy, a CDN with origin protection that blocks unknown IPs) the fetch will be rejected with a `403`. Verify the URL is publicly reachable without authentication. **Redirect at the authoritative URL** Validators reject any HTTP redirect on the `authoritative_location` URL. The URL must resolve directly to the JSON file with a `200` response — no `301`, `302`, or other redirect chain. If your CDN or hosting redirects the URL (HTTP→HTTPS canonicalization, `www` redirect, versioned-path redirect), update `authoritative_location` to point to the final destination URL directly. **Wrong Content-Type** The response must be served with `Content-Type: application/json`. Servers that return `text/html` or `text/plain` — even when the body contains valid JSON — will fail content-type validation. Check your CDN or origin response headers. **HTML error page with 200 status** Some CDNs return an HTML error page with `200 OK` instead of a `4xx` status. Validators check the `Content-Type` header and attempt JSON parsing; an HTML body will fail parsing even when the status is `200`. Look for a parse error alongside a `200` status in validator output. **Debugging checklist** Reproduce the validator's fetch from your terminal to diagnose these failures: ```bash theme={null} curl -v \ -H "Accept: application/json" \ "https://cdn.example.com/adagents.json" ``` Verify in the output: * Response status is `200` (not `301`, `302`, `403`, or `404`) * `Content-Type` header is `application/json` * Response body is valid JSON containing at least one of `properties`, `signals`, or `authorized_agents` * Body does **not** contain an `authoritative_location` field (nested references are rejected) If the crawler exposes a diagnostic endpoint, run your URL through it for structured error output. ### Caching Recommendations * Cache reference files for 24 hours minimum * Cache authoritative files separately with their own TTL * Use `last_updated` timestamp to detect when cache should be invalidated * Implement exponential backoff for failed fetches Absolute cache caps and refresh floors for authoritative files live in [Managed network security considerations](/dist/docs/3.1.20/governance/property/managed-networks#security-considerations) — the requirements there take precedence over any `Cache-Control` on the origin. ## Authorization Patterns AdCP supports six `authorization_type` values, each optimized for different use cases. The table below maps each value to the companion field that carries the selector — useful as a quick reference when writing or validating `adagents.json`: | `authorization_type` | Companion field | Carries | Pattern | | ---------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `property_ids` | `property_ids[]` | IDs into the top-level `properties[]` of the same file | [Pattern 1](#pattern-1-property-ids-direct-references) | | `property_tags` | `property_tags[]` | Tags into the top-level `properties[]` of the same file | [Pattern 2](#pattern-2-property-tags-efficient-grouping) | | `inline_properties` | `properties[]` *(see warning below)* | Property objects defined directly on the agent entry | [Pattern 3](#pattern-3-inline-properties) | | `publisher_properties` | `publisher_properties[]` | Per-publisher selectors that resolve against each listed publisher's `adagents.json` (or the parent file's inline `properties[]`) | [Pattern 4](#pattern-4-publisher-property-references) | | `signal_ids` | `signal_ids[]` | IDs into the top-level `signals[]` of the same file | Signal-provider entries (see `signals` schema) | | `signal_tags` | `signal_tags[]` | Tags into the top-level `signals[]` of the same file | Signal-provider entries (see `signals` schema) | **`inline_properties` companion field is `properties`, not `inline_properties`.** It is the only `authorization_type` whose companion field name does not mirror the discriminator value. Validators reject `inline_properties` entries that carry a top-level `inline_properties` array but no `properties`. See [Pattern 3](#pattern-3-inline-properties) for an example. ### Pattern 1: Property IDs (Direct References) **Best for**: Specific, enumerable property lists. Direct and unambiguous. **Structure**: ```json theme={null} { "properties": [ { "property_id": "cnn_ctv_app", "property_type": "ctv_app", "name": "CNN CTV App", "identifiers": [ {"type": "roku_store_id", "value": "12345"} ] } ], "authorized_agents": [ { "url": "https://cnn-ctv-agent.com", "authorized_for": "CNN CTV properties", "authorization_type": "property_ids", "property_ids": ["cnn_ctv_app"] } ] } ``` **How it works**: Agent is authorized for specific properties listed in `property_ids` array. The properties must be defined in the top-level `properties` array. ### Pattern 2: Property Tags (Efficient Grouping) **Best for**: Large networks where one tag can reference hundreds/thousands of properties. Provides grouping efficiency without listing every property ID. **Key Insight**: Tags are not just "human-readable metadata" - they're a **performance optimization**. A publisher with 500 properties can use one tag to authorize all of them, rather than listing 500 property IDs. **Structure**: ```json theme={null} { "properties": [ { "property_id": "instagram", "property_type": "mobile_app", "name": "Instagram", "identifiers": [ {"type": "ios_bundle", "value": "com.burbn.instagram"} ], "tags": ["meta_network", "social_media"] }, { "property_id": "facebook", "property_type": "mobile_app", "name": "Facebook", "identifiers": [ {"type": "ios_bundle", "value": "com.facebook.Facebook"} ], "tags": ["meta_network", "social_media"] } ], "tags": { "meta_network": { "name": "Meta Network", "description": "All Meta-owned properties - enables one tag to authorize entire network" } }, "authorized_agents": [ { "url": "https://meta-ads.com", "authorized_for": "All Meta properties", "authorization_type": "property_tags", "property_tags": ["meta_network"] } ] } ``` **How it works**: Agent is authorized for all properties that have ANY of the listed tags. Properties are matched against the `tags` array in each property definition. ### Pattern 3: Inline Properties **Best for**: Small, specific property sets without top-level property declarations. **Structure**: ```json theme={null} { "authorized_agents": [ { "url": "https://agent.com", "authorized_for": "Specific inventory", "authorization_type": "inline_properties", "properties": [ { "property_type": "website", "name": "Example Site", "identifiers": [ {"type": "domain", "value": "example.com"} ] } ] } ] } ``` **How it works**: Properties are defined directly within the agent authorization entry instead of the top-level `properties` array. Useful when each agent has unique property definitions. ### Pattern 4: Publisher Property References **Best for**: Third-party agents representing multiple publishers. Single source of truth for property definitions. **Structure**: ```json theme={null} { "contact": { "name": "Third-Party CTV Network" }, "authorized_agents": [ { "url": "https://ctv-network.com/api", "authorized_for": "CTV inventory from multiple publishers", "authorization_type": "publisher_properties", "publisher_properties": [ { "publisher_domain": "cnn.com", "selection_type": "by_tag", "property_tags": ["ctv"] }, { "publisher_domain": "espn.com", "selection_type": "by_tag", "property_tags": ["ctv"] } ] } ] } ``` **How it works**: Agent references properties from OTHER publishers' adagents.json files. The `publisher_domain` points to the publisher, and `selection_type` determines how to resolve properties (`by_id` or `by_tag`). **Compact form for managed networks (`publisher_domains`)**: when the same predicate applies to many publishers — the canonical managed-network shape — replace the singular `publisher_domain` with the plural `publisher_domains` array. Each `publisher_properties[]` entry takes exactly one of `publisher_domain` or `publisher_domains`; both are equivalent for resolution and both satisfy the [`managerdomain` fallback safety rule](#safety-rules-for-this-fallback) for every listed domain. The compact form is available on `selection_type: "all"` and `"by_tag"` only. **It is intentionally NOT available on `selection_type: "by_id"`**: property IDs are publisher-scoped, and fanning the same ID set across N publishers' files would silently authorize whichever inventory happens to share an ID at each publisher. Use one `publisher_properties[]` entry per publisher when each publisher's ID set differs. Validators MUST reject `by_id` selectors that include `publisher_domains[]`. Validators MUST reject any `publisher_properties[]` entry that includes both `publisher_domain` and `publisher_domains`, and MUST reject any entry that includes neither. Implementations MAY mix the two forms across entries — for example, use the compact form for the bulk of represented publishers and a singular-form entry for an outlier publisher with a different tag predicate. ```json theme={null} { "contact": { "name": "Example Managed Network" }, "authorized_agents": [{ "url": "https://agent.network.example/api", "authorized_for": "Managed-network display inventory across represented publishers", "authorization_type": "publisher_properties", "publisher_properties": [ { "publisher_domains": ["site1.example", "site2.example", "site3.example"], "selection_type": "by_tag", "property_tags": ["managed_network"] } ], "delegation_type": "ad_network" }] } ``` The above is semantically identical to repeating the singular-form entry once per domain. Use it when every represented publisher tags inventory the same way (typical of WordPress/managed-network deployments); fall back to one entry per publisher only when the selector predicate genuinely differs. ### Resolution paths A `publisher_properties` selector resolves against properties in one of two ways. Federated is the default and the trust root; parent-file inline is a per-domain optimization a consumer MAY take when the parent file carries enough information to resolve the selector locally. **1. Federated resolution (default).** For each domain in `publisher_domain` or `publisher_domains[]`, fetch that publisher's `adagents.json` and apply the selector predicate against the publisher's own top-level `properties[]`. Each listed domain is resolved **independently and in parallel**: * If a listed publisher's `adagents.json` is unreachable (404, 5xx, timeout, fails its own validation), the selector resolves to the empty set **for that publisher only** — the entry remains valid for all other listed publishers. Consumers MUST NOT treat a single unreachable publisher as poisoning the rest of the compact entry. * If a listed publisher's `adagents.json` carries no properties matching the predicate (no entries with the named tag), the selector resolves to the empty set for that publisher. Same partial-resolution rule applies. * Resolution caching follows each publisher's own cache policy on their `adagents.json`, independently. A consumer SHOULD NOT extend or shorten one publisher's cache TTL based on observations of another publisher in the same compact entry. **2. Parent-file inline resolution (managed-network optimization).** A consumer MAY satisfy the selector from the parent file's own top-level `properties[]` when **all** of the following hold: * The parent file has top-level `properties[]` entries. * Every matched property carries an explicit `publisher_domain` field whose value equals one of the domains in the selector's `publisher_domain` / `publisher_domains[]` set. * For `selection_type: by_tag`: the property's `tags[]` contains at least one of the selector's `property_tags[]`. * For `selection_type: by_id`: the property's `property_id` is in the selector's `property_ids[]` AND the selector uses its singular `publisher_domain` form (the compact `publisher_domains[]` form remains rejected for `by_id` — property IDs are publisher-scoped and fanning a fixed ID set across publishers would silently authorize wrong inventory). * For `selection_type: all`: every parent-file `properties[]` entry with a matching `publisher_domain` is selected. Inline resolution is a **per-domain optimization**: a consumer MAY use inline resolution for the listed domains that have matching inline properties in the parent file, AND federated resolution for the remainder. Both paths SHOULD produce the same `(publisher_domain, property_id)` set when both are available. **Why this is safe.** The trust anchor for property authorization is the publisher whose domain is named on the property. By requiring `publisher_domain` on each inline property and matching against the selector's `publisher_domains[]`, the inline path preserves the invariant that the publisher whose inventory is being authorized is explicitly named — the same invariant the [`managerdomain` fallback safety rule](#safety-rules-for-this-fallback) protects. A manager file cannot use inline resolution to authorize inventory for a publisher it doesn't list. **Divergence rule.** If a consumer resolves the same `(publisher_domain, property_id)` via both inline and federated paths and the results disagree, the federated result is authoritative. Consumers SHOULD log the divergence as a publisher-side data-integrity warning and MAY surface it to operators. Consumers that prefer strict federation MAY ignore the inline path entirely. **Revocation under inline resolution.** Inline resolution MUST honor `revoked_publisher_domains[]` on the parent file. A `publisher_domain` listed as revoked at the parent level resolves to the empty set for that domain, regardless of whether matching properties exist in the parent's `properties[]`. Consumers that also resolve federated SHOULD cross-check the child's own `revoked_publisher_domains[]`; first match (parent or child) revokes. **When to use which.** Inline resolution exists because strict federation at managed-network scale (thousands of represented publishers under one operator) requires N HTTP fetches per authorization check, which no production consumer can sustain. Files that inline `properties[]` with `publisher_domain` anchors are signalling "you can resolve this here." Consumers handling small federated entries (a handful of publishers, each with their own properly-populated `adagents.json`) should prefer the federated path; it carries less consumer-side trust assumption. Consumers indexing managed-network parent files at scale should prefer the inline path; the parent file is structurally the property catalog whether the spec endorses it or not, and inline resolution makes that explicit. ## Authorization Qualifiers The four property-side `authorization_type` patterns above answer **which inventory** an agent can sell; the two signal-side values (`signal_ids`, `signal_tags`) carry the same shape for `signals[]`. The optional qualifiers below answer **how** that inventory is being made available. ### `delegation_type` * **`direct`**: The publisher treats this endpoint as a direct way to buy from them, even if a third party operates the software behind the scenes * **`delegated`**: The agent is authorized to sell on the publisher's behalf * **`ad_network`**: The inventory is sold through a network/package sales path rather than as the publisher's direct endpoint ### `collections` Use `collections` when authorization should only apply to inventory associated with specific content programs. This is especially useful for CTV, streaming, podcasting, and creator inventory where the same property can carry many collections with different commercial arrangements. ### `placement_ids` Use `placement_ids` to narrow authorization to canonical placements published in this same `adagents.json`. This is the field that lets a publisher say "this agent is authorized for MSN homepage native feed, but not for the entire property" or "this network can sell pre-roll but not host-read sponsorships." In product responses and creative assignment, the corresponding placement identity is `{ publisher_domain, placement_id }`. Canonical placement definitions can also carry: * `tags` for grouping placements across properties and products * `property_ids` or `property_tags` to answer "what placements are on property X?" and "what properties is placement Y on?" * `format_options` to answer "what formats does this placement support?" without relying entirely on product response placement details ### `placement_tags` Use `placement_tags` when authorization should apply to a governed placement group rather than a hand-maintained list of placement IDs. This is useful for commercial access patterns such as: * `programmatic` * `direct_only` * `publisher_managed` * `managed_by_taboola` Unlike freeform labels, these tags should be treated as part of the publisher's placement governance model because authorization decisions depend on them. Define them in top-level `placement_tags` metadata the same way property tags are documented in top-level `tags`. ### `signing_keys` Use `signing_keys` when the publisher wants to pin the public keys an authorized agent is allowed to sign with. This avoids trusting key discovery from the agent domain alone. * These are publisher-attested trust anchors, not just convenience metadata * Buyers should verify signed agent responses against the pinned keys in `adagents.json` * If an agent domain is compromised, pinned keys prevent the attacker from silently swapping both the endpoint and its advertised keys Publishers MUST populate `signing_keys` for any authorized agent whose delegated scopes include **mutating operations** — any AdCP task that writes state on behalf of the publisher. In the 3.x catalog this means the media-buy task set (`create_media_buy`, `update_media_buy`, `sync_creatives`, `update_performance_index`) and any future task flagged as mutating in the [media-buy task reference](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy). Read-only discovery tasks (`get_products`, `get_signals`, `list_creative_formats`) are out of scope for this requirement. Leaving `signing_keys` empty for a mutating-scope authorization reduces the trust chain to counterparty-controlled `jwks_uri` discovery and forfeits the publisher's pin as a cross-check. Verifier requirement: if the publisher's `adagents.json` entry for an agent contains `signing_keys`, the verifier MUST reject any signature whose `keyid` is not in that pinned set, regardless of `jwks_uri` contents. The pin is authoritative; the agent-hosted JWKS is advisory and MUST NOT override it. **Key rotation and cache semantics.** To keep the pin usable across rotations without opening a DoS-by-rotation window: * Verifiers SHOULD cache the pinned `signing_keys` for at most the `Cache-Control` `max-age` the publisher serves on `adagents.json`, defaulting to **one hour** when no directive is present. Longer caching risks rejecting a legitimate rotated key. * On encountering an **unknown `keyid`**, the verifier MUST force-refresh the publisher's `adagents.json` (bypassing cache) before final rejection. This prevents a stale cache from locking out a legitimately rotated key. * Publishers MAY carry **overlapping keys** in `signing_keys` during a rotation window so verifiers can accept signatures produced under either the old or the new key. The pinned set is unordered: presence in the set is sufficient for acceptance. Operators SHOULD remove the retired key from the pin once they are confident no in-flight traffic is still signing with it (hours, not days). **Bootstrap scope.** The pin protects against **agent-domain** compromise: if the agent domain is taken over, an attacker cannot silently swap both the endpoint and its advertised keys because the publisher's pin still governs acceptance. It does **not** protect against publisher-domain compromise (an attacker who controls `adagents.json` can rewrite the pin itself). First-ever retrieval of `adagents.json` is TLS-trust-only; the R-1 root-of-trust / key-transparency work (tracked in `specs/registry-change-feed.md` §Feed-event content signing) is the track that will strengthen this boundary. A follow-up is tracked to promote `signing_keys` from optional to required at the schema level for mutating-scope authorizations; the prose requirement above is the normative floor until that schema change lands. ### `countries` Use ISO 3166-1 alpha-2 country codes to constrain authorization geographically. This avoids ambiguous regional shorthands such as "LATAM" or "EMEA" and gives buyer agents a precise machine-readable scope. ### `effective_from` / `effective_until` Use these fields for time-bounded rights such as seasonal exclusives, windowed syndication, or temporary delegated sales agreements. ### `exclusive` Set `exclusive: true` when this agent is the publisher's sole authorized path for the scoped slice of inventory. Leave it absent or set it to `false` when multiple agents are authorized concurrently. ### Example: Scoped Delegation ```json theme={null} { "placement_tags": { "programmatic": { "name": "Programmatic", "description": "Placements available through programmatic sales paths" }, "direct_only": { "name": "Direct only", "description": "Placements reserved for direct publisher sales" } }, "collections": [ { "collection_id": "signal_noise", "name": "Signal & Noise", "kind": "series" } ], "placements": [ { "placement_id": "pre_roll", "name": "Pre-roll", "tags": ["audio", "pre_roll", "programmatic"], "property_ids": ["publisher_podcast"], "collection_ids": ["signal_noise"], "format_options": [ { "format_kind": "audio_hosted", "params": { "duration_ms_exact": 15000, "audio_codecs": ["mp3"] } } ] }, { "placement_id": "host_read", "name": "Host-read Mid-roll", "tags": ["audio", "host_read", "premium", "direct_only"], "property_ids": ["publisher_podcast"], "collection_ids": ["signal_noise"], "format_options": [ { "format_kind": "audio_hosted", "params": { "duration_ms_exact": 60000, "asset_source": "publisher_host_recorded", "buyer_asset_acceptance": "rejected" } } ] } ], "authorized_agents": [ { "url": "https://sales.publisher.example.com", "authorized_for": "Direct US and CA sales for Signal & Noise host reads", "authorization_type": "property_ids", "property_ids": ["publisher_podcast"], "collections": [ { "publisher_domain": "publisher.example.com", "collection_ids": ["signal_noise"] } ], "placement_tags": ["direct_only"], "delegation_type": "direct", "countries": ["US", "CA"], "exclusive": true }, { "url": "https://network.example.com", "authorized_for": "Open network distribution outside US and CA for pre-roll", "authorization_type": "property_ids", "property_ids": ["publisher_podcast"], "collections": [ { "publisher_domain": "publisher.example.com", "collection_ids": ["signal_noise"] } ], "placement_tags": ["programmatic"], "delegation_type": "ad_network", "countries": ["GB", "AU", "NZ"] } ] } ``` This lets a publisher say "buy host reads directly from us in some markets, but use a network path for pre-roll in others" without implying that every authorized path is equivalent. `adagents.json` now provides a canonical publisher-level placement registry. Products still return their own `placements`, but placement IDs are publisher-scoped: catalog-backed placements SHOULD reference the publisher registry with `{ publisher_domain, placement_id }`. When a product spans multiple publishers and catalog IDs collide, the `publisher_domain` disambiguates them; creative assignments should use structured `placement_refs` for those cases. Referencing a catalog placement means the product is inheriting that placement's identity; the product can narrow `format_ids`, preserve or narrow placement tags, or add operational detail, but it should not redefine the placement into something incompatible. ## Domain Matching Rules For website properties with domain identifiers, AdCP follows web conventions: ### Base Domain (`example.com`) Matches domain plus standard web subdomains: * ✅ `example.com` * ✅ `www.example.com` (standard web) * ✅ `m.example.com` (standard mobile) * ❌ `subdomain.example.com` (requires explicit authorization) ### Specific Subdomain (`subdomain.example.com`) Matches only that exact subdomain: * ✅ `subdomain.example.com` * ❌ All other domains/subdomains ### Wildcard (`*.example.com`) Matches ALL subdomains but NOT base: * ✅ Any subdomain * ❌ `example.com` (base domain requires separate authorization) ## Real-World Examples ### Example 1: Meta Network (Tag-Based) Large network using tags for grouping efficiency: ```json theme={null} { "contact": { "name": "Meta Advertising Operations", "email": "adops@meta.com", "domain": "meta.com", "seller_id": "pub-meta-12345", "tag_id": "12345", "privacy_policy_url": "https://www.meta.com/privacy/policy" }, "properties": [ { "property_type": "mobile_app", "name": "Instagram", "identifiers": [ {"type": "ios_bundle", "value": "com.burbn.instagram"}, {"type": "android_package", "value": "com.instagram.android"} ], "tags": ["meta_network"], "publisher_domain": "instagram.com" }, { "property_type": "mobile_app", "name": "Facebook", "identifiers": [ {"type": "ios_bundle", "value": "com.facebook.Facebook"}, {"type": "android_package", "value": "com.facebook.katana"} ], "tags": ["meta_network"], "publisher_domain": "facebook.com" }, { "property_type": "mobile_app", "name": "WhatsApp", "identifiers": [ {"type": "ios_bundle", "value": "net.whatsapp.WhatsApp"}, {"type": "android_package", "value": "com.whatsapp"} ], "tags": ["meta_network"], "publisher_domain": "whatsapp.com" } ], "tags": { "meta_network": { "name": "Meta Network", "description": "All Meta-owned properties - one tag authorizes entire network efficiently" } }, "authorized_agents": [ { "url": "https://meta-ads.com", "authorized_for": "All Meta properties", "authorization_type": "property_tags", "property_tags": ["meta_network"] } ] } ``` **Why this works**: One tag (`meta_network`) authorizes all properties without listing individual property IDs. As Meta adds properties, they just tag them - no need to update agent authorization. ### Example 2: CNN (Channel Segmentation) Different agents for different channels: ```json theme={null} { "contact": { "name": "CNN Advertising Operations", "email": "adops@cnn.com", "domain": "cnn.com" }, "properties": [ { "property_id": "cnn_ctv_app", "property_type": "ctv_app", "name": "CNN CTV App", "identifiers": [ {"type": "roku_store_id", "value": "12345"} ], "tags": ["ctv"] }, { "property_id": "cnn_web_us", "property_type": "website", "name": "CNN.com US", "identifiers": [ {"type": "domain", "value": "cnn.com"} ], "tags": ["web"] } ], "authorized_agents": [ { "url": "https://cnn-ctv-agent.com", "authorized_for": "CNN CTV properties", "authorization_type": "property_ids", "property_ids": ["cnn_ctv_app"], "delegation_type": "direct", "exclusive": true }, { "url": "https://cnn-web-agent.com", "authorized_for": "CNN web properties", "authorization_type": "property_ids", "property_ids": ["cnn_web_us"], "delegation_type": "delegated", "countries": ["US", "CA"] } ] } ``` ### Example 3: Publisher with Governance Agent References Publishers can declare which governance agents have data about their properties using `property_features`. This enables buyers to discover where to get sustainability, quality, and suitability data. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "contact": { "name": "Premium News Publisher", "email": "adops@news.example.com", "domain": "news.example.com" }, "properties": [ { "property_id": "news_main", "property_type": "website", "name": "News Example", "identifiers": [ {"type": "domain", "value": "news.example.com"} ], "tags": ["premium", "news"], "publisher_domain": "news.example.com" } ], "tags": { "premium": { "name": "Premium Properties", "description": "High-quality, brand-suitable properties" }, "news": { "name": "News Properties", "description": "News and journalism content" } }, "authorized_agents": [ { "url": "https://sales.news.example.com", "authorized_for": "All news properties", "authorization_type": "property_tags", "property_tags": ["news"] } ], "property_features": [ { "url": "https://api.sustainability-vendor.example", "name": "Sustainability Vendor", "features": ["carbon_score", "green_media_certified"], "publisher_id": "pub_news_12345" }, { "url": "https://api.quality-vendor.example", "name": "Quality Vendor", "features": ["mfa_score", "ad_density", "page_speed"] }, { "url": "https://api.suitability-vendor.example", "name": "Suitability Vendor", "features": ["content_category", "brand_risk_score", "sentiment"], "publisher_id": "suit_news_67890" } ], "last_updated": "2025-01-10T18:00:00Z" } ``` **Why this works**: * Publishers declare relationships with governance agents upfront * Buyers discover governance agents by reading adagents.json (no need to query every possible agent) * The `publisher_id` field helps agents look up the publisher's data efficiently * Feature IDs tell buyers what data types are available without querying ## Governance Agent Discovery The `property_features` field solves a key discovery problem: how does a buyer know which governance agents have data about a given property? ```mermaid theme={null} sequenceDiagram participant Buyer as Buyer Agent participant PubDomain as Publisher Domain participant SustAgent as Sustainability Agent participant QualAgent as Quality Agent Buyer->>PubDomain: GET /.well-known/adagents.json PubDomain-->>Buyer: adagents.json with property_features Note over Buyer: Extract governance agents from property_features par Query governance agents Buyer->>SustAgent: get_adcp_capabilities SustAgent-->>Buyer: Available features (carbon_score, etc.) and Buyer->>QualAgent: get_adcp_capabilities QualAgent-->>Buyer: Available features (mfa_score, etc.) end Note over Buyer: Create property lists on each governance agent Buyer->>SustAgent: create_property_list(filters, brand) Buyer->>QualAgent: create_property_list(filters, brand) ``` ### When to Use property\_features | Scenario | Use property\_features? | | -------------------------------------------------------------- | ------------------------------ | | Publisher has carbon scoring from a sustainability vendor | ✅ Yes | | Publisher has MFA score measured by a quality vendor | ✅ Yes | | Publisher has content classification from a suitability vendor | ✅ Yes | | Publisher self-reports brand suitability | ❌ No - use property tags | | Sales agent provides quality data | ❌ No - that's agent capability | ### Vendor Extensions Governance agents can include vendor-specific data in feature definitions via an `ext` block. See [get\_adcp\_capabilities](/dist/docs/3.1.20/protocol/get_adcp_capabilities) for details. ## Fetching and Validating ### Using the AdAgents.json Builder The easiest way to validate or create an adagents.json file is using the **[AdAgents.json Builder](https://agenticadvertising.org/adagents/builder)** web tool. It provides: * Domain validation (fetches and checks `/.well-known/adagents.json`) * Structure validation against the JSON schema * Agent card endpoint verification (checks if agent URLs respond correctly) * Guided file creation with proper formatting ### Programmatic Validation For programmatic validation, use the validation API: ```javascript JavaScript theme={null} // Validate a domain's adagents.json file const response = await fetch('https://adcontextprotocol.org/api/adagents/validate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: 'example.com' }) }); const { success, data } = await response.json(); if (success && data.found) { console.log(`Valid: ${data.validation.valid}`); console.log(`Agents: ${data.validation.raw_data?.authorized_agents?.length || 0}`); // Check for any validation errors if (data.validation.errors?.length > 0) { console.log('Errors:', data.validation.errors.map(e => e.message)); } } else { console.log('No adagents.json found at this domain'); } ``` ```python Python theme={null} import httpx # Validate a domain's adagents.json file response = httpx.post( 'https://adcontextprotocol.org/api/adagents/validate', json={'domain': 'example.com'} ) result = response.json() if result['success'] and result['data']['found']: validation = result['data']['validation'] print(f"Valid: {validation['valid']}") print(f"Agents: {len(validation.get('raw_data', {}).get('authorized_agents', []))}") # Check for any validation errors if validation.get('errors'): print('Errors:', [e['message'] for e in validation['errors']]) else: print('No adagents.json found at this domain') ``` ```bash CLI theme={null} # Validate a domain's adagents.json file curl -X POST https://adcontextprotocol.org/api/adagents/validate \ -H "Content-Type: application/json" \ -d '{"domain": "example.com"}' | jq '.data.validation' ``` The validation API fetches `https://{domain}/.well-known/adagents.json`, validates its structure, follows URL references if present, and optionally checks agent card endpoints. ### Using AdCP Client Libraries The AdCP client libraries provide built-in validation and authorization checking: ```python Python theme={null} import asyncio from adcp import fetch_adagents, verify_agent_authorization async def validate_authorization(): # Fetch and validate adagents.json from a publisher domain adagents_data = await fetch_adagents('example-publisher.com') # Check if a specific agent is authorized is_authorized = verify_agent_authorization( adagents_data=adagents_data, agent_url='https://our-sales-agent.com', property_type='website', property_identifiers=[{'type': 'domain', 'value': 'example-publisher.com'}] ) print(f"Agent authorized: {is_authorized}") print(f"Total agents: {len(adagents_data.get('authorized_agents', []))}") asyncio.run(validate_authorization()) ``` ```javascript JavaScript theme={null} // Using the @adcp/sdk PropertyCrawler for discovery import { PropertyCrawler } from '@adcp/sdk'; const crawler = new PropertyCrawler({ logLevel: 'info' }); // Crawl agents to discover their authorized properties const result = await crawler.crawlAgents([ { agent_url: 'https://our-sales-agent.com', protocol: 'a2a' } ]); console.log(`Found ${result.totalProperties} properties across ${result.totalPublisherDomains} domains`); ``` ```bash CLI theme={null} # Fetch and inspect authorization file curl https://example-publisher.com/.well-known/adagents.json | jq '.' # Check specific agent authorization curl https://example-publisher.com/.well-known/adagents.json | \ jq '.authorized_agents[] | select(.url == "https://our-sales-agent.com")' ``` The Python library handles validation automatically when fetching - if the adagents.json file is malformed or missing required fields, it raises `AdagentsValidationError`. ## Best Practices ### 1. Use Appropriate Authorization Pattern * **Property IDs**: Small, enumerable lists (\< 20 properties) * **Property Tags**: Large networks (100+ properties) * **Inline Properties**: Simple cases without top-level properties * **Publisher Properties**: Third-party agents representing multiple publishers ### 2. Cache Files Appropriately * Cache for 24 hours minimum * Use `last_updated` timestamp to detect staleness * Handle 404 as "no file" (not an error - proceed without validation) * Implement retry logic with exponential backoff for network errors ### 3. Validate Structure * Validate against JSON schema before processing * Check required fields exist (`authorized_agents` array) * Verify authorization scope matches product claims * Cross-reference with seller.json if available ### 4. Handle Missing Files Gracefully * 404 status = No file present (not an authorization failure) * Absence of file does not mean agent is unauthorized * Use adagents.json as verification, not requirement ### 5. Handle Per-Property Validation Failures Gracefully File-level failures (unparseable JSON, missing required top-level `authorized_agents`) MUST abort processing for that domain — the file is unusable. Per-property validation failures are a separate tier: a single property object in an otherwise-valid file may omit `identifiers` or another required field due to a publisher-side templating error or a partial write. Per-property validation failures MUST NOT prevent processing of remaining properties in the same file. Treat a non-conforming property as absent from the array — never as a reason to abort the run: * **Skip** the non-conforming property * **Log** a warning including the source domain, the property's index in the array, and the reason (e.g., `missing required field: identifiers`) * **Continue** processing all remaining properties in the file Aborting the full crawl on a per-property failure is a common implementation error. A single malformed property in a managed-network file that covers hundreds of publisher domains can silently zero out an entire discovery run, making this failure mode disproportionately disruptive relative to the size of the underlying data problem. This follows the same principle as IAB Tech Lab ads.txt 1.1 §3.1, which specifies that a malformed line MUST be ignored and processing of subsequent lines MUST continue. This applies to all surfaces where property objects appear: the top-level `properties` array, inline properties inside `authorized_agents[*].properties` (the `inline_properties` authorization type), and properties fetched and resolved from remote domains during `publisher_properties` resolution. ## Next Steps After implementing adagents.json validation: 1. **Integrate with Product Discovery**: Use [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) to discover inventory 2. **Validate at Purchase**: Check authorization before calling [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) 3. **Cache Property Mappings**: Store resolved properties for efficient validation 4. **Monitor Authorization**: Track validation success rates and unauthorized attempts ## Learn More * [AdCP Basics: Authorized Properties](https://bokonads.com/p/adcp-basics-authorized-properties) - Accessible introduction to AdCP authorization * [get\_adcp\_capabilities](/dist/docs/3.1.20/protocol/get_adcp_capabilities) - Discover agent capabilities and portfolio * [Property Schema](https://adcontextprotocol.org/schemas/3.1.20/core/property.json) - Property definition structure * [AdAgents.json Builder](https://agenticadvertising.org/adagents/builder) - Web-based validator and creator # Understanding Authorization Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/authorized-properties Authorized properties in AdCP let buyers restrict campaigns to verified inventory using property lists and supply path validation. One of the foundational challenges in digital advertising is **unauthorized resale** - ensuring that sales agents are actually authorized to represent the advertising properties they claim to sell. AdCP solves this problem through a comprehensive authorization system that builds on the lessons learned from ads.txt in programmatic advertising. **New to AdCP authorization?** Read [AdCP Basics: Authorized Properties](https://bokonads.com/p/adcp-basics-authorized-properties) for an accessible introduction to how authorization works in agentic advertising. ## The Problem: Unauthorized Resale ### Historical Context In programmatic advertising, the Ads.txt initiative was created to solve a critical problem: unauthorized reselling of advertising inventory. Before ads.txt, bad actors could claim to represent popular websites and sell their inventory without permission, leading to: * **Revenue theft**: Publishers lost money to unauthorized sellers * **Brand safety issues**: Buyers couldn't verify legitimate inventory sources * **Market fragmentation**: No way to distinguish authorized from unauthorized sellers ### The Same Problem in AI-Powered Advertising AdCP faces similar challenges as AI agents begin to buy and sell advertising programmatically: * **AI sales agents** may claim to represent properties they don't actually control * **Buyer agents** need to verify authorization before making purchases * **Publishers** need a way to explicitly authorize specific sales agents * **Scale challenges**: Manual verification doesn't work for networks with thousands of properties ## The Solution: AdCP Authorization System AdCP prevents unauthorized resale through a three-part system: 1. **Publisher Authorization**: Publishers explicitly authorize sales agents via `adagents.json` with `delegation_type` (`direct`, `delegated`, or `ad_network`) 2. **Operator Declaration**: Operators declare their property portfolio in `brand.json` with the `relationship` field. First-party inventory uses `owned`; delegated or network paths use values that match `delegation_type`. 3. **Agent Discovery**: Sales agents declare their portfolio via [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) in the `media_buy.portfolio` section The first two create bilateral verification — like `ads.txt` + `sellers.json` in programmatic. For delegated or network paths, both sides must agree for the supply path to be trusted. See [ad networks](/dist/docs/3.1.20/sponsored-intelligence/networks) for the full pattern. ## How Publishers Authorize Sales Agents Publishers authorize sales agents by hosting an `adagents.json` file at `/.well-known/adagents.json` on their domain. This file lists all authorized agents and their permissions. ### Example adagents.json ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "contact": { "name": "Sports Network Media", "email": "adops@sportsnetwork.com", "domain": "sportsnetwork.com" }, "properties": [ { "property_id": "sports_network_main", "property_type": "website", "name": "Sports Network", "identifiers": [ {"type": "domain", "value": "sportsnetwork.com"} ], "tags": ["premium", "sports"] } ], "authorized_agents": [ { "url": "https://sports-media-sales.com", "authorized_for": "All Sports Network properties", "authorization_type": "property_tags", "property_tags": ["sports"], "delegation_type": "direct" }, { "url": "https://premium-ad-network.com", "authorized_for": "Premium inventory only", "authorization_type": "property_tags", "property_tags": ["premium"], "delegation_type": "ad_network", "countries": ["US", "CA"] } ], "last_updated": "2025-01-10T12:00:00Z" } ``` ### Key Fields * **contact**: Identifies the publisher/entity managing this file * **properties**: Defines the properties covered by this authorization file * **authorized\_agents**: List of sales agents authorized to represent properties * **url**: Agent's API endpoint URL * **authorized\_for**: Human-readable description of authorization scope * **authorization\_type**: How properties are selected (`property_ids`, `property_tags`, `inline_properties`, `publisher_properties`) * **delegation\_type**: Whether this path is `direct`, `delegated`, or `ad_network` * **collections**: Optional collection selectors that narrow authorization to specific content programs * **placement\_ids**: Optional placement IDs from the publisher's placement registry in `adagents.json` * **countries**: Optional ISO 3166-1 alpha-2 country codes * **effective\_from / effective\_until**: Optional authorization window * **exclusive**: Whether this is the sole authorized path for the scoped inventory slice * **last\_updated**: ISO 8601 timestamp of last modification ## How Sales Agents Share Authorized Properties Sales agents use the [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) task to declare their portfolio information in the `media_buy.portfolio` section. This serves multiple purposes: 1. **Transparency**: Buyers can see what publishers an agent represents 2. **Validation enablement**: Provides publisher domains for buyers to verify authorization via `adagents.json` 3. **Portfolio overview**: Includes primary channels, countries, and portfolio description ### Property Declaration Example ```json theme={null} { "properties": [ { "property_type": "website", "name": "Sports Network", "identifiers": [ {"type": "domain", "value": "sportsnetwork.com"} ], "tags": ["sports_network", "premium"], "publisher_domain": "sportsnetwork.com" }, { "property_type": "radio", "name": "WXYZ-FM Chicago", "identifiers": [ {"type": "station_id", "value": "WXYZ-FM"}, {"type": "facility_id", "value": "fcc:21242"} ], "tags": ["local_radio", "midwest", "chicago"], "publisher_domain": "radionetwork.com" } ], "tags": { "sports_network": { "name": "Sports Network Properties", "description": "145 sports properties and networks" }, "local_radio": { "name": "Local Radio Stations", "description": "1847 local radio stations across US markets" } }, "advertising_policies": "We maintain strict brand safety standards. Prohibited categories include: tobacco and vaping products, online gambling and sports betting, cannabis and CBD products, political advertising, and speculative financial products (crypto, NFTs, penny stocks).\n\nWe also prohibit misleading tactics such as clickbait headlines, false scarcity claims, hidden pricing, and ads targeting vulnerable populations.\n\nCompetitor brands in the streaming media space are blocked by policy.\n\nFull advertising guidelines: https://publisher.com/advertising-policies" } ``` ### Property Tags for Scale For large networks representing thousands of properties, AdCP supports **property tags** to make the system manageable: * **Products** can reference `["local_radio", "midwest"]` instead of listing hundreds of stations * **Buyers** use `get_adcp_capabilities` to discover the agent's portfolio and validate authorization * **Authorization validation** works on the resolved properties via `adagents.json` ## Authorization Validation Workflow Here's how a buyer agent validates that a sales agent is authorized to represent claimed properties: ### 1. One-Time Setup ```javascript theme={null} // Get portfolio information from capabilities const capabilities = await salesAgent.call('get_adcp_capabilities'); const portfolio = capabilities.media_buy?.portfolio; const publisherDomains = portfolio?.publisher_domains || []; // For each publisher domain, fetch and cache adagents.json const authorizationCache = {}; for (const domain of publisherDomains) { try { const adagents = await fetch(`https://${domain}/.well-known/adagents.json`); authorizationCache[domain] = await adagents.json(); } catch (error) { console.warn(`Could not verify authorization for ${domain}`); authorizationCache[domain] = null; } } ``` ### 2. Product Validation ```javascript theme={null} // When evaluating a product const result = await salesAgent.call('get_products', {brief: "Chicago radio ads"}); const product = result.products[0]; // Validate authorization for each publisher in publisher_properties const authorized = product.publisher_properties.every(pubProp => { const domain = pubProp.publisher_domain; const adagents = authorizationCache[domain]; if (!adagents) return false; // No adagents.json found // Verify the sales agent is in publisher's authorized_agents return adagents.authorized_agents.some(agent => agent.url === salesAgent.url && isAuthorizedForProperties(agent, pubProp) ); }); if (!authorized) { throw new Error("Sales agent not authorized for claimed properties"); } ``` ### 3. Ongoing Validation * **Cache adagents.json** responses with reasonable TTL (e.g., 24 hours) * **Re-validate periodically** for long-running campaigns * **Handle authorization changes** gracefully (pause vs. reject) ## Benefits of This Approach ### For Publishers * **Explicit control** over who can sell their inventory * **Granular permissions** by property, collection, country, and date range * **Standard web hosting** - no special infrastructure required * **Audit trail** of authorized agents ### For Sales Agents * **Clear authorization proof** that buyers can verify * **Efficient tag-based grouping** for large property portfolios * **Standardized declaration** across all AdCP interactions ### For Buyer Agents * **Automated verification** of seller authorization * **Fraud prevention** through cryptographic verification * **Confidence in purchases** from verified inventory sources * **Scalable validation** for large-scale automated buying ## Security Considerations ### Domain Verification * **HTTPS required**: adagents.json must be served over HTTPS * **Domain ownership**: Only domain owners can authorize agents for their properties * **Regular validation**: Buyers should re-check authorization periodically ### Authorization Scope * **Least privilege**: Grant minimal necessary permissions * **Time bounds**: Use start/end dates for temporary authorizations * **Property restrictions**: Limit to specific paths or property types when appropriate ### Error Handling * **Missing adagents.json**: Treat as unauthorized (fail closed) * **Invalid JSON**: Reject malformed authorization files * **Network errors**: Implement retry logic with fallback policies * **Expired authorization**: Handle gracefully in active campaigns ## Integration with Product Discovery Authorization validation integrates seamlessly with [Product Discovery](../../media-buy/product-discovery/): 1. **Discover products** using [`get_products`](../../media-buy/task-reference/get_products) 2. **Validate authorization** for properties referenced in products 3. **Proceed confidently** with authorized inventory 4. **Flag unauthorized** products for manual review This creates a trustworthy foundation for AI-powered advertising that prevents unauthorized resale while enabling efficient, automated transactions. ## Technical Implementation For complete technical details on implementing the `adagents.json` file format, including: * File location and format requirements (`/.well-known/adagents.json`) * JSON schema definitions and validation rules * Mobile application and CTV implementation patterns * Detailed property type specifications (website, mobile app, CTV, DOOH, podcast) * Domain matching rules and wildcard patterns * Validation code examples and error handling * Security considerations and best practices See the **[adagents.json Tech Spec](./adagents)** for complete implementation guidance. ## Related Documentation * **[`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities)** - Discover agent capabilities and portfolio information * **[Product Discovery](../../media-buy/product-discovery/)** - How authorization integrates with product discovery * **[Properties Schema](https://adcontextprotocol.org/schemas/3.1.20/core/property.json)** - Technical property data model * **[adagents.json Tech Spec](./adagents)** - Complete `adagents.json` implementation guide # Property Governance Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/index AdCP Property Governance standardizes identification, authorization, enrichment, and selection of advertising properties across all media channels. Property Governance standardizes how advertising properties (websites, apps, CTV, podcasts, billboards) are identified, authorized, enriched with data, and selected for campaigns. Ships in 3.0 as the `property-lists` specialism under the `governance` protocol. Property lists and [collection lists](/dist/docs/3.1.20/governance/collection/index) together form the **inventory list** system — property lists control *where* ads run (technical surfaces), while collection lists control *what content* ads run in (programs, shows, series). Both are setup-time artifacts managed by governance agents with the same lifecycle pattern. `adagents.json` is intentionally broader than `ads.txt`: it can describe not just which sales agents are present, but which properties, placements, and delegated sales paths they are actually authorized to make available. For a side-by-side comparison, see [Why adagents.json is more expressive than ads.txt](https://agenticadvertising.org/perspectives/adagents-json-vs-ads-txt). ## Overview Property Governance addresses five distinct concerns: | Concern | Question | Owner | Mechanism | | ----------------------- | -------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------- | | **Property Identity** | What properties exist? | Publishers | `adagents.json` properties array | | **Sales Authorization** | Who can sell this property? | Publishers | `adagents.json` authorized\_agents with `delegation_type` | | **Property Data** | What do we know about this property? | Data providers | Governance agents via [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) | | **Property Selection** | Which properties meet my requirements? | Buyers | Property lists with filters | The first three are **publisher-side declarations** via adagents.json. The last two are **buyer-side operations** that consume property data from governance agents. Authorization is bilateral — publishers declare authorized agents in `adagents.json` (with `delegation_type`), and operators declare their property portfolio in `brand.json` (with `relationship`). Both sides must agree for the supply path to be verified. This is the AdCP equivalent of `ads.txt` + `sellers.json`. See [ad networks](/dist/docs/3.1.20/sponsored-intelligence/networks) for how this works. ## Publisher Side: adagents.json Publishers declare their properties, authorize sales agents, and reference governance agents via `/.well-known/adagents.json`: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "properties": [ { "property_id": "example_site", "property_type": "website", "name": "Example Site", "identifiers": [{"type": "domain", "value": "example.com"}] } ], "authorized_agents": [ { "url": "https://agent.example.com", "authorized_for": "Official sales agent", "authorization_type": "property_ids", "property_ids": ["example_site"], "delegation_type": "direct" } ], "property_features": [ { "url": "https://api.sustainability-vendor.example", "name": "Sustainability Vendor", "features": ["carbon_score", "green_media_certified"] }, { "url": "https://api.quality-vendor.example", "name": "Quality Vendor", "features": ["mfa_score", "ad_load_rating", "page_speed"] } ] } ``` ### Governance Agent Discovery via property\_features The `property_features` array solves a key discovery problem: **how does a buyer know which governance agents have data about a given property?** Without `property_features`, buyers would need to query every possible governance agent to find out who has compliance, sustainability, or quality data. With `property_features`, publishers declare these relationships upfront: | Field | Purpose | | -------------- | ------------------------------------------------------------------- | | `url` | Governance agent's API endpoint | | `name` | Human-readable agent name | | `features` | Feature IDs this agent provides (e.g., `carbon_score`, `mfa_score`) | | `publisher_id` | Optional identifier for looking up this publisher at the agent | **Example use cases:** * **Sustainability**: Publisher declares a carbon measurement vendor tracks their emissions * **Quality**: Publisher declares a verification vendor measures MFA score and ad density * **Consumer experience**: Publisher declares a vendor that tracks page speed and ad load Buyers read `property_features` from adagents.json, then query only the relevant governance agents for detailed data. See the [adagents.json Tech Spec](/dist/docs/3.1.20/governance/property/adagents) for complete documentation including examples and the discovery workflow. ## Buyer Side: Property Data and Selection ### Property Data Providers Governance agents provide data about properties - compliance scores, brand suitability ratings, sustainability metrics, consumer experience scores, etc. They advertise their capabilities via [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) in the `governance.property_features` section: ```json theme={null} { "governance": { "property_features": [ { "feature_id": "mfa_score", "type": "quantitative", "range": { "min": 0, "max": 100 } }, { "feature_id": "coppa_certified", "type": "binary" }, { "feature_id": "carbon_score", "type": "quantitative", "range": { "min": 0, "max": 100 } } ] } } ``` Buyers send property lists to these agents, and the agents filter and score the properties based on their specialized data. Different agents specialize in different data: * **Brand suitability providers** (content classification, risk scoring) * **Quality measurement** (MFA score, ad density, fraud detection) * **Sustainability providers** (carbon scoring, green media certification) * **Consumer experience** (page speed, ad load, layout shift) ### Property Selection via Governance Agents Buyers create **property lists on governance agents** - the agents manage these lists and apply their filtering logic: ```json theme={null} { "tool": "create_property_list", "arguments": { "name": "Q1 Campaign - UK Premium", "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news"] } ], "filters": { "countries_all": ["UK"], "channels_any": ["display", "video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 70, "max_value": 100 } ] }, "brand": { "domain": "toybrand.com" } } } ``` When you provide a brand reference, governance agents resolve the brand identity and automatically apply appropriate rules (COPPA for children's brands, content filtering based on industry, etc.). A buyer agent typically works with **multiple governance agents** (brand suitability, quality, sustainability) and aggregates/intersects their results into a final compliant list. ## How It Fits Together ```mermaid theme={null} flowchart TB subgraph Publisher["PUBLISHER (adagents.json)"] P1["properties: identity"] P2["authorized_agents: sales auth"] P3["property_features: governance refs"] end subgraph Buyer["BUYER AGENT"] B1[Discovers governance agents from adagents.json] B2[Aggregates results from specialized agents] B3[Issues auth_tokens for sellers] end subgraph Governance["GOVERNANCE AGENTS"] SUS["Sustainability Agent
carbon_score
green_media
climate_risk"] QA["Quality Agent
mfa_score
ad_load
page_speed"] BS["Brand Suitability Agent
content_category
brand_risk
sentiment"] end subgraph Seller["SELLER AGENT (DSP/SSP)"] SE1[Caches resolved property lists] SE2[Uses cached lists for bid-time decisions] end Publisher -->|property_features discovery| Buyer Buyer -->|create_property_list + webhooks| SUS Buyer -->|create_property_list + webhooks| QA Buyer -->|create_property_list + webhooks| BS Buyer -->|get_property_list with auth_token| Seller ``` ### The Complete Flow 1. **Publisher declares** properties, sales agents, AND governance agents in `adagents.json` 2. **Buyer discovers** governance agents by reading `property_features` from adagents.json 3. **Buyer queries** each governance agent's `get_adcp_capabilities` for detailed capabilities 4. **Buyer creates** property lists on each governance agent with filters and brand references 5. **Governance agents evaluate** properties and notify buyer via webhooks when lists change 6. **Buyer aggregates** results into a final compliant list 7. **Buyer shares** property list reference with sellers (with auth token) 8. **Seller caches** resolved list for bid-time decisions ## Sharing Property Lists with Sellers Once a buyer has a compliant property list, they share it with sellers: 1. **Get a list reference**: The buyer agent exposes the list via `get_property_list` 2. **Issue an auth token**: The buyer generates a token that authorizes access to the list 3. **Pass to seller**: Include `property_list_ref` with `auth_token` in product discovery or media buy requests 4. **Seller caches locally**: Sellers fetch and cache the resolved list for bid-time decisions 5. **Webhooks for updates**: When the list changes, sellers are notified to refresh their cache ```json theme={null} { "property_list_ref": { "agent_url": "https://buyer-agent.example.com", "list_id": "pl_q1_uk_premium", "auth_token": "eyJhbGciOiJIUzI1NiIs..." } } ``` Sellers use this reference in `get_products` to filter available inventory: ```json theme={null} { "tool": "get_products", "arguments": { "brief": "UK video inventory for Q1", "property_list_ref": { "agent_url": "https://buyer-agent.example.com", "list_id": "pl_q1_uk_premium", "auth_token": "..." } } } ``` ## Relationship to Other Protocols ### Property Governance + Media Buy The Media Buy Protocol consumes property lists at multiple stages: * **Product discovery**: Pass `property_list_ref` to `get_products` to filter inventory to compliant properties * **Media buy creation**: Reference property lists to constrain where ads can run * **Authorization**: adagents.json validates agent authority to sell ### Property Governance + Signals Both protocols operate on properties but serve different purposes: | Signals Protocol | Property Governance | | ------------------------- | -------------------------------- | | Audience/contextual data | Property metadata and compliance | | "Who should see this ad?" | "Where can this ad run?" | | Signal activation | Property filtering | ## Tasks ### Discovery * **[`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities)**: Discover governance capabilities including property features (protocol-level task) ### Property List Management * **[create\_property\_list](/dist/docs/3.1.20/governance/property/tasks/property_lists#create_property_list)**: Create a new property list on a governance agent * **[get\_property\_list](/dist/docs/3.1.20/governance/property/tasks/property_lists#get_property_list)**: Retrieve resolved properties (with caching guidance) * **[update\_property\_list](/dist/docs/3.1.20/governance/property/tasks/property_lists#update_property_list)**: Modify filters or base properties * **[delete\_property\_list](/dist/docs/3.1.20/governance/property/tasks/property_lists#delete_property_list)**: Remove a property list ## Getting Started **Publishers:** 1. Create `/.well-known/adagents.json` with property definitions 2. Authorize sales agents for your properties 3. Declare governance agents in `property_features` (sustainability vendors for carbon, quality vendors for MFA and ad load, suitability vendors for content classification, etc.) **Buyers:** 1. Discover governance agents by reading `property_features` from publishers' adagents.json files 2. Query each governance agent's `get_adcp_capabilities` for capabilities 3. Create property lists on relevant governance agents with filters and brand references 4. Aggregate results into a final compliant list 5. Share property list references with sellers (with auth tokens) **Governance Agent Implementers:** 1. Implement [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) to advertise your capabilities in `governance.property_features` 2. Implement property list CRUD operations 3. Support webhooks to notify buyers when evaluations change 4. Work with publishers to get listed in their `property_features` 5. See the [Protocol Specification](/dist/docs/3.1.20/governance/property/specification) for implementation details See the [Protocol Specification](/dist/docs/3.1.20/governance/property/specification) for detailed implementation guidance. # Managed Network Deployment Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/managed-networks How managed publisher networks deploy adagents.json across thousands of domains using the URL reference pattern, delegation types, and common infrastructure approaches. Managed ad networks (e.g. networks operating hundreds or thousands of publisher domains) already distribute `ads.txt` via HTTP redirects or centralized hosting. `adagents.json` supports the same scale through a built-in delegation model: the [URL reference pattern](/dist/docs/3.1.20/governance/property/adagents#url-reference-pattern). This guide maps your existing `ads.txt` deployment knowledge to `adagents.json` and covers the infrastructure patterns that work at network scale. ## How it compares to ads.txt distribution Both `ads.txt` and `adagents.json` require a file at a well-known path on each publisher origin. The deployment mechanics are similar, but `adagents.json` has a built-in delegation model that replaces the HTTP redirect patterns networks typically use for `ads.txt`. | Concern | `ads.txt` | `adagents.json` | | ----------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------- | | File location | `/ads.txt` | `/.well-known/adagents.json` | | Delegation mechanism | HTTP 301/302 redirect | `authoritative_location` field (in-file reference) | | What the delegation expresses | "This file lives somewhere else" | "This publisher delegates to a named authority" | | Publisher intent | Ambiguous (redirect could be infrastructure) | Explicit (pointer file is a declaration) | | Scope of authorization | Flat (`DIRECT` / `RESELLER`) | Structured (property, placement, country, time window, delegation type) | | Caching at scale | Each domain cached independently (no deduplication) | Validators cache one authoritative file for all domains that reference it | | File format | Plain text, one entry per line | JSON with schema validation | The key difference: an HTTP redirect is invisible to the consumer. A validator following a 301 cannot tell whether the redirect means "the publisher delegates to this network" or "the CDN reorganized its paths." The `authoritative_location` field makes delegation an explicit publisher declaration. ## The pointer file pattern Each managed domain hosts a minimal pointer file at `/.well-known/adagents.json`. The pointer references one centralized authoritative file that the network maintains. **Pointer file** (on each domain): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "authoritative_location": "https://network.example.com/adagents/v2/adagents.json", "last_updated": "2025-06-01T00:00:00Z" } ``` The `last_updated` timestamp in the pointer file reflects when the pointer itself was last modified (e.g., when the `authoritative_location` URL changed), not when the authoritative file was updated. The authoritative file carries its own `last_updated`. **Authoritative file** (at the network): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "contact": { "name": "Example Network Ad Operations", "email": "adops@network.example.com", "domain": "network.example.com" }, "properties": [ { "property_id": "site_cooking_daily", "property_type": "website", "name": "Cooking Daily", "identifiers": [{"type": "domain", "value": "cookingdaily.com"}], "tags": ["food", "managed_network"], "publisher_domain": "cookingdaily.com" }, { "property_id": "site_garden_weekly", "property_type": "website", "name": "Garden Weekly", "identifiers": [{"type": "domain", "value": "gardenweekly.com"}], "tags": ["home", "managed_network"], "publisher_domain": "gardenweekly.com" } ], "tags": { "managed_network": { "name": "Managed Network", "description": "All domains managed by Example Network" } }, "authorized_agents": [ { "url": "https://sales.network.example.com", "authorized_for": "All managed network properties", "authorization_type": "property_tags", "property_tags": ["managed_network"], "delegation_type": "ad_network" } ], "last_updated": "2025-06-01T00:00:00Z" } ``` ### How validators resolve pointer files ```mermaid theme={null} sequenceDiagram participant V as Validator participant P as cookingdaily.com participant N as network.example.com V->>P: GET /.well-known/adagents.json P-->>V: {"authoritative_location": "https://network.example.com/..."} Note over V: Detect pointer — single hop allowed V->>N: GET /adagents/v2/adagents.json N-->>V: Full adagents.json (properties, agents, placements) Note over V: Verify: no nested authoritative_location Note over V: Validate against schema ``` The validator fetches the pointer file, follows the `authoritative_location` URL, and validates the authoritative file as a normal inline structure. **Single hop only.** The authoritative file must not itself contain an `authoritative_location`. This prevents redirect chains and infinite loops. ### One authoritative file vs. per-publisher files The example above shows every domain pointing to the same authoritative file. This works when all publishers share the same agents, delegation types, and placement structure. Per-publisher authoritative files make sense when arrangements differ across the network: * Different publishers authorize different agents (some have their own direct sales alongside the network) * Different delegation types (Publisher A is `ad_network` only, Publisher B retains a `direct` path for premium placements) * Different placement structures (one publisher has `pre_roll` and `host_read`, another only has `display_banner`) * Different governance vendors in `property_features` In this model, each pointer file references a publisher-specific URL: ``` cookingdaily.com/.well-known/adagents.json → "authoritative_location": "https://network.example.com/adagents/cookingdaily.json" gardenweekly.com/.well-known/adagents.json → "authoritative_location": "https://network.example.com/adagents/gardenweekly.json" ``` The network still hosts all the authoritative files centrally — the pointer files just reference different paths. The [CI/CD pipeline](#cicd-pipeline) pattern is a natural fit: generate per-publisher authoritative files from a central database and deploy them to the network's CDN. Start with one shared file. Move to per-publisher files as publishers negotiate individual arrangements. ### Why not HTTP redirects? HTTP redirects work for `ads.txt` because `ads.txt` is a flat list with no self-referential semantics. Crawlers follow the redirect chain and validate the final file. For `adagents.json`, HTTP redirects cause problems: * **Ambiguous intent.** A redirect could mean delegation, infrastructure migration, or CDN routing. The pointer file explicitly declares delegation. * **No scoping.** An HTTP redirect is all-or-nothing. A pointer file sits alongside a structured authorization model where the network can declare exactly what it is authorized to sell. * **Caching penalty.** With HTTP redirects, a validator has no way to know that 10,000 domains all redirect to the same file. It must treat each response as independent — 10,000 cache entries for identical content. With `authoritative_location`, the validator sees the same URL across all pointer files and caches the authoritative file once. For a network with thousands of domains, this is the difference between one cache entry and thousands. Those problems are about using a redirect to express **delegation** — a cross-domain hop that says "my authorization lives somewhere else." That is what `authoritative_location` replaces. They are not about **hosting normalization**, which is the ordinary case where a publisher's apex domain `301`-redirects to `www` (the default on most managed hosting and CDNs). The two are distinguished by where the redirect points, so the fetch rule is split accordingly: * **Same-registrable-domain redirects MUST be followed** on the `/.well-known/adagents.json` fetch — `apex ↔ www`, and any redirect that stays on the same registrable domain (eTLD+1) — re-validating the [SSRF controls](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-url-validation-ssrf) on every hop, refusing any redirect to a non-HTTPS scheme, and capping the chain at 3 hops. Refusing these silently reports a correctly-configured publisher as unauthorized. The same-registrable-domain comparison MUST be anchored on the **originally-requested** domain at every hop, not the previous hop, so a `same → cross` two-hop chain cannot escape it. * **Cross-registrable-domain redirects MUST be refused** on this fetch. A cross-domain redirect is the ambiguous, unscoped delegation signal this pattern exists to replace — declare delegation explicitly with `authoritative_location` instead. The validator dereferences `authoritative_location` as a second hop with redirects **refused** (see [Security considerations](#security-considerations)): the named URL is the declared authoritative location, and a redirect away from it changes that declaration. ## Choosing the right delegation type When a network authorizes agents on behalf of managed publishers, the `delegation_type` field describes the commercial relationship: | `delegation_type` | Use when | Example | | ----------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------ | | `direct` | Publisher treats this as their own sales channel, even though the network operates it | A white-label sales agent branded as the publisher | | `delegated` | Publisher authorizes the network to sell on their behalf | A rep firm with explicit publisher agreements | | `ad_network` | Inventory is sold through the network's package, not as the publisher's endpoint | Mediavine-style managed network selling across its portfolio | Most managed networks will use `ad_network`. Use `delegated` when individual publishers maintain their own commercial identity but authorize the network to represent them. Use `direct` only when the network operates what publishers present as their own sales infrastructure. A single authoritative file can mix delegation types — different agents can have different relationships with the same inventory: ```json theme={null} { "authorized_agents": [ { "url": "https://sales.network.example.com", "authorized_for": "Network-sold inventory across all managed properties", "authorization_type": "property_tags", "property_tags": ["managed_network"], "delegation_type": "ad_network" }, { "url": "https://premium.publisher.example.com", "authorized_for": "Publisher's direct premium sales", "authorization_type": "property_ids", "property_ids": ["site_cooking_daily"], "delegation_type": "direct", "placement_tags": ["premium"], "exclusive": true } ] } ``` ## Keeping the file efficient with property tags A managed network with 500 properties and three authorized agents could list every property ID in every agent entry — but that means maintaining 1,500 property-to-agent mappings. Property tags eliminate that redundancy. The principle: **list each property once** with its identifier and tags, then **authorize agents by tag**. ```json theme={null} { "properties": [ { "property_id": "site_cooking_daily", "property_type": "website", "name": "Cooking Daily", "identifiers": [{"type": "domain", "value": "cookingdaily.com"}], "tags": ["managed_network", "food"], "publisher_domain": "cookingdaily.com" }, { "property_id": "site_garden_weekly", "property_type": "website", "name": "Garden Weekly", "identifiers": [{"type": "domain", "value": "gardenweekly.com"}], "tags": ["managed_network", "home"], "publisher_domain": "gardenweekly.com" } ], "tags": { "managed_network": { "name": "Managed Network", "description": "All domains managed by Example Network" }, "food": { "name": "Food & Cooking", "description": "Food and cooking content verticals" }, "home": { "name": "Home & Garden", "description": "Home and garden content verticals" } }, "authorized_agents": [ { "url": "https://sales.network.example.com", "authorized_for": "All managed network properties", "authorization_type": "property_tags", "property_tags": ["managed_network"], "delegation_type": "ad_network" }, { "url": "https://food-vertical-agent.example.com", "authorized_for": "Food vertical properties only", "authorization_type": "property_tags", "property_tags": ["food"], "delegation_type": "delegated" } ] } ``` Each property appears once. Tags handle the mapping. When a new domain joins the network, add it to `properties` with the right tags — no authorization entries need to change. When a new vertical agent comes on, add one agent entry with the relevant tag. This keeps the file readable, maintainable, and compact even at thousands of properties. ## Representing many publishers under a single authorization The pattern above is for a single publisher's `adagents.json` listing its own properties. Managed networks that represent many *other* publishers (WordPress networks, content-recommendation networks, multi-property holding-company configurations) host one authoritative file that declares which agents are authorized to sell on each represented publisher's behalf. When every represented publisher delegates the same agent under the same tag predicate — the canonical managed-network shape — use the compact `publisher_domains[]` form of `publisher_properties`: ```json theme={null} { "authorized_agents": [{ "url": "https://agent.network.example/api", "authorized_for": "Managed-network inventory across represented publishers", "authorization_type": "publisher_properties", "publisher_properties": [{ "publisher_domains": ["site1.example", "site2.example", "site3.example"], "selection_type": "by_tag", "property_tags": ["managed_network"] }], "delegation_type": "ad_network" }] } ``` One entry per shared selector predicate, not per publisher. The above is semantically identical to repeating the singular-form entry once per listed domain. See [Pattern 4 in the adagents.json reference](/dist/docs/3.1.20/governance/property/adagents#pattern-4-publisher-property-references) for the full mechanics, the singular-vs-compact contract, and the interaction with the `managerdomain` ads.txt fallback. The compact form scales linearly with the *number of distinct selector predicates* — typically a small constant — instead of with the number of represented publishers. ## Controlling what each agent can sell with placements Unlike `ads.txt`, where every authorized seller appears to have access to everything, `adagents.json` lets networks declare exactly which placements each agent is authorized to sell. This preserves sales leverage — buyers can see that premium inventory is only available through specific paths. Define placements once at the top level, tag them for grouping, then scope each agent's authorization to specific placement tags: ```json theme={null} { "placement_tags": { "programmatic": { "name": "Programmatic", "description": "Placements available through programmatic sales paths" }, "direct_only": { "name": "Direct only", "description": "Premium placements reserved for direct network sales" } }, "placements": [ { "placement_id": "bottom_native_feed", "name": "Bottom-of-page native feed", "tags": ["programmatic"], "property_tags": ["managed_network"] }, { "placement_id": "page_takeover", "name": "Full-page takeover", "tags": ["direct_only", "premium"], "property_tags": ["managed_network"] }, { "placement_id": "sidebar_display", "name": "Sidebar display", "tags": ["programmatic"], "property_tags": ["managed_network"] } ], "authorized_agents": [ { "url": "https://taboola.com/agent", "authorized_for": "Bottom-of-page native feed across all managed properties", "authorization_type": "property_tags", "property_tags": ["managed_network"], "placement_tags": ["programmatic"], "delegation_type": "ad_network" }, { "url": "https://sales.network.example.com", "authorized_for": "Premium direct-sold placements", "authorization_type": "property_tags", "property_tags": ["managed_network"], "placement_tags": ["direct_only"], "delegation_type": "direct", "exclusive": true } ] } ``` In this example, Taboola can only sell programmatic placements (the bottom-of-page native feed and sidebar). The network's own sales team has exclusive access to page takeovers. A buyer agent reading this file knows exactly which paths lead to which inventory — there is no ambiguity about who can sell what. ## Additional authorization qualifiers Beyond property tags and placement tags, agents can be scoped with: * **`countries`** — restrict by geography (e.g. `["US", "CA"]`) * **`effective_from` / `effective_until`** — time-bounded authorization for seasonal or trial arrangements * **`exclusive`** — declare whether this is the sole authorized path for the scoped inventory ## What publishers are authorizing When a publisher's domain hosts a pointer file, they are declaring that the authoritative file speaks for them. This means: * The agents listed in the authoritative file are authorized to sell the publisher's inventory * The `delegation_type` on each agent entry describes the commercial relationship * Qualifiers (`placement_tags`, `countries`, `exclusive`, etc.) scope what each agent can sell If the network operates the domain infrastructure, the publisher has typically already consented to this through their network agreement. But the pointer file is the machine-readable declaration of that consent. If a publisher leaves the network, removing or replacing the pointer file revokes authorization immediately. ## Deployment patterns All of these patterns accomplish the same thing: serve a static JSON pointer file at `/.well-known/adagents.json` on each managed domain. Choose based on your existing infrastructure. ### CDN edge function Serve the pointer file from a CDN worker or edge function. This is the most common pattern for networks that already manage DNS and CDN for their publishers. **Cloudflare Worker:** ```javascript theme={null} export default { async fetch(request) { const url = new URL(request.url); if (url.pathname === '/.well-known/adagents.json') { return new Response(JSON.stringify({ "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "authoritative_location": "https://network.example.com/adagents/v2/adagents.json", "last_updated": "2025-06-01T00:00:00Z" }), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' } }); } return fetch(request); } }; ``` **AWS CloudFront function:** ```javascript theme={null} function handler(event) { if (event.request.uri === '/.well-known/adagents.json') { return { statusCode: 200, statusDescription: 'OK', headers: { 'content-type': { value: 'application/json' }, 'cache-control': { value: 'public, max-age=86400' }, 'access-control-allow-origin': { value: '*' } }, body: JSON.stringify({ "$schema": "https://adcontextprotocol.org/schemas/3.1.20/adagents.json", "authoritative_location": "https://network.example.com/adagents/v2/adagents.json", "last_updated": "2025-06-01T00:00:00Z" }) }; } return event.request; } ``` ### CMS plugin For networks managing WordPress or similar CMS installs, a plugin can serve the pointer file without touching server configuration. **WordPress (mu-plugin):** ```php theme={null} 'https://adcontextprotocol.org/schemas/3.1.20/adagents.json', 'authoritative_location' => 'https://network.example.com/adagents/v2/adagents.json', 'last_updated' => '2025-06-01T00:00:00Z', ]); exit; } }); ``` Drop this in `wp-content/mu-plugins/` across managed installs. Must-use plugins load automatically without activation. ### CI/CD pipeline Generate pointer files from a central configuration and deploy them as static assets alongside each site. **GitHub Actions example:** ```yaml theme={null} name: Deploy adagents.json pointer files on: push: branches: [main] paths: ['config/managed-domains.json'] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Generate pointer files run: | TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") for domain in $(jq -r '.domains[]' config/managed-domains.json); do mkdir -p "dist/${domain}/.well-known" cat > "dist/${domain}/.well-known/adagents.json" < report.json - name: Check for failures run: | ISSUES=$(jq '.orphanedPointers + .stalePointers + .missingPointers + .schemaErrors | length' report.json) if [ "$ISSUES" -gt 0 ]; then echo "::error::Network consistency check found $ISSUES issues" jq '.' report.json exit 1 fi ``` ## Troubleshooting Five failure modes that occur in managed network deployments, how to detect them, and how to fix them. ### Orphaned pointer **What happened:** A publisher domain has a pointer file referencing your authoritative URL, but the authoritative file doesn't list that domain in its `properties`. **How it looks:** A buyer agent fetches `cookingdaily.com/.well-known/adagents.json`, follows the pointer to the network's authoritative file, and finds no property with `publisher_domain: "cookingdaily.com"`. The domain appears to delegate to a network that doesn't claim it. **Common cause:** The network removed the publisher from the authoritative file (e.g., contract ended) but the pointer file on the domain was not removed. **Fix:** Either re-add the property to the authoritative file, or remove/replace the pointer file on the publisher's domain. If the network no longer manages the domain's DNS or CDN, coordinate with the publisher to remove the pointer. **Detection:** `npx adcp check-network --url ` reports these as orphaned pointers. ### Stale pointer **What happened:** A publisher's pointer file still references the network's authoritative URL after the relationship ended. Similar to an orphaned pointer, but from the publisher's perspective — the domain still claims delegation to a network that no longer authorizes it. **Common cause:** Network terminated the publisher but doesn't control the domain's infrastructure. The publisher hasn't updated their well-known path. **Fix:** The publisher must update or remove their pointer file. The network should notify the publisher when removing them from the authoritative file. The AAO registry detects this mismatch during crawls and surfaces it in network health monitoring. ### Missing pointer **What happened:** A domain is listed in the authoritative file's `properties` (via `publisher_domain`), but `/.well-known/adagents.json` on that domain either doesn't exist or doesn't point to the expected authoritative URL. **How it looks:** The network claims to represent the domain, but the domain doesn't confirm delegation. Buyer agents cannot verify the authorization chain. **Common cause:** Publisher recently joined the network but the pointer file hasn't been deployed yet, or the deployment failed. **Fix:** Deploy the pointer file to the domain using one of the [deployment patterns](#deployment-patterns) above. Verify with: ```bash theme={null} curl -s https://newpublisher.com/.well-known/adagents.json | jq '.authoritative_location' ``` ### Schema errors **What happened:** The authoritative file has validation errors — malformed JSON, missing required fields, invalid field values. **Impact:** One bad deploy breaks validation for every domain in the network, since they all reference the same file. **Fix:** Validate the authoritative file before deploying: ```bash theme={null} # Validate against the JSON schema npx adcp check-network --url https://network.example.com/adagents/v2/adagents.json ``` Use the [AdAgents.json Builder](https://agenticadvertising.org/adagents/builder) for interactive validation during development. Add schema validation to your CI/CD pipeline as a pre-deploy check. ### Agent endpoint unreachable **What happened:** An `authorized_agents` entry's URL doesn't respond or returns errors. Buyer agents cannot reach the sales agent declared in the authorization. **Common cause:** Agent service is down, URL changed, or DNS is misconfigured. **Fix:** Verify the agent endpoint is reachable and returns a valid agent card: ```bash theme={null} # A2A agent curl -s https://sales.network.example.com/.well-known/agent-card.json | jq . # Check via AdCP client npx adcp check-network --url ``` The `check-network` command validates all agent endpoints and reports response times, so you can catch slow or failing agents before buyers do. ## Security considerations One deploy to the authoritative file changes authorization across every publisher in the network. That scale is the point, and it's also the blast radius — a compromised network CDN can authorize a malicious sales agent across thousands of domains simultaneously. Two concrete implications that go beyond schema correctness: **Validator fetch semantics.** The authoritative URL points at a network-controlled origin. Without explicit fetch rules, a misbehaving origin can poison caches or hang validators. Validators MUST: * Connect only over HTTPS with valid certificates, and refuse to follow redirects (a redirect changes the declared location — treat as an error). * Cap response size with a two-tier policy: pointer files served at `/.well-known/adagents.json` (whether inline or carrying `authoritative_location`) keep the [general SSRF body cap](/dist/docs/3.1.20/building/by-layer/L1/security#webhook-url-validation-ssrf) of 5 MB — a per-publisher pointer file should be tiny, and 5 MB catches misconfiguration. Authoritative files reached by dereferencing `authoritative_location` (the second hop) use a higher recommended cap of 20 MB, because that origin has explicitly opted in to fanning out across a publisher network and routinely needs to enumerate thousands of properties or publisher domains. Enforce short connect/read timeouts (≤ 10s each) on both hops. * On 5xx or timeout, serve the previously cached authoritative file for up to 24 hours rather than failing closed. A transient CDN outage is not a revocation. * Attempt a refresh at least every 24 hours. Repeated 5xx responses MUST NOT extend the cache — the 7-day absolute cap is measured from the most recent successful fetch, not from the most recent response of any kind. * Cap cached lifetime at 7 days from the most recent successful fetch, regardless of the origin's `Cache-Control`. After that, fail closed — the network has had seven days to fix its origin. * Treat a non-monotonic `last_updated` (the refreshed file's timestamp is older than the cached file's) as an invalid response, equivalent to a 5xx: serve the cache, alert, do not adopt the older file. This blocks rollback attacks where an attacker re-serves a stale file to reinstate a previously revoked agent. **Incremental refresh (conditional requests).** A 20 MB authoritative file refreshed naïvely on every consumer is expensive on both sides and turns every publisher churn into a full re-walk. Authoritative-file origins SHOULD support HTTP conditional requests, and validators SHOULD use them: * Origins SHOULD emit `ETag` and `Last-Modified` headers on the authoritative file, regenerated whenever the file content changes. * Validators SHOULD send `If-None-Match` (preferred) or `If-Modified-Since` on every refresh and treat a `304 Not Modified` as "cache stays valid, restart the 7-day clock from this success" — same effect as a successful body fetch for cache-lifetime purposes. * The per-`authorized_agents[]` optional `last_updated` field (see `adagents.json` schema) gives consumers a second axis for partial-walk indexing: a validator that already has an index keyed by the file-level `last_updated` can skip authorized-agents entries whose own `last_updated` is older than the indexed value. This is advisory — consumers MAY ignore it and re-index the full file. Conditional requests are the partial-update protocol for managed networks at scale. Without them, a network with 3,000 publishers churning weekly forces every buyer-side validator to download 20 MB per refresh per buyer. With them, the steady-state cost is one round-trip and a 304. ### Publisher revocation (the exit lifecycle) Removing a publisher domain from `publisher_properties[].publisher_domains[]` alone is not enough — cached authoritative files at downstream validators will keep authorizing the departed publisher for up to the 7-day cache cap. For revocations that need to propagate on the next refresh (a publisher leaving a network in dispute, a compliance issue, a misconfiguration cleanup), the authoritative file MUST list the publisher in the top-level `revoked_publisher_domains[]` array with a `revoked_at` timestamp. Validator behavior: * Validators MUST treat any publisher domain in `revoked_publisher_domains[]` as no-longer-authorized, **regardless** of whether the same domain still appears in any `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]`, in `authorized_agents[].properties[].publisher_domain` (`inline_properties` authorization type), or in top-level `properties[].publisher_domain` elsewhere in the file. The revocation list takes precedence — this lets a network ship a revocation without redeploying every selector entry. * Validators with a cached prior version of the file MUST apply the revocation as of the validator's `last_updated` adoption time, not the entry's `revoked_at` (which can be in the past for catch-up entries). * **Append-only durability on the validator side.** A `publisher_domain` that a validator has ever observed in a `revoked_publisher_domains[]` entry MUST be held as revoked for **7 days from the earliest `revoked_at` the validator has ever observed for that domain**, even if the entry is missing from a subsequent fetch. Durability keys on `publisher_domain` alone — keying on the `(publisher_domain, revoked_at)` tuple would let an attacker re-emit the same domain with a slightly mutated `revoked_at` (one second earlier, say) and present a "fresh" tuple the validator has never observed, bypassing the hold. Use `revoked_at` only to set the clock origin, not to identify the durability key. This places durability on the validator's cached state rather than on the network's retention SHOULD, and closes the rollback gap: an attacker who re-serves a stale file with `revoked_publisher_domains[]` removed and `last_updated` advanced cannot reinstate a previously-revoked publisher inside the validator's 7-day window. New revocations seen for the first time in the current fetch (no prior observation) start the 7-day clock from now. * **Persistence across restart.** Validators SHOULD persist observed revocation entries (`{publisher_domain, earliest_revoked_at, first_observed_at}`) to durable storage. An in-memory-only validator that restarts inside the 7-day window MAY accept a rolled-back file because it has no record of the prior observation; operators SHOULD treat this as a known limitation and either persist the index or accept the residual risk. The 7-day window is measured from the *validator's* first observation, not from process start. * The network SHOULD retain each `revoked_publisher_domains[]` entry for at least 7 days after `revoked_at` so validators that didn't observe the entry during its first appearance still pick it up on the next refresh. For multi-publisher exits or routine churn under a normal commercial relationship-end, networks SHOULD use `reason: "relationship_ended"` so validator change-detection can suppress alerts on routine revocations and route the rest for review. **Re-authorizing a previously-revoked publisher.** There is no `revoked_until` field and no un-revoke verb. To re-authorize, the network removes the entry from `revoked_publisher_domains[]` *after* the validator-side 7-day durability window has elapsed since `revoked_at`. Removing the entry sooner is a no-op on validators that observed the original revocation (they hold the revocation locally for the remainder of the window). For time-bounded compliance pulls where same-week reinstatement is operationally required, prefer running the revocation as a `reason: "compliance_violation"` and coordinating reinstatement out of band; the schema deliberately does not give the network a re-authorize-before-7-days back door, which would be the same surface as a rollback attack. **Extension fields on revocation entries have no normative effect.** `revoked_publisher_domains[]` items use the project-wide `additionalProperties: true` policy, but validators MUST ignore unknown fields on these entries — extensions cannot loosen revocation semantics or carry side-channel reinstatement signals. **Propagation latency.** Validators that maintain an in-memory authorization index keyed by `(agent, publisher_domain)` MUST NOT continue to authorize a pair more than one crawl interval after the validator has *successfully* refetched an `adagents.json` that lists the publisher in `revoked_publisher_domains[]`. Cache-bounded staleness from the fetch semantics above (24-hour fallback on 5xx, 7-day absolute cap) applies — the bound holds against the most recent successful fetch, not against any arbitrary wall-clock time. Crawl backlog and intermediate CDN caching extend the practical latency; the requirement is on the validator's own pipeline, not on the network. Validators SHOULD also apply revocation **synchronously during manifest ingest**, not only on the next crawl cycle: when adopting any `adagents.json` whose `revoked_publisher_domains[]` lists *any* publisher domain — regardless of whether the validator currently authorizes any agent for it — the validator SHOULD remove every `(agent, publisher_domain)` entry it holds for that publisher from the in-memory index before serving the next authorization decision. This composes with, does not replace, the append-only durability rule above: the in-memory index update is what keeps live queries fresh; the 7-day hold is what survives a rollback attack. **Reference implementation (non-normative).** Other validators MAY satisfy the requirements above through different mechanisms. The chain in this repo is: (a) the writer's revocation branch retires the matching catalog rows; (b) the next crawl pass re-snapshots the `(agent, publisher_domain)` set from the catalog (excluding retired rows); (c) the pre-vs-post diff emits an `authorization.revoked` event for every dropped pair; (d) the registry-sync consumer applies the event to the in-memory index. The `authorization.revoked` event shape is reference-implementation vocabulary, not a normative wire format. **Change detection.** Because one deploy affects every publisher, buyer agents and validators SHOULD store the previous authoritative file and diff on each refresh, alerting on outlier changes. Concrete default thresholds that partners can implement on day one: any newly added `authorized_agents` entry that was not present in the previous fetch, any delegation-type downgrade (for example, an `exclusive` entry becoming non-exclusive), any property-count decrease greater than 10% or 50 absolute properties, and any change to `authoritative_location` itself. Tune from there — the goal is to catch a compromised deploy before it routes spend, not to suppress routine updates. **Pointer integrity (the per-publisher swap threat).** The *Validator fetch semantics* and *Change detection* rules above defend against compromise of the network-side authoritative file and origin. They do not defend against compromise of the *pointer file itself* at a single publisher's edge. An attacker who gains write access to one publisher's `/.well-known/adagents.json` — via that publisher's CDN control plane, origin storage, or DNS — can silently change the `authoritative_location` to an attacker-controlled URL. TLS on that URL is valid because the attacker is serving from infrastructure the publisher's domain resolves to, the size/redirect/timeout caps do not trigger, and the change reads to a validator as a legitimate delegation handoff. This last property is what makes pointer-swap distinct from a generic integrity failure: the whole point of the `authoritative_location` pattern is that publishers are *allowed* to change where they delegate, so the validator cannot treat any pointer change as adversarial without breaking legitimate delegation handoffs. The network-CDN threat is wide-and-shallow (one compromise, every publisher hijacked); the pointer-swap threat is narrow-and-deep (one publisher hijacked, but through a surface the network cannot monitor). Both are in scope. Validators MUST treat a changed `authoritative_location` as a high-severity event, not a routine refresh. Concretely: * Validators MUST NOT auto-adopt a changed `authoritative_location`. Continue serving the previously cached authoritative file (subject to the 7-day cap above) while the change is under confirmation. This is the minimum normative floor; the SHOULDs below specify how confirmation is obtained. * Validators SHOULD honor the new location only after either (a) out-of-band confirmation — operator acknowledgement, a publisher support-channel notice, or an announced network transition — or (b) a minimum stability grace window of 24 hours during which the new pointer value must remain unchanged. The 24 h window is a fallback for the unconfirmed path; an out-of-band confirmation completed in minutes satisfies (a) and is compliant — validators MUST NOT impose a 24 h floor on the OOB path. * "Announced network transition" in (a) means a publisher-attested or network-attested statement the validator operator can verify (e.g., a signed announcement countersigned by an existing trusted key, an operator-verified update to the publisher's `brand.json` `agents[]` set, or a notice on an established publisher-identity channel the operator already trusts for that publisher). A blog post or press release by itself does not qualify; the bar is verifiability, not publicity. * Validators SHOULD cross-check the candidate authoritative file against the publisher's `/.well-known/brand.json` when one is published. If `brand.json` declares `agents[]`, the candidate authoritative file's `authorized_agents[]` URLs SHOULD reconcile with the `brand.json` agent set. An authoritative file that authorizes sales agents absent from the publisher's own identity declaration is a strong signal of pointer compromise and SHOULD block adoption pending operator review. During a legitimate inter-network migration the `brand.json` `agents[]` set can lag the pointer change; when `brand.json`'s `last_updated` is older than the pointer file's `last_updated`, treat a `brand.json`/authoritative mismatch as *stale cross-check* rather than *authoritative contradiction*, and fall back to path (a) or (b) above to confirm the migration. * Refuse adoption on mixed signals: a pointer change coincident with a `last_updated` regression on the candidate authoritative file, a domain-wide delegation-type downgrade, or a first-seen sales agent with no prior ecosystem history is grounds to hold the cache and alert rather than to adopt. For this rule, *regression* means the candidate file's `last_updated` is strictly earlier than the cached file's `last_updated` by more than a small clock-skew tolerance (recommended: 60 seconds); pointer files served from multiple edges can observe minor non-monotonicity under normal operation, and the regression check is for rollback attacks, not clock jitter. Publishers managing their own pointer file SHOULD serve it from the same infrastructure and change-management controls as other publisher-identity surfaces (`/.well-known/brand.json`, DNS records, TLS certificate issuance). A pointer file is an identity declaration; treating it as a static marketing asset is the misconfiguration that makes the swap threat practical. **Relationship termination.** The pointer-file pattern relies on the network controlling publisher DNS or edge. When the relationship ends, both sides of the delegation must come down together: * The network MUST remove the publisher from the authoritative file's `properties` at termination, even when it has already lost DNS/edge control. A publisher still pointing at an ex-network's file with no matching property becomes an [orphaned pointer](#orphaned-pointer) — visible to buyers as unauthorized. * Validators SHOULD re-fetch and re-validate when a publisher domain transfers ownership, rather than relying on cached delegation. **Signed pointers (planned).** A full close of the pointer-swap gap requires a signed-pointer mechanism: the pointer file carries a publisher-controlled detached signature over the canonical `(authoritative_location, last_updated)` object, with the public key anchored out-of-band — publisher-attested in `brand.json`, or via the future centralized publisher-key registry. The signing primitive and key-discovery / rotation model require an agreed design and are tracked as a planned AdCP 4.0 addition, not a 3.x requirement. To keep the 4.0 rollout viable, implementers publishing pointer files today SHOULD keep the pointer object shape stable: the top-level object SHOULD contain only `authoritative_location` and `last_updated`, with no additional top-level fields, so a detached signature can later be carried in a sibling field (or a `.sig` companion path) without colliding with custom fields added in the interim. Until 4.0 lands, the operator-side controls above are the normative baseline — they do not match the strength of a signed pointer, but they raise the cost of pointer-swap attacks above the cost of a routine CDN compromise, which is what 3.x can promise honestly. ## Next steps * [adagents.json Tech Spec](/dist/docs/3.1.20/governance/property/adagents) — full schema reference, authorization patterns, and validation behavior * [Property Governance overview](/dist/docs/3.1.20/governance/property) — how adagents.json fits into the broader governance model * [AdAgents.json Builder](https://agenticadvertising.org/adagents/builder) — interactive validator and file creator * [@adcp/sdk](https://github.com/adcontextprotocol/adcp-client) — TypeScript client library with network consistency checking # Property Governance Specification Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/specification Formal specification for AdCP property governance — property models, feature evaluation, list management, and delivery validation. **Status**: Stable (shipped in AdCP 3.0 as the `property-lists` specialism under the `governance` protocol) **Last Updated**: April 2026 The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). ## Abstract The Property Protocol defines a standard Model Context Protocol (MCP) and Agent-to-Agent (A2A) interface for property identity, authorization, data provision, and selection. This protocol enables publishers to declare properties and authorized agents, data providers to offer property intelligence, and buyers to select compliant property sets. ## Overview The Property Protocol addresses four distinct concerns: | Concern | Question | Owner | Mechanism | | ----------------------- | -------------------------------------- | -------------- | --------------------------------------------- | | **Property Identity** | What properties exist? | Publishers | `adagents.json` properties array | | **Sales Authorization** | Who can sell this property? | Publishers | `adagents.json` authorized\_agents | | **Property Data** | What do we know about this property? | Data providers | Governance agents via `get_adcp_capabilities` | | **Property Selection** | Which properties meet my requirements? | Buyers | Property lists with filters | The first two are **publisher-side declarations** via adagents.json. The last two are **buyer-side operations** that consume property data from governance agents. ### Property Data and Selection Property data and selection use a **stateful** model: * **Feature discovery**: Agents advertise what they can evaluate via `get_adcp_capabilities` * **Property list management**: CRUD operations for managed property lists with filters * **Brand references**: Let agents automatically apply rules based on brand identity * **Webhook notifications**: Real-time updates when resolved lists change * **Marketplace architecture**: Multiple specialized agents as subscription services All evaluation (scoring, filtering, discovery) happens implicitly when property lists are resolved via `get_property_list`. ## Core Concepts ### Request Roles and Relationships Every governance request involves two key roles: #### Orchestrator (Buyer Agent) The platform or system making the API request to the governance agent. In AdCP documentation, this role is often called a "buyer agent" when operating in the media buying context. * **Examples**: DSP, trading desk platform, campaign management tool * **Responsibilities**: Makes API calls, handles authentication, manages the technical interaction * **Account**: Has technical credentials and API access to the governance agent #### Account The billing and policy entity on whose behalf the request is being made, identified via `account` (`account_id` or the `{brand, operator}` natural key): * **Examples**: Advertiser (Nike), agency (Omnicom), brand team * **Responsibilities**: Owns the campaign objectives and policy requirements * **Policies**: May have custom thresholds, blocklists, or compliance requirements ### Property Identification Properties are identified using the standard AdCP property model: ```json theme={null} { "property_type": "website", "name": "Example News", "identifiers": [ { "type": "domain", "value": "example.com" } ], "supported_channels": ["display", "olv"] } ``` Property types include: `website`, `mobile_app`, `ctv_app`, `desktop_app`, `dooh`, `podcast`, `radio`, `linear_tv`, `streaming_audio`, `ai_assistant`. Properties may also declare `supported_channels` to indicate which advertising channels their inventory aligns with. ### Property List References For large property sets, use property list references instead of embedding properties: ```json theme={null} { "property_list_ref": { "agent_url": "https://lists.example.com", "list_id": "premium_news_sites", "auth_token": "eyJhbGciOiJIUzI1NiIs..." } } ``` The receiving agent fetches and caches the list independently, enabling: * **Scale**: Pass 50,000+ properties without payload bloat * **Updates**: Lists evolve without changing requests * **Authorization**: Token controls access to the list ### Governance Agent Types #### Compliance Agents Specialized vendors providing property compliance intelligence: * **Examples**: Data integrity scoring, consent quality measurement * **Business Model**: Subscription or per-query pricing * **Methodology**: Published rubrics for transparency #### Brand Safety Agents Content classification and risk assessment: * **Examples**: Content categorization, brand safety scoring * **Coverage**: May specialize by channel or geography #### Quality Agents Performance and fraud measurement: * **Examples**: Viewability prediction, IVT detection * **Integration**: May correlate with campaign outcomes ### Scoring and Data Privacy #### Scores Are Internal **Critical design principle**: Raw scores are NOT shared with buyers or downstream clients. This prevents data leakage. Governance agents maintain internal scoring models, but the protocol is designed around **list management**, not score exposure: * Buyers specify **thresholds** via `feature_requirements` (e.g., `"min_value": 85`) * Agents return **pass/fail lists** of properties that meet the thresholds * Raw scores never leave the governance agent This design prevents: * Score enumeration attacks (running lists with different thresholds to reverse-engineer scores) * Competitive intelligence leakage * Data arbitrage where buyers resell scoring data #### What Buyers Receive When calling `get_property_list`, buyers receive a compact list of identifiers (not full property objects) for efficiency: ```json theme={null} { "list_id": "pl_abc123", "identifiers": [ { "type": "domain", "value": "bbc.co.uk" }, { "type": "domain", "value": "theguardian.com" }, { "type": "domain", "value": "ft.com" } ], "total_count": 847 } ``` Properties that pass the threshold are included. Properties that fail are excluded. No scores or property metadata are returned - just the identifiers needed for bid-time lookups. #### Methodology Discovery The `get_adcp_capabilities` task returns information about what features an agent evaluates and their methodology, but NOT the underlying scores: ```json theme={null} { "features": [ { "feature_id": "mfa_score", "name": "Made For Advertising Score", "type": "quantitative", "range": { "min": 0, "max": 100 }, "methodology": "mfa_detection", "methodology_version": "v2.1", "methodology_url": "https://quality.example.com/methodology" } ] } ``` This allows buyers to: * Understand what an agent measures * Compare methodologies across agents * Set appropriate thresholds But they cannot retrieve the actual scores for individual properties. ## Tasks ### Discovery #### get\_adcp\_capabilities Discover what features a governance agent can evaluate. **Use Cases**: * Capability discovery: Understand what an agent can evaluate * Marketplace browsing: Compare features across agents * Integration planning: Know what filters are available before creating lists ### Property List Management #### create\_property\_list Create a new property list with filters and optional brand reference. **Optional Filters**: * `countries_all` (string\[]): ISO 3166-1 alpha-2 country codes — property must have data for ALL. Omit for global lists. * `channels_any` (string\[]): Advertising channels — property must support ANY. Omit for all-channel lists. **Base Properties**: An array of property sources to evaluate. Each entry is a discriminated union with `selection_type` as the discriminator: * **`publisher_tags`**: `{ "selection_type": "publisher_tags", "publisher_domain": "...", "tags": [...] }` - tags scoped to publisher * **`publisher_ids`**: `{ "selection_type": "publisher_ids", "publisher_domain": "...", "property_ids": [...] }` - property IDs scoped to publisher * **`identifiers`**: `{ "selection_type": "identifiers", "identifiers": [...] }` - no publisher context needed * **Omitted**: Query the agent's entire property database See the [base-property-source schema](https://adcontextprotocol.org/schemas/3.1.20/property/base-property-source.json) for the full specification. **Filter Logic** (explicit in field names): * `countries_all`: Property must have feature data for **ALL** listed countries * `channels_any`: Property must support **ANY** of the listed channels * `feature_requirements`: Property must pass **ALL** requirements (AND) **Use Cases**: * Define compliant property sets with filters (country, channel, feature thresholds) * Provide brand reference for automatic rule application * Register webhook URL for change notifications #### update\_property\_list Modify an existing property list. **Use Cases**: * Add or remove properties from base list * Adjust filters based on campaign needs * Update webhook URL #### get\_property\_list Retrieve a property list with resolved properties. **Use Cases**: * Get the current list of compliant properties after filters applied * Cache resolved list for bid-time use * Retrieve updated list after webhook notification #### list\_property\_lists List property lists owned by a given account, or all property lists accessible to the authenticated agent when `account` is omitted. #### delete\_property\_list Remove a property list. ### Validation #### validate\_property\_delivery Validates delivery records against a property list to determine compliance. Closes the loop between "what I wanted" and "what I got." Performs two independent validations: 1. **Property compliance**: Is the identifier in the resolved property list? 2. **Supply path authorization**: Was the sales agent authorized to sell that property? (optional, requires `sales_agent_url`) **Use Cases**: * Post-campaign validation: Verify impressions landed on compliant properties * Supply path verification: Confirm sales agents were authorized by publishers * Real-time monitoring: Check compliance rate during campaign execution * Audit trails: Generate compliance reports for regulatory or brand safety reviews **Property Validation Statuses**: * `compliant`: Identifier is in the resolved property list * `non_compliant`: Identifier is NOT in the resolved property list * `not_covered`: Identifier recognized but governance agent has no data for it (e.g., property too new) * `unidentified`: Identifier type not resolvable by this agent (e.g., detection failed, unsupported type) **Authorization Validation Statuses** (when `sales_agent_url` provided): * `authorized`: Sales agent is listed in publisher's adagents.json * `unauthorized`: Sales agent is NOT in publisher's authorized\_agents list * `unknown`: Could not fetch or parse adagents.json **Unverifiable Records**: Both `not_covered` and `unidentified` records should be excluded when calculating compliance rates - you cannot penalize for detection gaps or coverage limitations. The distinction helps identify whether the gap is in the agent's data coverage vs the identifier resolution. **Response Format**: The response returns raw counts (compliant, non\_compliant, not\_covered, unidentified impressions). Consumers calculate rates as needed. Governance agents may optionally include an `aggregate` field with computed metrics (score, grade, label) - the format and meaning are agent-specific. ## Typical Flows ### Property List Flow Property lists enable buyers to define and manage compliant property sets: 1. **Create property list**: Buyer defines list on governance agent with filters 2. **Resolve and iterate**: Buyer calls `get_property_list` to see resolved properties 3. **Share list reference**: Buyer provides `list_id` to orchestrator/seller 4. **Cache locally**: Orchestrator/seller fetches and caches resolved properties 5. **Use at bid time**: Orchestrator/seller uses local cache (no governance agent calls) 6. **Refresh periodically**: Re-fetch based on `cache_valid_until` (typically 1-24 hours) **Important**: Governance agents are NOT in the real-time bid path. All bid-time decisions use locally cached property sets. ### Webhook and Caching Pattern Webhooks provide **notification** that a property list has changed. The webhook payload contains a summary of changes, but you must call `get_property_list` to retrieve the actual updated properties. ``` ┌─────────────────────────────────────────────────────────────────┐ │ Webhook Flow │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. Governance agent re-evaluates properties (background) │ │ 2. Webhook fires with change summary (added/removed counts) │ │ 3. Recipient calls get_property_list to fetch updated list │ │ 4. Recipient updates local cache │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` **Best Practices for Downstream Consumers**: Consumers of property lists (orchestrators, sellers, buyer agents) should implement **at least one** of these patterns: 1. **Webhook-driven updates** (recommended): Register a webhook URL when creating the property list. Re-fetch via `get_property_list` when notified of changes. 2. **Polling with cache hints**: Use `cache_valid_until` from `get_property_list` responses to schedule periodic re-fetches. Typical validity periods are 1-24 hours. 3. **Hybrid approach**: Use webhooks for immediate updates, with polling as a fallback safety net. **Cache Expiry Guidance**: Every `get_property_list` response includes: * `resolved_at`: When the list was evaluated * `cache_valid_until`: When consumers should consider the cache stale ```json theme={null} { "resolved_at": "2026-01-04T10:00:00Z", "cache_valid_until": "2026-01-04T22:00:00Z" } ``` Consumers MUST NOT use cached data beyond `cache_valid_until` without re-fetching. ### Property Discovery Flow 1. **Define filters**: Specify country, channel, quality thresholds when creating property list 2. **Resolve list**: Call `get_property_list` with `resolve=true` to get matching properties 3. **Review candidates**: Evaluate returned properties for fit 4. **Add to campaign**: Include property list reference in media buy ## Response Structure All AdCP Governance responses follow a consistent structure: ### Core Response Fields * **message**: Human-readable summary of the operation result * **context\_id**: Session continuity identifier for follow-up requests * **data**: Task-specific payload (varies by task) ### Protocol Transport * **MCP**: Returns complete response as flat JSON object * **A2A**: Returns as structured artifacts with message in text part, data in data part * **Data Consistency**: Both protocols contain identical AdCP data structures ## Error Handling ### Error Codes * `REFERENCE_NOT_FOUND`: Referenced property, policy, or property list does not exist or is not accessible by the caller. Returned uniformly for both "does not exist" and "exists but unauthorized" — see [Uniform response for inaccessible references](/dist/docs/3.1.20/building/by-layer/L3/error-handling#standard-error-codes). * `PROPERTY_NOT_MONITORED`: Governance agent doesn't cover this property * `METHODOLOGY_NOT_SUPPORTED`: Requested methodology version unavailable * `PARTIAL_RESULTS`: Some properties couldn't be evaluated ### Partial Success For bulk operations, the response may include partial results: ```json theme={null} { "message": "Evaluated 847 of 850 properties. 3 properties not in coverage.", "context_id": "ctx-gov-123", "scores": [...], "errors": [ { "code": "PROPERTY_NOT_MONITORED", "property": { "identifiers": [{ "type": "domain", "value": "unknown.com" }] }, "message": "Property not in monitoring coverage" } ] } ``` ## Implementation Notes ### Caching Architecture Governance decisions are highly cacheable: #### Orchestrator-Side Caching * **Score cache**: Store scores with TTL from `valid_until` field * **Decision cache**: Pre-compute pass/fail for campaigns * **List cache**: Cache property lists from `property_list_ref` #### Agent-Side Caching * **Profile cache**: Maintain pre-computed property profiles * **Methodology cache**: Cache scoring algorithm results ### Performance Requirements | Operation | Target Latency | | ----------------------------- | -------------- | | Single property score | \< 100ms | | Bulk scoring (100 properties) | \< 2s | | Filter decision (cached) | \< 5ms | | Property discovery | \< 5s | ### Multi-Agent Strategies Orchestrators may consult multiple governance agents: 1. **Primary + Validation**: Use primary agent, validate with secondary 2. **Specialization**: Route by property type to specialist agents 3. **Consensus**: Require multiple agents to agree 4. **Competitive**: Track agent accuracy, weight by performance ## Agent Discovery There are two complementary discovery mechanisms: ### Publisher-Side Discovery via adagents.json Publishers declare which governance agents have data about their properties using the `property_features` field in `adagents.json`: ```json theme={null} { "property_features": [ { "url": "https://api.sustainability-vendor.example", "name": "Sustainability Vendor", "features": ["carbon_score", "green_media_certified"], "publisher_id": "pub_12345" }, { "url": "https://api.quality-vendor.example", "name": "Quality Vendor", "features": ["mfa_score", "ad_density", "page_speed"] } ] } ``` This solves the discovery problem: buyers don't need to query every possible governance agent. Instead, they read `property_features` from the publisher's adagents.json to find which agents have relevant data. See the [adagents.json Tech Spec](/dist/docs/3.1.20/governance/property/adagents#governance-agent-discovery) for the complete discovery workflow. ### Agent-Side Discovery via agent-card.json Governance agents expose capabilities via `.well-known/agent-card.json`: ```json theme={null} { "name": "Example Compliance Provider", "url": "https://compliance.example.com", "capabilities": { "tasks": [ "get_adcp_capabilities", "create_property_list", "get_property_list", "update_property_list", "delete_property_list", "list_property_lists", "validate_property_delivery" ], "protocols": ["MCP", "A2A"], "schema_version": "v1" }, "methodology": { "documentation_url": "https://compliance.example.com/methodology", "scoring_frameworks": ["data_integrity_index", "brand_safety_score"], "coverage": { "property_types": ["website", "mobile_app", "ctv_app"], "jurisdictions": ["GDPR", "CCPA", "COPPA"] } } } ``` ### Detailed Capability Discovery Use `get_adcp_capabilities` for detailed capability discovery: ```json theme={null} { "tool": "get_adcp_capabilities", "arguments": {} } ``` Returns the specific features the agent can evaluate (mfa\_score, carbon\_score, brand\_risk, etc.). ## Marketplace Architecture The Property Protocol enables a marketplace of specialized data agents: ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ SELLER AGENT (DSP/SSP) │ │ "Give me the compliant property list for this campaign" │ └───────────────────────────────┬─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ BUYER AGENT (implements Property Protocol) │ │ - Exposes: get_adcp_capabilities, get_property_list, webhooks │ │ - Source of truth for final compliant list │ │ - Intersects results from specialized agents │ └───────────────────────────┬─────────────────────────────────────────────┘ │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ Quality Agent │ │ Sustainability│ │ Suitability │ │ │ │ Agent │ │ Agent │ ├───────────────┤ ├───────────────┤ ├───────────────┤ │ Features: │ │ Features: │ │ Features: │ │ mfa_score │ │ carbon_score │ │ content_cat │ │ ad_density │ │ climate_risk │ │ brand_risk │ │ page_speed │ │ green_media │ │ sentiment │ ├───────────────┤ ├───────────────┤ ├───────────────┤ │ Subscription │ │ Subscription │ │ Subscription │ └───────────────┘ └───────────────┘ └───────────────┘ ``` ### Key Principles 1. **Buyer agent is source of truth**: The buyer agent aggregates data from multiple specialized governance agents 2. **Seller sees one interface**: Sellers interact only with the buyer agent using standard Property Protocol 3. **Subscription model**: Each specialized agent is a paid service with its own features and coverage 4. **Webhook-driven updates**: Specialized agents notify the buyer agent when property evaluations change ### Multi-Agent Orchestration A buyer agent can distribute a master property list to multiple specialized agents: ```python theme={null} # Buyer agent creates variants on each specialized agent consent_list = consent_agent.create_property_list( name="Q1 Campaign - Consent", base_properties=master_list, brand=brand ) # Configure webhook for updates consent_agent.update_property_list( list_id=consent_list.list_id, webhook_url="https://buyer.example.com/webhooks/consent" ) suitability_list = suitability_agent.create_property_list( name="Q1 Campaign - Sustainability", base_properties=master_list, brand=brand ) suitability_agent.update_property_list( list_id=suitability_list.list_id, webhook_url="https://buyer.example.com/webhooks/suitability" ) # Buyer agent intersects filtered results def on_list_changed(event): consent_props = consent_agent.get_property_list(consent_list.list_id, resolve=True) suitability_props = suitability_agent.get_property_list(suitability_list.list_id, resolve=True) # Intersection = properties that pass ALL governance agents compliant_props = intersect(consent_props, suitability_props) # Update buyer agent's exposed list update_compliant_list(compliant_props) ``` ### Brand Instead of specifying complex filters, buyers provide a brand reference: ```json theme={null} { "brand": { "domain": "toybrand.com" } } ``` Each governance agent resolves the brand identity and applies rules according to their domain expertise: * **Consent agent**: Applies COPPA requirements for children's brands * **Brand safety agent**: Filters to appropriate content, excludes violence/adult * **Sustainability agent**: Applies any green media requirements The buyer doesn't need to know the specific rules - they declare who they are, and agents figure out what applies. ## Integration with Media Buy Protocol ### Property Lists in Media Buys The Media Buy Protocol accepts property list references: ```json theme={null} { "task": "create_media_buy", "arguments": { "packages": [{ "property_list_ref": { "agent_url": "https://governance.example.com", "list_id": "approved_q1_campaign", "auth_token": "..." } }] } } ``` ### Policy Compliance Media buys can reference governance policies via property list references: ```json theme={null} { "compliance_requirements": { "property_list_ref": { "agent_url": "https://compliance.example.com", "list_id": "pl_q1_compliant", "auth_token": "eyJhbGciOiJIUzI1NiIs..." } } } ``` ## Best Practices 1. **Cache aggressively**: Property scores change slowly; cache for hours/days 2. **Bulk where possible**: Use batch operations for planning, not per-property calls 3. **Pre-compute decisions**: Build pass/fail lookups before bid-time 4. **Monitor coverage**: Track which properties agents don't cover 5. **Log methodology versions**: For audit trails, record which scoring version was used 6. **Handle partial results**: Not all properties will be scorable; plan for gaps ## Next Steps * See the [adagents.json Tech Spec](/dist/docs/3.1.20/governance/property/adagents) for property declaration and authorization * See the [get\_adcp\_capabilities task reference](/dist/docs/3.1.20/protocol/get_adcp_capabilities) for capability discovery * See the [Property List Management](/dist/docs/3.1.20/governance/property/tasks/property_lists) for CRUD operations and webhooks * See the [validate\_property\_delivery task reference](/dist/docs/3.1.20/governance/property/tasks/validate_property_delivery) for post-campaign compliance validation # Property Governance Tasks Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/tasks/index Task reference for AdCP property governance — property list management, feature evaluation, and delivery validation tasks. # Property Governance Tasks Property governance uses a **stateful** model where all evaluation happens through property list management. Create lists with filters and brand references, then resolve them to get compliant properties. ## Discovery | Task | Purpose | Response Time | | --------------------------------------------------------------------------- | --------------------------- | ------------- | | [get\_adcp\_capabilities](/dist/docs/3.1.20/protocol/get_adcp_capabilities) | Discover agent capabilities | \~200ms | Use the protocol-level `get_adcp_capabilities` task to discover what features a governance agent can evaluate. See the [governance section](/dist/docs/3.1.20/protocol/get_adcp_capabilities#governance-protocol) for details on the `property_features` array. ## Property List Management | Task | Purpose | Response Time | | --------------------------------------------------------------- | -------------------------------------- | ------------- | | [create\_property\_list](./property_lists#create_property_list) | Create a new property list | \~500ms | | [update\_property\_list](./property_lists#update_property_list) | Modify an existing list | \~500ms | | [get\_property\_list](./property_lists#get_property_list) | Retrieve list with resolved properties | \~2-5s | | [list\_property\_lists](./property_lists#list_property_lists) | List all property lists | \~500ms | | [delete\_property\_list](./property_lists#delete_property_list) | Delete a property list | \~200ms | See [Property List Management](./property_lists) for complete CRUD documentation. ## Validation | Task | Purpose | Response Time | | ------------------------------------------------------------ | ---------------------------------------- | ------------- | | [validate\_property\_delivery](./validate_property_delivery) | Validate delivery records against a list | \~1-5s | See [validate\_property\_delivery](./validate_property_delivery) for post-campaign compliance validation. ## Task Selection Guide ### Creating a Property List Use `create_property_list` with filters and brand reference: ```json theme={null} { "tool": "create_property_list", "arguments": { "name": "Q1 Campaign - UK Premium", "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news"] } ], "filters": { "countries_all": ["UK"], "channels_any": ["display", "video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 85, "max_value": 100 }, { "feature_id": "coppa_certified", "allowed_values": [true] } ] }, "brand": { "domain": "toybrand.com" } } } ``` **Filters** (all optional): `countries_all` restricts to properties with data in ALL listed countries, `channels_any` restricts to properties supporting ANY listed channel. Omitting a filter means no restriction on that dimension. **Base properties**: An array of property sources to evaluate. Each entry is a discriminated union with `selection_type`: * **`publisher_tags`**: `{ "selection_type": "publisher_tags", "publisher_domain": "...", "tags": [...] }` * **`publisher_ids`**: `{ "selection_type": "publisher_ids", "publisher_domain": "...", "property_ids": [...] }` * **`identifiers`**: `{ "selection_type": "identifiers", "identifiers": [...] }` * **Omitted**: Query the agent's entire property database **Filter logic** (explicit in field names): * `countries_all`: Property must have feature data for ALL listed countries * `channels_any`: Property must support ANY of the listed channels * `feature_requirements`: Property must pass ALL requirements (AND) Filters have two built-in fields (`countries_all`, `channels_any`) plus `feature_requirements` which reference features the agent provides (discovered via [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities)). For quantitative features, use `min_value`/`max_value`. For binary or categorical features, use `allowed_values`. ### Getting Resolved Properties Use `get_property_list` to retrieve the list with resolved identifiers: ```json theme={null} { "tool": "get_property_list", "arguments": { "list_id": "pl_abc123", "resolve": true } } ``` Response includes resolved identifiers. Note that **raw scores are not returned** - only identifiers that pass the filter thresholds are included: ```json theme={null} { "list_id": "pl_abc123", "identifiers": [ { "type": "domain", "value": "bbc.co.uk" }, { "type": "domain", "value": "news.sky.com" } ], "cache_valid_until": "2026-01-04T17:15:00Z" } ``` The `auth_token` for sharing with sellers is returned at creation time (from `create_property_list`). Store it securely - it's only returned once. ### Multi-Agent Integration Create the same property list on multiple governance agents, then configure webhooks to aggregate results: ```python theme={null} # Create lists on specialized agents consent_list = consent_agent.create_property_list( name="Q1 - Consent", base_properties=master_list, brand=brand ) consent_agent.update_property_list( list_id=consent_list.list_id, webhook_url="https://buyer.example.com/webhooks/consent" ) suitability_list = suitability_agent.create_property_list( name="Q1 - Sustainability", base_properties=master_list, brand=brand ) suitability_agent.update_property_list( list_id=suitability_list.list_id, webhook_url="https://buyer.example.com/webhooks/suitability" ) # Buyer agent intersects results when webhooks fire ``` # Property List Management Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/tasks/property_lists Property list tasks in AdCP create, update, get, list, and delete inclusion and exclusion lists combining static sets with dynamic filters. **Tasks**: Create, update, get, list, and delete property lists. Property lists are managed resources that combine static property sets with dynamic filters. When resolved, filters are applied to produce the final property set. ## Architecture: Setup Time, Not Real-Time Property lists are designed for **setup-time** operations, not real-time bid decisions: ``` ┌─────────────────────────────────────────────────────────────────┐ │ SETUP TIME (Campaign Planning) │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. Buyer creates/updates property list on governance agent │ │ 2. Buyer resolves list to get current properties │ │ 3. Buyer provides list_id to orchestrator/seller │ │ 4. Orchestrator/seller fetches and caches resolved list │ │ 5. Campaign targets only cached compliant properties │ │ │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ BID TIME (Milliseconds) │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ • Orchestrator/seller uses LOCAL cache only │ │ • NO calls to governance agent │ │ • Pass/fail from cached property set │ │ │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ REFRESH (Periodic) │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ • Orchestrator/seller re-fetches list on schedule │ │ • Frequency based on cache_valid_until │ │ • Typically every 1-24 hours │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` This enables: * **Static lists**: Curated sets of approved properties * **Dynamic lists**: Properties matching criteria (country, channel, score thresholds) * **Hybrid lists**: Base set modified by filters ## Tasks Overview | Task | Purpose | Response Time | | ---------------------- | -------------------------------------- | -------------------------- | | `create_property_list` | Create a new property list | \~500ms | | `update_property_list` | Modify an existing list | \~500ms | | `get_property_list` | Retrieve list with resolved properties | \~2-5s (depending on size) | | `list_property_lists` | List all property lists | \~500ms | | `delete_property_list` | Delete a property list | \~200ms | ## Property List Structure A property list contains: ```json theme={null} { "list_id": "uk_premium_news_q1", "name": "UK Premium News Sites Q1 2026", "description": "High-quality UK news sites for Q1 campaign", "account": { "account_id": "acc_brand_x_direct_01" }, "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news", "uk_tier1"] } ], "filters": { "countries_all": ["UK"], "channels_any": ["display", "video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 90, "max_value": 100 } ] }, "brand": { "domain": "acmecorp.com" }, "created_at": "2026-01-03T10:00:00Z", "updated_at": "2026-01-03T10:00:00Z", "property_count": 847 } ``` ## Brand Instead of manually specifying all filters, provide a brand reference and let the governance agent apply appropriate rules based on who you are: ```json theme={null} { "brand": { "domain": "toybrand.com" } } ``` The agent resolves the brand identity and applies rules based on its domain expertise: * A consent agent applies COPPA requirements based on the brand's target audience * A brand safety agent infers content categories from the brand's industry * A sustainability agent applies requirements from the brand's profile The brand reference uses the standard [core/brand-ref](https://adcontextprotocol.org/schemas/3.1.20/core/brand-ref.json) schema. The governance agent resolves the domain to discover the brand's identity via its `brand.json` file. ## Webhooks Configure webhooks via `update_property_list` to receive notifications when the resolved list changes. **Important**: Webhooks provide **notification only**. They tell you that the list has changed, but do not stream the updated properties. After receiving a webhook, you must call `get_property_list` to retrieve the updated property set. ### Webhook Flow ``` 1. Governance agent re-evaluates properties (periodically or on trigger) 2. Agent detects changes to the resolved property list 3. Webhook fires with change summary (counts, not full list) 4. Recipient calls get_property_list(list_id, resolve=true) 5. Recipient updates local cache with new properties ``` ### Webhook Payload ```json theme={null} { "idempotency_key": "plch_01HW9DFQK6NP9R3T5V7X9Z1B3D", "event": "property_list_changed", "list_id": "uk_premium_news_q1", "list_name": "UK Premium News Sites Q1 2026", "change_summary": { "properties_added": 12, "properties_removed": 3, "total_properties": 856 }, "resolved_at": "2026-01-03T18:00:00Z", "cache_valid_until": "2026-01-04T18:00:00Z", "signature": "..." } ``` The webhook payload includes counts but NOT the actual properties. This keeps payloads small and avoids redundant data transfer when recipients may not need the full list immediately. Recipients MUST verify the `signature` before processing and MUST dedupe by `idempotency_key` so retried deliveries of the same change event are ignored. ### Webhook Use Cases 1. **Buyer agent aggregation**: Receive updates from multiple specialized agents, intersect results 2. **Seller cache invalidation**: Know when to re-fetch the compliant property list 3. **Alerting**: Notify when significant changes occur to compliance status ## Filters Filters are applied when the list is resolved (via `get_property_list`): | Filter | Type | Description | | ---------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `countries_all` | string\[] | ISO 3166-1 alpha-2 country codes (case-insensitive, uppercase recommended) - property must have feature data for ALL. Optional — omit for global lists. | | `channels_any` | string\[] | Advertising channels - property must support ANY. Optional — omit for all-channel lists. | | `property_types` | string\[] | Property types (website, mobile\_app, ctv\_app, etc.) | | `feature_requirements` | FeatureRequirement\[] | Requirements based on agent-provided features | | `exclude_identifiers` | Identifier\[] | Identifiers to always exclude | ### Feature Requirements Feature requirements reference features discovered via `get_adcp_capabilities`. Each agent exposes different features (mfa\_score, carbon\_score, coppa\_certified, etc.). For **quantitative** features (scores, ranges): ```json theme={null} { "feature_id": "mfa_score", "min_value": 85, "max_value": 100 } ``` For **binary** features (true/false): ```json theme={null} { "feature_id": "coppa_certified", "allowed_values": [true] } ``` For **categorical** features (enum values): ```json theme={null} { "feature_id": "content_category", "allowed_values": ["news", "sports", "technology"] } ``` ### Handling Missing Coverage When a property doesn't have data for a required feature, you can control the behavior with `if_not_covered`: ```json theme={null} { "feature_id": "mfa_score", "min_value": 70, "if_not_covered": "include" } ``` | Value | Behavior | Use Case | | ------------------- | --------------------------------- | --------------------------------------------------------------- | | `exclude` (default) | Property is removed from the list | Strict enforcement - only include properties with verified data | | `include` | Property passes this requirement | Lenient enforcement - don't penalize for coverage gaps | When `if_not_covered: "include"` is used, the response includes a `coverage_gaps` field showing which properties were included despite missing data: ```json theme={null} { "identifiers": [...], "coverage_gaps": { "mfa_score": [ { "type": "domain", "value": "app.example.com" }, { "type": "domain", "value": "ctv.example.com" } ] } } ``` This transparency helps agencies distinguish between properties that passed a requirement vs. those that couldn't be evaluated. ### Required Filters Every property list must include at least: * One country in `countries_all` (ISO 3166-1 alpha-2 code, case-insensitive) * One channel in `channels_any` (display, video, audio, etc.) These are required because governance agents need to know which jurisdiction and context to evaluate properties against. ### Filter Logic The filter field names make the logic explicit: * **`countries_all`**: Property must have feature data for **ALL** listed countries. * **`channels_any`**: Property must support **ANY** of the listed channels. * **`feature_requirements`**: Property must pass **ALL** requirements (AND). ### Base Properties `base_properties` is an array of property sources to evaluate. Each entry is a **discriminated union** with `selection_type` as the discriminator: ```json theme={null} { "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news", "tier1"] }, { "selection_type": "publisher_tags", "publisher_domain": "mediavine.com", "tags": ["lifestyle"] }, { "selection_type": "identifiers", "identifiers": [ { "type": "domain", "value": "bbc.co.uk" }, { "type": "domain", "value": "ft.com" } ] } ] } ``` Each entry must include `selection_type`: | selection\_type | Required Fields | Description | | ---------------- | ---------------------------------- | ------------------------------------------------------- | | `publisher_tags` | `publisher_domain`, `tags` | All properties matching these tags within the publisher | | `publisher_ids` | `publisher_domain`, `property_ids` | Specific property IDs within the publisher | | `identifiers` | `identifiers` | Direct domain/app identifiers (no publisher context) | If `base_properties` is omitted, the agent queries its entire property database for properties matching the filters. See the [base-property-source schema](https://adcontextprotocol.org/schemas/3.1.20/property/base-property-source.json) for the full specification. *** ## create\_property\_list Create a new property list. ### Request ```json theme={null} { "tool": "create_property_list", "arguments": { "name": "UK Premium News Q1", "description": "High-quality UK news sites for Q1 campaign", "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news"] } ], "filters": { "countries_all": ["UK"], "channels_any": ["display", "video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 85, "max_value": 100 } ] } } } ``` ### Response ```json theme={null} { "message": "Created property list 'UK Premium News Q1'.", "context_id": "ctx-gov-list-123", "list": { "list_id": "pl_abc123", "name": "UK Premium News Q1", "description": "High-quality UK news sites for Q1 campaign", "account": { "account_id": "acc_brand_x_direct_01" }, "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news"] } ], "filters": { "countries_all": ["UK"], "channels_any": ["display", "video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 85, "max_value": 100 } ] }, "created_at": "2026-01-03T16:30:00Z", "updated_at": "2026-01-03T16:30:00Z", "property_count": 847 }, "auth_token": "eyJhbGciOiJIUzI1NiIs..." } ``` ### Dynamic List (Filters Only) Create a list that dynamically queries the governance agent's database (no base\_properties - uses agent's full coverage): ```json theme={null} { "tool": "create_property_list", "arguments": { "name": "GDPR-Compliant DE Video", "description": "All DE properties supporting video with strong consent", "filters": { "countries_all": ["DE"], "channels_any": ["video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 90, "max_value": 100 } ] } } } ``` *** ## update\_property\_list Modify an existing property list. ### Request - Update Filters ```json theme={null} { "tool": "update_property_list", "arguments": { "list_id": "pl_abc123", "filters": { "countries_all": ["UK"], "channels_any": ["display", "video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 80, "max_value": 100 } ] } } } ``` ### Request - Replace Base Properties ```json theme={null} { "tool": "update_property_list", "arguments": { "list_id": "pl_abc123", "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news", "uk_tier1"] } ] } } ``` ### Request - Add Exclusions ```json theme={null} { "tool": "update_property_list", "arguments": { "list_id": "pl_abc123", "filters": { "exclude_identifiers": [ { "type": "domain", "value": "excluded-site.com" } ] } } } ``` ### Response ```json theme={null} { "message": "Updated property list 'UK Premium News Q1'.", "context_id": "ctx-gov-list-456", "list": { "list_id": "pl_abc123", "name": "UK Premium News Q1", "updated_at": "2026-01-03T17:00:00Z", "property_count": 845 } } ``` *** ## get\_property\_list Retrieve a property list with optional resolution of filters. ### Request - Get Resolved Properties ```json theme={null} { "tool": "get_property_list", "arguments": { "list_id": "pl_abc123", "resolve": true, "pagination": { "max_results": 1000 } } } ``` ### Response The response returns a compact list of **identifiers only** (not full property objects) for efficiency. Only identifiers that pass the feature requirements are included - no scores or metadata. ```json theme={null} { "message": "Retrieved property list 'UK Premium News Q1' with 847 resolved identifiers.", "context_id": "ctx-gov-list-789", "list_id": "pl_abc123", "identifiers": [ { "type": "domain", "value": "bbc.co.uk" }, { "type": "domain", "value": "news.sky.com" }, { "type": "domain", "value": "ft.com" }, { "type": "domain", "value": "theguardian.com" } ], "pagination": { "has_more": true, "cursor": "eyJvZmZzZXQiOjEwMH0=", "total_count": 847 }, "resolved_at": "2026-01-03T17:15:00Z", "cache_valid_until": "2026-01-04T17:15:00Z" } ``` The `auth_token` is only returned when the list is created via `create_property_list`. Store it securely - you'll need it to share access with sellers. ### Request - Get Metadata Only ```json theme={null} { "tool": "get_property_list", "arguments": { "list_id": "pl_abc123", "resolve": false } } ``` ### Response (Metadata Only) ```json theme={null} { "message": "Retrieved property list 'UK Premium News Q1' metadata.", "context_id": "ctx-gov-list-790", "list": { "list_id": "pl_abc123", "name": "UK Premium News Q1", "description": "High-quality UK news sites for Q1 campaign", "base_properties": [ { "selection_type": "publisher_tags", "publisher_domain": "raptive.com", "tags": ["premium_news"] } ], "filters": { "countries_all": ["UK"], "channels_any": ["display", "video"], "feature_requirements": [ { "feature_id": "mfa_score", "min_value": 85, "max_value": 100 } ] }, "created_at": "2026-01-03T16:30:00Z", "updated_at": "2026-01-03T17:00:00Z", "property_count": 847 } } ``` *** ## list\_property\_lists List property lists owned by a given account, or all property lists accessible to the authenticated agent when `account` is omitted. ### Request ```json theme={null} { "tool": "list_property_lists", "arguments": { "name_contains": "UK", "pagination": { "max_results": 50 } } } ``` ### Response ```json theme={null} { "message": "Found 3 property lists matching 'UK'.", "context_id": "ctx-gov-list-list-123", "lists": [ { "list_id": "pl_abc123", "name": "UK Premium News Q1", "description": "High-quality UK news sites for Q1 campaign", "created_at": "2026-01-03T16:30:00Z", "updated_at": "2026-01-03T17:00:00Z", "property_count": 847 }, { "list_id": "pl_def456", "name": "UK Sports Sites", "description": "UK sports content for sponsorship", "created_at": "2026-01-02T10:00:00Z", "updated_at": "2026-01-02T10:00:00Z", "property_count": 156 } ], "pagination": { "has_more": false, "total_count": 3 } } ``` *** ## delete\_property\_list Delete a property list. ### Request ```json theme={null} { "tool": "delete_property_list", "arguments": { "list_id": "pl_abc123" } } ``` ### Response ```json theme={null} { "message": "Deleted property list 'UK Premium News Q1'.", "context_id": "ctx-gov-list-del-123", "deleted": true, "list_id": "pl_abc123" } ``` *** ## Integration with Other Tasks ### Using Lists in score\_properties Reference a property list instead of passing properties inline: ```json theme={null} { "tool": "score_properties", "arguments": { "property_list_ref": { "agent_url": "https://governance.example.com", "list_id": "pl_abc123" }, "scoring_context": { "jurisdiction": "GDPR" } } } ``` ### Using Lists in Media Buys Pass property lists to media buy creation: ```json theme={null} { "tool": "create_media_buy", "arguments": { "packages": [{ "property_list_ref": { "agent_url": "https://governance.example.com", "list_id": "pl_abc123" } }] } } ``` *** ## Error Codes | Code | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `REFERENCE_NOT_FOUND` | Property list ID doesn't exist, or the caller lacks access. Returned uniformly for both cases — see [Uniform response for inaccessible references](/dist/docs/3.1.20/building/by-layer/L3/error-handling#standard-error-codes). Sellers MUST NOT distinguish "exists but unauthorized" from "does not exist." | | `INVALID_FILTER` | Filter configuration is invalid | | `LIST_NAME_EXISTS` | A list with this name already exists | ## Caching and Refresh The `get_property_list` response includes caching guidance: | Field | Description | | ------------------- | ------------------------------------------------- | | `resolved_at` | When filters were applied and properties resolved | | `cache_valid_until` | When consumers should re-fetch the list | **Typical flow for orchestrators/sellers:** ```python theme={null} # Initial setup response = governance_agent.get_property_list(list_id, resolve=True) local_cache = build_property_lookup(response.properties) cache_expiry = response.cache_valid_until # Periodic refresh (background job) if now() >= cache_expiry: response = governance_agent.get_property_list(list_id, resolve=True) local_cache = build_property_lookup(response.properties) cache_expiry = response.cache_valid_until # Bid time (no external calls) def should_bid(property_domain): return property_domain in local_cache ``` ## Usage Notes 1. **Setup time only**: Governance agents are not in the real-time bid path; resolve lists during campaign setup 2. **Local caching**: Orchestrators/sellers must cache resolved properties locally for bid-time decisions 3. **Refresh on schedule**: Re-fetch lists based on `cache_valid_until` (typically every 1-24 hours) 4. **Dynamic vs Static**: Use filters-only lists when you want the agent to maintain the property set; use base\_properties when you need explicit control 5. **Pagination**: Large lists may require multiple requests with cursor-based pagination 6. **No score leakage**: Raw scores are kept internal to governance agents; responses contain pass/fail lists, not scores # validate_property_delivery Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/property/tasks/validate_property_delivery validate_property_delivery checks delivery records against a property list for compliance and supply path authorization in AdCP. # validate\_property\_delivery Validates delivery records against a property list to determine compliance. Answers two questions: 1. **Property compliance**: Did my impressions land on properties in my list? 2. **Supply path authorization**: Was the sales agent authorized to sell that inventory? ## Use Cases * **Post-campaign validation**: Verify that impressions were delivered to compliant properties * **Supply path verification**: Confirm sales agents were authorized by publishers * **Real-time monitoring**: Check compliance rate during campaign execution * **Audit trails**: Generate compliance reports for regulatory or brand safety reviews ## Request ```json theme={null} { "$schema": "/schemas/3.1.20/property/validate-property-delivery-request.json", "list_id": "pl_abc123", "records": [ { "identifier": { "type": "domain", "value": "www.nytimes.com" }, "impressions": 103 }, { "identifier": { "type": "domain", "value": "sketchy-site.example" }, "impressions": 47 }, { "identifier": { "type": "android_package", "value": "com.unknown.app" }, "impressions": 25 } ], "include_compliant": false } ``` ### Parameters | Parameter | Type | Required | Description | | --------------------------- | ------- | -------- | --------------------------------------------------------------- | | `list_id` | string | Yes | ID of the property list to validate against | | `records` | array | Yes | Delivery records to validate (1-10,000 records) | | `records[].identifier` | object | Yes | Property identifier (`type` and `value`) | | `records[].impressions` | integer | Yes | Number of impressions delivered | | `records[].record_id` | string | No | Client-provided ID for correlation | | `records[].sales_agent_url` | string | No | Sales agent URL to validate authorization against adagents.json | | `include_compliant` | boolean | No | Include compliant records in results (default: false) | ## Response ```json theme={null} { "$schema": "/schemas/3.1.20/property/validate-property-delivery-response.json", "status": "completed", "list_id": "pl_abc123", "summary": { "total_records": 4, "total_impressions": 200, "compliant_records": 1, "compliant_impressions": 103, "non_compliant_records": 1, "non_compliant_impressions": 47, "not_covered_records": 1, "not_covered_impressions": 25, "unidentified_records": 1, "unidentified_impressions": 25 }, "aggregate": { "score": 68.7, "grade": "C+", "label": "68.7% compliant", "methodology_url": "https://governance.example.com/methodology/compliance-scoring" }, "results": [ { "identifier": { "type": "domain", "value": "sketchy-site.example" }, "status": "non_compliant", "impressions": 47, "features": [ { "feature_id": "record:list_membership", "status": "failed", "explanation": "Identifier not found in resolved property list" } ] }, { "identifier": { "type": "domain", "value": "new-site.example" }, "status": "not_covered", "impressions": 25 }, { "identifier": { "type": "android_package", "value": "com.unknown.app" }, "status": "unidentified", "impressions": 25 } ], "validated_at": "2026-01-04T19:00:00Z", "list_resolved_at": "2026-01-04T12:00:00Z" } ``` ### Response Fields | Field | Type | Description | | ------------------ | -------- | --------------------------------------------------- | | `list_id` | string | ID of the property list validated against | | `summary` | object | Raw counts for property compliance validation | | `aggregate` | object | Optional computed metrics from the governance agent | | `results` | array | Per-record validation results | | `validated_at` | datetime | When validation was performed | | `list_resolved_at` | datetime | Resolution timestamp of the property list used | ### Summary Fields The summary provides raw counts - consumers calculate rates as needed: | Field | Description | | ----------------------------------------------------- | -------------------------------------------- | | `total_records` | Total records validated | | `total_impressions` | Total impressions across all records | | `compliant_records` / `compliant_impressions` | Records/impressions in the property list | | `non_compliant_records` / `non_compliant_impressions` | Records/impressions NOT in the property list | | `not_covered_records` / `not_covered_impressions` | Identifier recognized but no data available | | `unidentified_records` / `unidentified_impressions` | Identifier type not resolvable | ### Validation Statuses | Status | Meaning | | --------------- | ------------------------------------------------------------- | | `compliant` | Identifier is in the resolved property list | | `non_compliant` | Identifier is NOT in the resolved property list | | `not_covered` | Identifier recognized but governance agent has no data for it | | `unidentified` | Identifier type not resolvable by this agent | ## Understanding not\_covered vs unidentified These two statuses distinguish different types of "unknown" scenarios: **`not_covered`** - The governance agent recognized the identifier (e.g., it's a valid domain) but doesn't have data for that specific property. This happens when: * A property is too new to be in the agent's database * The property exists but hasn't been evaluated yet * The agent's coverage doesn't include that property category **`unidentified`** - The governance agent couldn't recognize the identifier at all. This happens when: * Client-side detection failed to capture the property * The identifier type isn't supported (e.g., agent handles domains but received an app ID) * The identifier value is malformed or invalid Both statuses should be excluded from compliance rate calculations - you cannot penalize for detection or coverage gaps. ## Optional Aggregate Metrics Governance agents can optionally return computed metrics in the `aggregate` field: ```json theme={null} "aggregate": { "score": 68.7, "grade": "C+", "label": "68.7% compliant", "methodology_url": "https://governance.example.com/methodology" } ``` | Field | Description | | ----------------- | ------------------------------------------------------- | | `score` | Numeric score (scale is agent-defined, typically 0-100) | | `grade` | Letter grade or category (e.g., "A+", "B-", "Gold") | | `label` | Human-readable summary (e.g., "85% compliant") | | `methodology_url` | URL explaining how the aggregate was calculated | The `aggregate` field is optional and agent-specific. Consumers should not assume a particular format - always check `methodology_url` for interpretation. ## Calculating Your Own Rates The response always includes raw counts. Calculate rates as needed: ```python theme={null} # Compliance rate (exclude unverifiable from denominator) unverifiable = summary.not_covered_impressions + summary.unidentified_impressions verifiable = summary.total_impressions - unverifiable compliance_rate = summary.compliant_impressions / verifiable if verifiable > 0 else None ``` In the example above: * Compliant impressions: 103 * Non-compliant impressions: 47 * not\_covered + unidentified impressions: 50 (excluded) * Compliance rate: 103 / (200 - 50) = 103 / 150 = 68.7% ## Feature Results Every entry in `features[]` carries a `feature_id` and a `status`. Data features come from the governance agent's feature catalog (discovered via `get_adcp_capabilities`). Record-level structural checks use reserved namespaces so they sit in the same identity space as data features. ### Reserved feature\_id prefixes | Prefix | Scope | Canonical feature\_ids | | ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------- | | `record:` | Record-level structural checks | `record:list_membership`, `record:excluded`, `record:country_mismatch`, `record:channel_mismatch` | | `delivery:` | Delivery-path checks | `delivery:seller_authorization`, `delivery:click_url_presence` | Governance agents MAY add new checks within these namespaces and publish them in their feature catalog. ### Feature-Level Failures When a property fails a specific feature requirement, the entry references the feature by id. The response carries the verdict and a directional explanation. When the caller authored the requirement (e.g., `feature_requirements` on a property list), the evaluator MAY echo the `requirement` back to enable fix-and-retry loops without re-reading the list definition. ```json theme={null} { "identifier": { "type": "domain", "value": "low-quality-site.example" }, "status": "non_compliant", "impressions": 150, "features": [ { "feature_id": "mfa_score", "status": "failed", "explanation": "Property below MFA score requirement", "requirement": { "min_value": 85 } } ] } ``` Oracle pattern: the response tells you **what** failed (feature\_id) and **where to find the rule** (policy\_id, optional). Evaluator internals (confidence thresholds, inference logic) stay hidden. ## Supply Path Authorization When `sales_agent_url` is provided in delivery records, the governance agent validates that the sales agent is authorized to sell the property by checking the publisher's `adagents.json`. ### Request with Authorization ```json theme={null} { "list_id": "pl_abc123", "records": [ { "identifier": { "type": "domain", "value": "www.nytimes.com" }, "impressions": 103, "sales_agent_url": "https://legitimate-ssp.example.com" }, { "identifier": { "type": "domain", "value": "www.nytimes.com" }, "impressions": 50, "sales_agent_url": "https://unauthorized-reseller.example.com" } ] } ``` ### Response with Authorization When authorization is validated, each result includes an `authorization` field: ```json theme={null} { "list_id": "pl_abc123", "summary": { "total_records": 2, "total_impressions": 153, "compliant_records": 2, "compliant_impressions": 153, "non_compliant_records": 0, "non_compliant_impressions": 0, "unknown_records": 0, "unknown_impressions": 0 }, "authorization_summary": { "records_checked": 2, "impressions_checked": 153, "authorized_records": 1, "authorized_impressions": 103, "unauthorized_records": 1, "unauthorized_impressions": 50, "unknown_records": 0, "unknown_impressions": 0 }, "results": [ { "identifier": { "type": "domain", "value": "www.nytimes.com" }, "status": "compliant", "impressions": 50, "authorization": { "status": "unauthorized", "publisher_domain": "nytimes.com", "sales_agent_url": "https://unauthorized-reseller.example.com", "violation": { "code": "agent_not_authorized", "message": "Sales agent not listed in nytimes.com/.well-known/adagents.json" } } } ], "validated_at": "2026-01-04T19:00:00Z" } ``` ### Authorization Statuses | Status | Meaning | Included in authorization\_rate? | | -------------- | ------------------------------------------------------ | -------------------------------- | | `authorized` | Sales agent is listed in publisher's adagents.json | Yes (numerator) | | `unauthorized` | Sales agent is NOT listed in publisher's adagents.json | Yes (denominator only) | | `unknown` | Could not fetch or parse adagents.json | No (excluded) | ### Authorization Violation Codes | Code | Description | | ----------------------- | ------------------------------------------------------------------- | | `agent_not_authorized` | Sales agent URL not found in publisher's authorized\_agents list | | `adagents_not_found` | Publisher's adagents.json could not be fetched (404, timeout, etc.) | | `adagents_invalid` | Publisher's adagents.json exists but is malformed | | `property_not_declared` | Property identifier not declared in publisher's adagents.json | ### Two Independent Checks Property compliance and authorization are **independent checks**. A record can be: | Property Status | Authorization Status | Meaning | | --------------- | -------------------- | -------------------------------------------------------- | | compliant | authorized | Fully valid - property in list, sold by authorized agent | | compliant | unauthorized | Property is approved but sold by unauthorized reseller | | non\_compliant | authorized | Authorized agent sold property outside your list | | non\_compliant | unauthorized | Neither property nor agent validated | Both checks use the same "unknown excludes from rate" pattern - you cannot penalize for detection gaps. ## Best Practices ### Batch Validation For large-scale validation, batch records up to the 10,000 limit: ```python theme={null} def validate_delivery_batch(records, list_id, governance_agent): """Validate delivery records in batches.""" batch_size = 10000 all_results = [] for i in range(0, len(records), batch_size): batch = records[i:i + batch_size] response = governance_agent.validate_property_delivery( list_id=list_id, records=batch ) all_results.extend(response.results) return all_results ``` ### Sampling Strategy For real-time monitoring during campaign execution, validate a statistical sample rather than all records: ```python theme={null} import random def sample_and_validate(records, sample_size=1000): """Validate a random sample for real-time monitoring.""" sample = random.sample(records, min(sample_size, len(records))) return governance_agent.validate_property_delivery( list_id=list_id, records=sample ) ``` ### Handling Unknown Records Track unknown rates separately to identify detection gaps: ```python theme={null} def analyze_validation(response): """Analyze validation results with unknown handling.""" summary = response.summary # Core compliance metric compliance_rate = summary.compliance_rate # Detection quality metric unknown_rate = summary.unknown_impressions / summary.total_impressions if unknown_rate > 0.1: print(f"Warning: {unknown_rate:.1%} of impressions unresolvable") return { "compliance_rate": compliance_rate, "unknown_rate": unknown_rate, "non_compliant_impressions": summary.non_compliant_impressions } ``` ## Related Tasks * [create\_property\_list](./property_lists#create_property_list) - Create the list to validate against * [get\_property\_list](./property_lists#get_property_list) - Retrieve current list membership * [get\_adcp\_capabilities](/dist/docs/3.1.20/protocol/get_adcp_capabilities) - Discover available filter features # RFC process Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/rfc-process How to propose and ratify material changes to AdCP — the lifecycle from draft to specification change, including proposal template and decision-record format. Protocol proposals use a lightweight RFC (Request for Comments) process to ensure material changes are motivated, reviewed, and recorded before they reach the specification. This page describes when the process applies, how to submit a proposal, and what a decision record looks like. ## What requires an RFC | Change | Requires RFC | | ---------------------------------------------------------------------------- | ------------ | | Schema field removed or renamed | Yes | | Task added or removed | Yes | | Normative language changed (`MUST` / `SHOULD` / `MAY`) | Yes | | Compatibility surface altered — default value, field type, required↔optional | Yes | | Optional schema field added | No | | New enum value appended | No | | Doc wording clarified without semantic change | No | | Typo fix | No | | Internal tooling, CI, or infra | No | | Docs navigation changes (`docs.json`) | No | When in doubt: if the change would force downstream implementations to update code to keep working, it requires an RFC. ## Lifecycle Open a GitHub issue using the [proposal template](#proposal-template) as the body. Title format: `RFC: `. Add the `rfc` label. The author should solicit early feedback from working group members or affected implementers before requesting formal review. The issue is queued for the next working group session. At least two [working group members](/dist/docs/3.1.20/community/working-group) must complete the reviewer checklist before the WG votes. The review period is a minimum of seven calendar days after the issue is filed. The WG records a decision — accepted, rejected, or deferred — by posting a [decision record](#decision-record-format) as a comment on the RFC issue. Dissent must be recorded even when consensus is reached. After the decision record exists and its status is **accepted**, any contributor may open the spec PR. The PR must reference the RFC issue with `Refs #N` (not `Closes #N`) and may not merge until the decision record exists. The spec PR reviewer confirms the diff matches the accepted RFC scope. The final spec PR carries `Closes #N` to close the RFC issue on merge. An accepted RFC is the required trigger for each spec-lifecycle stage transition: it is what moves a feature from Draft → Proposed, or gates Deprecated → Sunset. No lifecycle transition is valid without a traceable, accepted decision record. ## Proposal template Copy this into the GitHub issue body when filing an RFC: ```markdown theme={null} ## Motivation ## Scope ## Alternatives considered ## Compatibility impact ## Reviewer checklist - [ ] Motivation is clear and not redundant with existing functionality - [ ] Scope is specific enough to implement without further clarification - [ ] Alternatives section covers at least one non-obvious alternative - [ ] Compatibility impact accurately states breaking vs. non-breaking - [ ] Wire-format or schema snippet included (for schema or task changes) ``` ## Decision-record format Post this as a comment on the RFC issue after the WG vote. The `Dissent` section is required — omitting it signals that all reviewers explicitly confirmed no minority position existed. ```markdown theme={null} ## Decision record **Status:** accepted | rejected | deferred **Date:** YYYY-MM-DD **Discussion:** **Vote outcome:** N in favor, N opposed, N abstained ## Rationale ## Dissent ## Next steps ``` ## See also * Specification lifecycle — approved RFCs drive spec-lifecycle stage transitions (Draft → Proposed → Final, and Final → Deprecated); the dedicated page tracks [#2441](https://github.com/adcontextprotocol/adcp/issues/2441) * [Governance overview](/dist/docs/3.1.20/governance/overview) — the three-party model and campaign governance domains * [Embedded human judgment](/dist/docs/3.1.20/governance/embedded-human-judgment) — the principle behind the governance system that most RFCs serve # Working group charter Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/governance/working-group-charter Operational charter for the AdCP Working Group: quorum, voting thresholds, cadence, recusal, and escalation. The AdCP Working Group (WG) develops, reviews, and maintains the Ad Context Protocol specification under Foundation governance. This charter records the operational rules the WG has adopted for its own meetings, decisions, and conduct. It operates under the Foundation's [Bylaws](https://agenticadvertising.org/governance), [IPR Policy](https://github.com/adcontextprotocol/adcp/blob/main/IPR_POLICY.md), and [Charter](https://github.com/adcontextprotocol/adcp/blob/main/CHARTER.md). In any conflict, those documents control. These thresholds are operative from the merge date of this document. The merge commit is the authoritative ratification record. ## Participation The WG is open to: * **Voting participants** — employees of AgenticAdvertising.org Voting Member organizations. Each member organization holds one vote, exercised by a designated representative. * **Observers** — non-member practitioners, researchers, and interested parties. Observers may speak and comment in any forum but may not vote. **Active status** — a voting participant retains voting eligibility by meeting either threshold in each rolling eight-week window: * Attends ≥ 2 of the last 4 synchronous sessions, or * Participates in ≥ 3 of the last 4 async GitHub ballots (see [Meeting cadence](#meeting-cadence)). No application is required. Enrollment is at [agenticadvertising.org/governance](https://agenticadvertising.org/governance). ## Decision classes and voting thresholds The WG uses three decision classes with corresponding quorum and pass-threshold rules. These thresholds were adopted by the WG at ratification of this charter and are the operative rules for all subsequent decisions. | Class | Examples | Quorum | Pass threshold | | ------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------------------- | | **Editorial** | Typos, broken-link fixes, non-semantic rewording, metadata-only updates | 3 voting participants | Simple majority (> 50%) | | **Normative** | Non-breaking additions: optional fields, new tasks, new enum values, new doc sections, new capabilities | 5 voting participants, ≥ 2 member orgs | ⅔ supermajority | | **Breaking** | Removing or renaming public surface identifiers, optional → required, semantic meaning changes, default value changes | 7 voting participants, ≥ 3 member orgs | ¾ supermajority | ### Experimental surfaces Changes exclusively to surfaces marked `x-status: experimental` in schemas — or under `static/schemas/source/trusted-match/`, `static/schemas/source/sponsored-intelligence/`, or `static/schemas/source/a2ui/` — receive a downgraded decision class consistent with the [experimental surface policy](/dist/docs/3.1.20/reference/experimental-status): | Would be (stable) | Treated as (experimental) | | ----------------- | ------------------------- | | Breaking | Normative | | Normative | Editorial | | Editorial | Editorial | When a PR promotes a surface from experimental to stable (removes `x-status: experimental`), the class is determined by the promoted surface's first stable contract, not its experimental history. ### Classification challenge Any participant may challenge an Editorial classification within **72 hours** of the PR being posted to GitHub. A challenge requires a comment from the challenger plus a second from one other participant. A valid challenge elevates the PR to Normative treatment. The WG Chair records the outcome in the PR thread. ## Meeting cadence * **Working sessions** — weekly, via video and the `#wg-adcp` Slack channel. Agendas are published at least 48 hours in advance. * **Minutes** — published to [`governance/minutes/`](https://github.com/adcontextprotocol/adcp/tree/main/governance/minutes) within 7 calendar days of each session. * **Session recordings** — available to AgenticAdvertising.org members via the members-only Slack archive. Recordings are access-restricted to protect participant candor and to comply with the antitrust safe-harbor provisions in [Article VII of the Bylaws](https://agenticadvertising.org/governance). * **Async ballots** — any participant may open an async GitHub ballot on a labeled issue. The ballot window is 5 calendar days. Async ballots count toward active-status tracking. ## Escalation When the WG cannot reach the required threshold after the standard comment window: 1. **Extended comment** — the WG Chair extends the window by 7 calendar days and posts a summary of outstanding objections in the issue thread. 2. **Escalation to Foundation leadership** — if still unresolved, the Chair escalates in writing to Foundation leadership. During the interim period (before the first AGM), escalation goes to the **interim Board** directly. After the first AGM, escalation goes to the **Executive Committee**, which issues a binding resolution within 30 calendar days per [Bylaws § 4.14](https://agenticadvertising.org/governance). 3. **Full Board vote** — if the Executive Committee (post-AGM) is deadlocked on a Breaking-class change, the matter is elevated to the full Board under the director voting rules in Article IV of the Bylaws. ## Tie-break * **Editorial** — the WG Chair casts the deciding vote if the simple majority is exactly tied. * **Normative / Breaking** — no WG-level tie-break; escalate per the path above. ## Recusal **General rule** — a voting participant must disclose any conflict before a vote and must recuse from the vote (but may remain in the discussion) when: * Their employer is a named party in the specific decision (for example, a registry listing or certification dispute involving their organization). * They have a financial interest in the outcome not shared by the general membership. * They or their employer hold a patent whose claims would be Necessary Claims on a change under vote. See the [IPR Policy](https://github.com/adcontextprotocol/adcp/blob/main/IPR_POLICY.md) for disclosure obligations. **Interim board concentration (through the first AGM)** — as disclosed in [CHARTER.md § 4.1](https://github.com/adcontextprotocol/adcp/blob/main/CHARTER.md#41-interim-board), two of the four interim directors (Michael Blum and Brian O'Kelley) represent Scope3. During the interim period, the general rule is supplemented as follows: any WG participant affiliated with Scope3 must declare before a vote any WG decision that would confer a specific material advantage to Scope3 (for example, decisions that prioritize infrastructure or tooling primarily developed or maintained by Scope3). If the advantage is not shared broadly with the membership, recusal from that vote is required. This is an addition to the general rule, not a substitution for it. Recusals are recorded in the meeting minutes. ## Roster The current roster of voting participants and their member-organization affiliations is maintained at [agenticadvertising.org/governance](https://agenticadvertising.org/governance). The interim board composition is listed in [CHARTER.md § 4.1](https://github.com/adcontextprotocol/adcp/blob/main/CHARTER.md#41-interim-board). ## Amendments Amendments to this charter follow Normative-class rules (⅔ supermajority, 5 voting participants, ≥ 2 member orgs). An amendment that reduces the quorum or pass threshold for Breaking-class decisions must itself pass under Breaking-class rules. # Accountability Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/advanced-topics/accountability How buyers and sellers negotiate, enforce, and resolve performance standards, measurement terms, and cancellation policies in AdCP guaranteed buys. ## Overview Guaranteed media buys in AdCP have three accountability surfaces: * **Performance standards** — rate thresholds for viewability, IVT, completion, brand safety, and attention (IAB T\&Cs Section XI) * **Measurement terms** — who counts the billing metric and what remedies (makegoods) apply when thresholds are breached (IAB T\&Cs Sections V, VII, IX) * **Cancellation policy** — notice period and cancellation fee for early termination (IAB T\&Cs Section XII) These are structured, machine-readable fields — not free text. Buyer and seller agents negotiate them programmatically through the standard product discovery and buy creation workflow. ## Lifecycle ### 1. Discovery: Buyer States Requirements At `get_products`, the buyer filters for products that meet their performance requirements: ```json theme={null} { "buying_mode": "brief", "brief": "Premium video for CPG brand, Q3 flight", "filters": { "delivery_type": "guaranteed", "required_performance_standards": [ { "metric": "viewability", "threshold": 0.70, "standard": "mrc", "vendor": { "domain": "doubleverify.com" } }, { "metric": "ivt", "threshold": 0.05, "vendor": { "domain": "doubleverify.com" } } ] } } ``` Products that cannot meet these thresholds or do not support the specified vendors are excluded from results. The buyer is saying: "I need DoubleVerify for viewability at 70% MRC and IVT under 5%." ### 2. Product Response: Seller Declares Defaults Returned products include the seller's default `performance_standards`, `measurement_terms`, and `cancellation_policy`: ```json theme={null} { "product_id": "premium_video_q3", "delivery_type": "guaranteed", "performance_standards": [ { "metric": "viewability", "threshold": 0.70, "standard": "mrc", "vendor": { "domain": "doubleverify.com" } }, { "metric": "ivt", "threshold": 0.05, "vendor": { "domain": "doubleverify.com" } }, { "metric": "completion_rate", "threshold": 0.75, "vendor": { "domain": "doubleverify.com" } } ], "measurement_terms": { "billing_measurement": { "vendor": { "domain": "admanager.google.com" }, "max_variance_percent": 10 }, "makegood_policy": { "available_remedies": ["additional_delivery", "credit", "invoice_adjustment"] } }, "cancellation_policy": { "notice_period": { "interval": 30, "unit": "days" }, "cancellation_fee": { "type": "percent_remaining", "rate": 0.5 } } } ``` The buyer can see all terms before committing budget. ### 3. Refinement: Negotiate Before Committing Using `buying_mode: "refine"`, the buyer can propose changes to performance standards or measurement terms. This uses the same `required_performance_standards` filter — the seller responds with updated products reflecting what they can offer. Refinement is iterative and non-binding. ### 4. Buy Creation: Buyer Proposes, Seller Accepts At `create_media_buy`, the buyer can propose different terms on the package request: ```json theme={null} { "product_id": "premium_video_q3", "budget": 50000, "pricing_option_id": "cpm_usd_fixed", "performance_standards": [ { "metric": "viewability", "threshold": 0.75, "standard": "mrc", "vendor": { "domain": "doubleverify.com" } }, { "metric": "ivt", "threshold": 0.03, "vendor": { "domain": "doubleverify.com" } } ], "measurement_terms": { "billing_measurement": { "vendor": { "domain": "campaignmanager.google.com" }, "max_variance_percent": 5 } } } ``` The seller has three responses: * **Accept** — echo the buyer's terms on the confirmed package * **Reject** — return `TERMS_REJECTED` with details about which term failed and acceptable range * **Adjust** — return modified terms on the confirmed package (the buyer agent inspects the response to see what changed) When the buyer omits `performance_standards` or `measurement_terms`, the product's defaults apply. #### Measurement terms for phased-maturation channels Some channels produce billing-grade data in phases rather than delivering final numbers on day one — broadcast TV, DOOH, digital with IVT filtering, podcast downloads, and others. For these, the buyer proposes a `measurement_window` alongside the vendor, specifying which maturation stage the guarantee is reconciled against: ```json theme={null} { "product_id": "primetime_30s_q4", "budget": 250000, "pricing_option_id": "unit_rate_30s", "agency_estimate_number": "EST-2026-04821", "measurement_terms": { "billing_measurement": { "vendor": { "domain": "videoamp.com" }, "measurement_window": "c7", "max_variance_percent": 10 } } } ``` The `measurement_window` references a `window_id` from the product's `reporting_capabilities.measurement_windows`. This tells both sides: "VideoAmp's C7 numbers are what we reconcile against." For a DOOH product it would be `"final"` (post-IVT/fraud-check); for digital it might be `"post_sivt"`. The same mechanism declares both which data is authoritative for billing and when it becomes available — reconciliation and invoicing clocks follow that declared availability. The `agency_estimate_number` is the financial reference that links the order to the agency's media plan — it travels with the order through the transaction lifecycle. ### 5. Confirmed Package: The Contract The confirmed package reflects the agreed terms — the binding contract: ```json theme={null} { "package_id": "pkg_001", "product_id": "premium_video_q3", "performance_standards": [ { "metric": "viewability", "threshold": 0.75, "standard": "mrc", "vendor": { "domain": "doubleverify.com" } }, { "metric": "ivt", "threshold": 0.03, "vendor": { "domain": "doubleverify.com" } } ], "measurement_terms": { "billing_measurement": { "vendor": { "domain": "campaignmanager.google.com" }, "max_variance_percent": 5 }, "makegood_policy": { "available_remedies": ["additional_delivery", "credit", "invoice_adjustment"] } } } ``` ### 6. Creative Enforcement When agreed `performance_standards` specify a vendor, creatives assigned to that package MUST include `tracker_script` or `tracker_pixel` URL assets from that vendor. Sales agents SHOULD reject creative assignments that lack required verification tags with `CREATIVE_REJECTED`. For example, if the agreed terms include DoubleVerify for viewability, every creative on the package must carry DV tags so viewability can be measured. **Formats without tracker support**: Not all formats accept third-party tracking assets. Broadcast TV spots, for instance, have no tracker slot — there is no pixel to fire on a television. Buyer agents should check the format's `assets` array for tracker slots before proposing performance standards that require creative-level verification. When a format does not support trackers, measurement comes from the vendor declared in `billing_measurement` (panel data, set-top box telemetry) rather than creative-embedded pixels. See [Format definitions](/dist/docs/3.1.20/creative/formats#third-party-tracker-support) for details. ### 7. Breach and Resolution When a performance standard or billing measurement variance is breached, the seller proposes a remedy from the agreed `makegood_policy`: * **`additional_delivery`** — extend or add impressions (like-for-like, same or later campaign) * **`credit`** — credit toward future buys on the same account * **`invoice_adjustment`** — reduce the invoice for the current buy The buyer accepts or disputes. ## Cancellation Policy Cancellation policy is not a negotiation surface. The seller declares it on the product; the buyer accepts it by creating a media buy. A guaranteed buy canceled without sufficient notice incurs the declared cancellation fee. **Cancellation fee types:** * `percent_remaining` — percentage of remaining committed spend (e.g., 50%) * `full_commitment` — buyer owes the full committed budget * `fixed_fee` — flat monetary amount * `none` — no cancellation fee ## Insertion Orders The insertion order is a signing wrapper around a committed proposal. It does not introduce new deal terms. All negotiated terms live on products and packages. The IO's `terms` object provides summary fields (advertiser, publisher, budget, dates, payment terms) so buyer agents can verify the IO matches the proposal before a human signs via DocuSign or similar. ## Vendor Identity and Measurement Agents All measurement and verification vendors are identified by domain using the standard [brand reference](/dist/docs/3.1.20/brand-protocol/brand-json) — the same system used for brands, operators, and accounts. Examples: * `{ "domain": "doubleverify.com" }` — DoubleVerify * `{ "domain": "integralads.com" }` — IAS * `{ "domain": "oracle.com", "brand_id": "moat" }` — MOAT * `{ "domain": "campaignmanager.google.com" }` — Google Campaign Manager * `{ "domain": "admanager.google.com" }` — Google Ad Manager * `{ "domain": "videoamp.com" }` — VideoAmp (broadcast/CTV measurement) * `{ "domain": "comscore.com" }` — Comscore (cross-platform measurement) The vendor's `brand.json` at their domain is the discovery point for their agent capabilities. Vendors declare agents in the `agents` array with `type: "measurement"`: ```json theme={null} { "house": { "domain": "doubleverify.com", "name": "DoubleVerify", "agents": [ { "type": "measurement", "url": "https://api.doubleverify.com/adcp/measurement", "id": "dv_measurement" } ] } } ``` This follows the same pattern used for all agent types in brand.json — brand, rights, governance, creative, buying, and signals agents are all discovered the same way. The buyer or seller queries the measurement agent for current rates against agreed performance standards, rather than waiting for post-campaign reporting. The measurement vendor participates as an agent, not a black box. ### Relationship to Content Standards [Content standards agents](/dist/docs/3.1.20/governance/content-standards) validate WHAT was delivered (brand safety, content categorization). Performance standards measure HOW WELL it was delivered (viewability rates, IVT rates, completion rates). The vendor may be the same company — DoubleVerify provides both brand safety scoring and viewability measurement — but the concerns are distinct: * **Content standards**: `validate_content_delivery` — "was this ad placed next to safe content?" * **Performance standards**: measurement agent — "what percentage of impressions were viewable?" Both use agent-to-agent workflows. Content standards are already fully specified. Measurement agent interfaces are a follow-up to this specification. # Accounts & Security Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/advanced-topics/accounts-and-security AdCP accounts and security — authentication, rate cards, billing entities, and data isolation for multi-tenant media buying between buyer and seller agents. An **Account** represents a billing relationship between a buyer and a seller in AdCP. Sales agents use accounts to determine pricing (rate cards), billing entities, and to enforce data isolation between different buyers. ## Authentication All requests must be authenticated using a bearer token in the standard `Authorization` header: ``` Authorization: Bearer ``` The server validates this token and identifies the **agent** making the request. The agent may have access to one or more accounts. See [Authentication](/dist/docs/3.1.20/building/by-layer/L2/authentication) for details on obtaining credentials and authentication methods. ### Agents and Accounts AdCP distinguishes between: * **Agent**: The authenticated entity making API calls (e.g., `"pinnacle_trading_desk"`) * **Account**: The billing relationship for a media buy (e.g., `"acme_c/o_pinnacle"`) An agent may operate on multiple accounts. For example, an agency trading desk might manage accounts for multiple advertisers and their own house account. See [Accounts and Agents](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents) for details. ## Data Isolation Authentication provides the foundation for strict data isolation. Sales agents **MUST** enforce the following rules: 1. When an object like a `MediaBuy` is created, it **MUST** be permanently associated with the account used for that request. 2. For any subsequent request to read or modify that object, the server **MUST** verify that the agent has access to that account. 3. If the agent does not have access, the server **MUST** return a permission denied error. This model ensures that one account's data cannot be accessed by agents who lack authorization. Passing an `account_id` for an account you don't have access to will result in an error. ## Security Requirements For the full normative implementation reference — two-step authorization, row-level security, IDOR defense, and the wider security posture (webhooks, idempotency, signed governance context) — see [Security — Agent and Account Isolation](/dist/docs/3.1.20/building/by-layer/L1/security#agent-and-account-isolation). ### Required Security Measures Sales agent implementations **MUST**: * Validate bearer tokens on every authenticated request * Enforce account-based data isolation * Use TLS for all communications * Log authentication failures for security monitoring ### Recommended Security Measures Sales agent implementations **SHOULD**: * Implement rate limiting per agent and account * Support token expiration and refresh * Provide audit logging for compliance * Support IP allowlisting for high-security accounts # Agentic eXecution Engine (AXE) Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/advanced-topics/agentic-execution-engine Agentic eXecution Engine (AXE) — AdCP's real-time execution layer for brand suitability enforcement, frequency capping, and dynamic audience targeting at impression time. AXE is deprecated. The [Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match) replaces AXE with structural privacy separation, multi-surface support (web, mobile, CTV, AI assistants, retail media), and a standardized offer model. New integrations should use TMP. Existing AXE integrations will continue to work — the `axei`/`axex`/`axem` segment model maps to TMP's offers and signals. The Agentic eXecution Engine (AXE) is AdCP's original real-time execution layer that enables dynamic audience targeting, brand suitability enforcement, and frequency management at impression time. AXE is how AdCP reaches impression-time execution. It enables cross-publisher frequency capping the same way OpenRTB enables programmatic decisioning: by giving the buyer or orchestrator a real-time look at each impression before the ad server decides whether to serve. ## Two-Phase Workflow AXE operates in two distinct phases: **offline campaign setup** and **real-time ad serving**. ### Phase 1: Offline Setup Before ads serve, campaigns are configured and segment data is synchronized: ```mermaid theme={null} flowchart TB subgraph offline["Offline Setup Phase"] direction TB buyer["**Buyer Agent**
• Campaigns
• Budgets
• Targeting"] signal["**Signal Agent**
• Audiences
• Contextual
• Suitability"] orch["**Orchestrator**
• Map to AXE segments
• Sync to RTD"] sales["**Sales Agent**
Create line items
with AXE targeting"] buyer --> orch signal --> orch orch --> sales end ``` **What happens:** 1. **Buyer Agent** creates campaigns with targeting and budgets via AdCP 2. **Signal Agents** attach contextual data (audiences, brand suitability rules, weather triggers) 3. **Orchestrator** maps campaigns to AXE segments and syncs data to the real-time module 4. **Sales Agent** creates ad server line items targeting AXE segment key-values ### Phase 2: Real-Time Serving When an ad request arrives, AXE evaluates it in real-time and returns segment decisions: ```mermaid theme={null} flowchart TB subgraph realtime["Real-Time Serving Phase"] direction TB page["**Page**
User visits"] adserver1["**Ad Server**
GAM / Kevel"] prebid["**Prebid**
OpenRTB request"] axe["**AXE**
Segment lookup"] page --> adserver1 --> prebid --> axe axeresponse["**AXE Response**
axei: Include segment
axex: Exclude (suitability)
axem: Creative macro data"] axe --> axeresponse adserver2["**Ad Server**
Match segments to line items
→ Serve ad"] axeresponse --> adserver2 end ``` **What happens:** 1. User visits page, triggering ad request 2. Ad server initiates request to Prebid (or similar) 3. Prebid sends OpenRTB bid request to AXE 4. AXE evaluates user/context and returns segment values 5. Ad server matches segments to line items and serves appropriate ad ## AXE Segment Types AXE returns three types of segment values to the ad server: | Segment | Key | Purpose | Example | | ----------- | ------ | ----------------------------------------------------- | ---------------------------- | | **Include** | `axei` | Audience targeting - user belongs to this segment | `"seg_auto_intenders"` | | **Exclude** | `axex` | Brand suitability/suppression - block this impression | `"unsafe_content"` | | **Macro** | `axem` | Creative personalization data | `"eyJjb250ZXh0IjoiLi4uIn0="` | ### How Segments Flow to Creatives ```json theme={null} { "packages": [{ "product_id": "premium_video", "targeting_overlay": { "axe_include_segment": "seg_auto_intenders_q1", "axe_exclude_segment": "seg_existing_customers" } }] } ``` At impression time: * `axei` is checked against `axe_include_segment` → must match to serve * `axex` is checked against `axe_exclude_segment` → must NOT match to serve * `axem` is passed to creative via the `{AXEM}` macro ## Data Flow Example Here's a concrete example of AXE in action for a customer acquisition campaign: ### Setup (Offline) **1. Buyer uploads suppression list:** ``` Advertiser CRM → Hash emails (SHA256) → Upload to orchestrator → Receive segment ID: "seg_existing_customers_acme" ``` **2. Create media buy with AXE targeting:** ```json theme={null} { "packages": [{ "product_id": "premium_video_millennials", "budget": { "amount": 50000 }, "targeting_overlay": { "axe_exclude_segment": "seg_existing_customers_acme" } }] } ``` **3. Sales Agent creates line item:** ``` Line item: "Acme Q1 Acquisition" Targeting: axex != "seg_existing_customers_acme" ``` ### Serving (Real-Time) **4. User visits publisher site:** ``` GET /ad-request User-Agent: Mozilla/5.0... Cookie: uid=abc123 ``` **5. AXE lookup:** ``` Input: uid=abc123 Check: Is abc123 in seg_existing_customers_acme? Result: YES (hashed email matches) ``` **6. AXE response:** ```json theme={null} { "axei": null, "axex": "seg_existing_customers_acme", "axem": null } ``` **7. Ad server decision:** ``` Line item requires: axex != "seg_existing_customers_acme" Current axex: "seg_existing_customers_acme" Decision: DO NOT SERVE (user is existing customer) ``` **Result:** Acquisition budget is not wasted on existing customers. ## Core Capabilities ### 1. Dynamic Audience Targeting Bring your own DMP/CDP segments to publisher inventory: * Upload audience data (hashed emails, device IDs, etc.) * Receive segment IDs from your orchestrator * Reference segment IDs in `axe_include_segment` * AXE matches users at impression time **Use cases:** Lookalike targeting, CRM activation, behavioral segments ### 2. Brand Suitability Real-time content evaluation at impression time: * **Content classification** - News, entertainment, sports, etc. * **Sentiment analysis** - Positive/negative content detection * **Keyword blocking** - Brand-specific term avoidance * **Adjacency rules** - What other ads are on the page Brand suitability rules flow from Signal Agents through the orchestrator to AXE. ### 3. Cross-Publisher Frequency Management Unlike publisher-side caps, AXE tracks exposure across: * Multiple publishers * Multiple campaigns * Multiple devices (with identity resolution) AXE enforces frequency caps and returns segment decisions to the ad server. The ad server doesn't know *why* a segment matched or didn't match—it just knows whether to serve. ### How Cross-Publisher Frequency Capping Works Cross-publisher frequency capping is now handled by the [Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match), which uses structurally separated Context Match and Identity Match operations. The AXE segment model maps to TMP's offers and eligibility responses. In the AXE model, every eligible impression is checked in real time against shared exposure state: ```mermaid theme={null} flowchart LR pubA["**Publisher A**
Impression opportunity"] pubB["**Publisher B**
Impression opportunity"] pubC["**Publisher C**
Impression opportunity"] req["**OpenRTB-style request**
user, placement, context"] axe["**AXE**
Evaluate cap eligibility"] state["**Exposure store**
cross-publisher history"] decision["**Decision**
serve or suppress"] pubA --> req pubB --> req pubC --> req req --> axe state --> axe axe --> decision ``` In TMP, this same pattern is achieved with structural privacy: the Identity Match path handles frequency caps (the buyer checks exposure history without knowing what page the user is on), and the Context Match path handles content relevance (the buyer evaluates packages without knowing who the user is). The publisher joins both responses locally. ### 4. First-Party Data Activation Activate your customer data without sharing PII: 1. Hash customer identifiers (emails, phone numbers) 2. Upload to orchestrator (data stays with orchestrator) 3. Reference via segment ID in campaigns 4. AXE matches at impression time 5. Publisher never sees raw customer data ## Privacy by Design: Opaque Segment IDs A key AXE design principle is that **segment IDs are intentionally opaque**. The ad server only sees that segment `ABCD` passed or failed—it doesn't know what type of targeting the segment represents. This could mean: * User exceeded frequency cap * Page failed brand suitability check * User is in a first-party suppression list * User matches an audience segment This opacity protects buyer data. Publishers and ad servers cannot reverse-engineer: * Which users are in buyer CRM lists * Frequency cap thresholds * Brand suitability rules * Audience segment definitions All the ad server knows is: "AXE says serve" or "AXE says don't serve." ## Integration Points ### For Buyers | Step | Action | Result | | ---- | ---------------------------------------- | ----------------------------------- | | 1 | Upload audience to orchestrator | Receive segment ID | | 2 | Include segment ID in `create_media_buy` | Campaign created with AXE targeting | | 3 | Monitor delivery reports | Track segment match rates | ### For Publishers Publishers don't implement AXE directly — the orchestrator handles integration (see [How AXE Reaches the Page](#how-axe-reaches-the-page)). Publisher responsibilities: 1. **Enable the orchestrator's integration** - Add RTD module (Prebid) or enable platform integration 2. **Accept key-value targeting** - Pass `axei`, `axex` values to ad server 3. **Configure line items** - Target on AXE segment key-values 4. **Declare support** - Indicate AXE compatibility in `adagents.json` ### For Orchestrators Orchestrators operate the AXE layer: 1. **Segment ingestion** - Accept audience data from buyers 2. **Real-time lookups** - Sub-10ms segment membership checks 3. **Signal integration** - Apply brand suitability and contextual signals 4. **Frequency state** - Maintain cross-campaign exposure tracking 5. **Ad platform integration** - Expose segments via Prebid RTD module, platform container, or server-side endpoint ## How AXE Reaches the Page AXE is a protocol-level concept. **Orchestrators implement AXE** and integrate it into ad serving environments. The integration path depends on the ad platform: | Integration Path | How It Works | Example | | --------------------------- | ----------------------------------------------------------------------------------- | ---------------------------- | | **Prebid RTD module** | Orchestrator distributes a Prebid module that calls the AXE endpoint during auction | `exampleRtdProvider` | | **Proprietary ad platform** | AXE runs as a container or secure enclave within the platform's infrastructure | Platform-native integration | | **Server-side** | AXE endpoint called server-to-server by the ad platform before decisioning | Custom ad server integration | The common thread: whatever the integration path, AXE evaluates segments and returns `axei`/`axex`/`axem` decisions that the ad platform uses for targeting. For cross-publisher frequency capping, those impression-time calls are what let the buyer apply shared exposure rules across sellers instead of relying on one publisher's local ad server counter. ### The Chain: Orchestrator → AXE Endpoint → Segment Targeting ``` Ad platform calls orchestrator's AXE endpoint → AXE evaluates segments and returns axei/axex/axem values → Values used for targeting decisions (key-values, container logic, etc.) → Matching campaigns serve ``` The `axe_integrations` URL in a seller's [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) response tells buyers which orchestrator's AXE endpoint the seller connects to: ```json theme={null} { "media_buy": { "execution": { "axe_integrations": ["https://axe.example.com"] } } } ``` ### Prebid Integration (Web) For web publishers using Prebid, AXE integrates via the orchestrator's RTD module. The module name in Prebid matches the orchestrator, not "AXE": ```javascript test=false theme={null} // Prebid build includes: rtdModule, exampleRtdProvider, ...other modules pbjs.setConfig({ realTimeData: { auctionDelay: 100, dataProviders: [{ name: 'example', // Orchestrator's module name waitForIt: true, params: { // Orchestrator-specific configuration } }] } }); ``` The orchestrator's RTD module: 1. Intercepts the auction before bids are requested 2. Sends an OpenRTB-style request to the AXE endpoint 3. Receives segment decisions (axei/axex/axem) 4. Sets targeting key-values on the ad server request Publishers don't need to know AXE internals — the orchestrator's module handles everything. ### Proprietary Platform Integration AXE can also run within proprietary ad platforms as a container or secure enclave. In this model: * The orchestrator deploys AXE logic into the platform's infrastructure * Segment evaluation happens within the platform's decisioning pipeline * No external network call is needed at impression time — reducing latency * The platform calls AXE as part of its native ad selection process This is particularly relevant for platforms that don't use Prebid or where latency requirements are stricter than what an external RTD call allows. ### Identifying AXE Support The definitive check is the seller's `get_adcp_capabilities` response. For Prebid-based integrations, you can also inspect the page directly: | What to look for | Where | Meaning | | -------------------------------------------------------------------- | -------------------------------------- | --------------------------------- | | `axe_integrations` in capabilities | `get_adcp_capabilities` response | Seller supports AXE | | `axei`/`axex`/`axem` key-values | Ad server request (network tab) | AXE segments flowing to ad server | | Orchestrator RTD module (e.g., `exampleRtdProvider`) in Prebid build | Page source or `pbjs.installedModules` | AXE via Prebid | | Orchestrator entry in `realTimeData.dataProviders` | `pbjs.getConfig('realTimeData')` | AXE is active | Different orchestrators may implement AXE through different integration paths — the segment protocol (axei/axex/axem) is the same regardless of how AXE is deployed. ## Universal Macro: Creatives can receive AXE context data for dynamic rendering: ```html theme={null} ``` The `{AXEM}` macro contains base64-encoded contextual metadata: * Weather conditions * Content category * User segment attributes (anonymized) * Custom orchestrator data See [Universal Macros](/dist/docs/3.1.20/creative/universal-macros) for details. ## When to Use AXE | Scenario | Use AXE? | Alternative | | ---------------------------------- | -------- | -------------------------------- | | Target users in my CRM | ✅ Yes | — | | Suppress existing customers | ✅ Yes | — | | Cross-publisher frequency cap | ✅ Yes | — | | Real-time brand suitability | ✅ Yes | — | | Target "millennials in California" | ❌ No | Express in brief | | Geographic restrictions | ❌ No | Use `geo_countries` | | Publisher's audience segments | ❌ No | Express in brief | | Single-publisher frequency cap | ❌ No | Publisher ad server handles this | ## Performance AXE is designed for ad serving latency requirements: | Operation | Target Latency | | ---------------------------- | -------------- | | Segment membership lookup | \< 10ms | | Brand suitability evaluation | \< 20ms | | Frequency check | \< 5ms | | Combined AXE decision | \< 50ms | ## Related Documentation * **[Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting)** - Brief-based targeting and geographic overlays * **[Signals Protocol](/dist/docs/3.1.20/signals/overview)** - Signal discovery and activation * **[Universal Macros](/dist/docs/3.1.20/creative/universal-macros)** - Creative-level AXE integration * **[Orchestrator Design](/dist/docs/3.1.20/building/operating/orchestrator-design)** - Building orchestration platforms # Billing Authority Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/advanced-topics/billing-authority How AdCP names the authoritative counter of a media buy's billing metric, marks numbers as final, and reconciles between buyer and seller views when those views disagree. ## Overview For a media buy to invoice deterministically, two questions must have machine-readable answers: 1. **Whose number is authoritative?** Different deals name different parties — the seller's ad server, a buyer's third-party ad server, or a named measurement vendor like Nielsen or IAS. 2. **Has that number stopped moving?** Telemetry settles over hours, days, or weeks (broadcast C3 → C7 DVR accumulation, digital post-IVT scrubbing, podcast 30-day downloads, conversion dedup). Final numbers are billable; provisional numbers are not. AdCP answers both with structured terms already on the wire: `measurement_terms.billing_measurement` (negotiated at `create_media_buy`) names authority; `is_final` / `final` flags with `finalized_at` timestamps (on `get_media_buy_delivery` and `report_usage`) mark closure. This page ties those pieces together. ## Naming authority `measurement_terms.billing_measurement` carries: | Field | Meaning | | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `vendor` (required, [BrandRef](/dist/docs/3.1.20/brand-protocol/brand-json)) | The party whose count of the billing metric governs invoicing. Same field shape covers seller's ad server, buyer's 3PAS, or a third-party vendor — the BrandRef domain disambiguates. | | `max_variance_percent` | Tolerance above which the seller's and authoritative party's counts trigger resolution under `makegood_policy`. IAB default is 10%; broadcast/CTV often 5%. | | `measurement_window` | Which maturation stage of the data is the reconciliation point (`c7`, `post_sivt`, `downloads_30d`, etc.) — references a `window_id` from the product's `reporting_capabilities.measurement_windows`. | | `finalization_deadline_hours` | Maximum hours by which the authoritative party MUST publish a final record. When `measurement_window` is set, hours are counted from the window's close (not from `reporting_period.end`); when absent, from `reporting_period.end`. The deadline applies symmetrically to whichever party is named in `vendor`. On miss, the counterparty MAY fall back to its own attestation; the breach is handled under `makegood_policy`. | Absence is informative: when `billing_measurement` is absent the default is seller-attested, with no contractual finalization deadline. ## Marking numbers as final Final means **settled for the named measurement window** — not "final forever." A broadcast row marked `is_final: true` for `measurement_window: "c3"` is final for C3; a later `c7` row supersedes it with its own provisional → final lifecycle. Disputes (when AdCP 3.2 adds them) join on `(media_buy_id, reporting_period, measurement_window)`. ### On `get_media_buy_delivery` Each row in `media_buy_deliveries[]` carries: * `is_final: boolean` — row-level finality, equivalent to all packages in the row being final for the same window. * `finalized_at: string (date-time)` — present iff `is_final: true`. Anchors any deadline declared in `billing_measurement.finalization_deadline_hours`. * Per-package: `by_package[*].is_final`, `by_package[*].finalized_at`, `by_package[*].measurement_window`, `by_package[*].supersedes_window`. ### On `report_usage` Each usage record carries: * `final: boolean` — **absent means unknown.** Set `true` only when the reporter has actually settled the numbers (e.g., post-SIVT month-end close). Set `false` for preliminary records (daily pacing pushes, intra-period progress). When `measurement_terms.billing_measurement` names this reporter as authoritative, the receiver MUST NOT invoice on `final: false` or absent — request a final record first. For 3.0-style usage with no `billing_measurement` and for non-media-buy variants (signals, governance, creative, brand — domains with no provisional state concept), receivers MAY treat absent as final, preserving existing behavior. * `finalized_at: string (date-time)` — present iff `final: true`. * `measurement_window: string` — SHOULD be set when the buy's `billing_measurement.measurement_window` is set, so the receiver reconciles against the correct stage. When the same `(account, media_buy_id, reporting_period)` is later reported with `final: true`, that record supersedes any prior records for the period. ### Distinguishing webhook finality from row finality `get_media_buy_delivery` webhooks carry a top-level `notification_type` enum that includes `"final"` — this signals **"this is the last scheduled notification for the campaign,"** not that contained rows are final for invoicing. The two axes are independent: a webhook with `notification_type: "final"` may still contain rows where `is_final: false` (e.g., a campaign-end notification before C7 settles). Always check the per-row `is_final` for billing decisions. ## How a seller produces an invoice ### Seller-attested (default) ``` billing_measurement absent, or vendor names seller's own ad server ``` Seller invoices off `get_media_buy_delivery` rows where `is_final: true` for the contracted `measurement_window`. No `report_usage` from the buyer is required. ### Buyer-attested (3PAS) ``` billing_measurement.vendor.domain = "campaignmanager.google.com" (buyer's CM360) billing_measurement.max_variance_percent = 10 billing_measurement.measurement_window = "post_sivt" billing_measurement.finalization_deadline_hours = 240 // 10 days after post_sivt close ``` Sequence: 1. Buyer's CM360 settles post-SIVT for the period. 2. The buyer's operator — in practice a holdco platform (e.g., Choreograph, Annalect, Acxiom) or an in-house agency engineering team that wraps the CM360 export — calls `report_usage` with the media-buy record: `media_buy_id`, `impressions`, `vendor_cost`, `currency`, `final: true`, `finalized_at`, `measurement_window: "post_sivt"`. Daily pacing pushes (if used) set `final: false`; only the settled file sets `final: true`. 3. Seller compares against its own post-SIVT row from `get_media_buy_delivery` (`is_final: true`, same `measurement_window`). If `|seller - buyer| / max ≤ max_variance_percent`, the seller invoices on the buyer's numbers. 4. If variance exceeds threshold, the seller proposes a remedy from `makegood_policy.available_remedies`. 5. If the buyer doesn't publish a final record within `finalization_deadline_hours`, the seller MAY invoice off its own seller-attested numbers and treat the late finalization as a breach under `makegood_policy`. > **Today's reality:** monthly 3PAS reconciliation is mostly handled out-of-band via CSV/PDF emailed to the seller's AR team. This flow is the wire-format upgrade — the first adopters are likely holdco operators with the engineering to wrap CM360 / Flashtalking / Innovid exports in `report_usage`, working with sympathetic direct publishers rather than RTB SSPs. ### Vendor-attested (named third party) ``` billing_measurement.vendor.domain = "nielsen.com" billing_measurement.max_variance_percent = 5 billing_measurement.measurement_window = "c7" billing_measurement.finalization_deadline_hours = 528 // 22 days after c7 close ``` Two operational patterns, depending on who holds the vendor relationship: * **Seller holds the vendor relationship (the common CTV pattern).** The seller pulls C7 numbers from Nielsen (NPower, etc.) on its own schedule and publishes them on `get_media_buy_delivery` as a row with `measurement_window: "c7"`, `is_final: true`, and `finalized_at` set when the C7 window closes plus its internal processing. No `report_usage` push from the buyer is needed; reconciliation happens against the seller's row. * **Buyer holds the vendor relationship.** The buyer (or its operator) fetches the vendor's authoritative numbers — e.g., an agency's iSpot subscription, an in-house IAS dashboard export — and pushes them via `report_usage` against the buy's account with `final: true`, `finalized_at`, and `measurement_window: "c7"`. The vendor itself does not call AdCP. The `vendor.domain` BrandRef identifies who the contract names, not who calls the API. The vendor's own integration (NPower feed, IAS API, DV pinnacle) is out of AdCP scope. > **Today's reality:** SSPs running RTB will keep being seller-attested for the foreseeable future — their billing systems were never designed for buyer-attested invoice basis and `max_variance_percent` plus `makegood_policy` is not enough commercial cover for them to retrofit it. The first real adopters here are CTV direct sellers (e.g., Disney, NBCU, Paramount) whose Nielsen-backed guarantees already invoice on a measurement vendor's count — for them this PR just describes existing practice in structured terms. ## Resolving disagreements today When numbers disagree beyond `max_variance_percent`, AdCP 3.0–3.1 expects out-of-band resolution between counterparties, backed by: * The buy's `makegood_policy.available_remedies` — the menu of remedies (additional delivery, credit, invoice adjustment) the seller has pre-committed to. * Final records on both sides with `finalized_at` timestamps — a full audit trail of who said what was final, when. * The original `committed_metrics` and `measurement_terms` captured at `create_media_buy` — what the parties signed up for. A structured dispute task — opening a dispute on the wire, transitioning it through `under_review` / `seller_proposed_adjustment` / `buyer_accepted` / `unresolved_arbitration`, and recording resolution in the audit log — is targeted for AdCP 3.2. The data shape needed to dispute (final records, attestation, measurement window, makegood menu) is already on the wire in 3.1. ## Worked example: buyer-attested 3PAS reconciliation ```json title="create_media_buy excerpt — measurement terms" theme={null} { "measurement_terms": { "billing_measurement": { "vendor": { "domain": "campaignmanager.google.com" }, "max_variance_percent": 10, "measurement_window": "post_sivt", "finalization_deadline_hours": 240 }, "makegood_policy": { "available_remedies": ["additional_delivery", "credit", "invoice_adjustment"] } } } ``` ```json title="get_media_buy_delivery — seller's final post-SIVT row" theme={null} { "media_buy_deliveries": [ { "media_buy_id": "mb_q1_2026", "is_final": true, "finalized_at": "2026-04-08T18:00:00Z", "by_package": [ { "package_id": "pkg_001", "is_final": true, "finalized_at": "2026-04-08T18:00:00Z", "measurement_window": "post_sivt", "impressions": 5120000, "spend": 51200 } ] } ] } ``` ```json title="report_usage — buyer's final 3PAS push" theme={null} { "idempotency_key": "f9b3...e2a1", "reporting_period": { "start": "2026-03-01T00:00:00Z", "end": "2026-03-31T23:59:59Z" }, "usage": [ { "account": { "account_id": "acct_acme_seller" }, "media_buy_id": "mb_q1_2026", "currency": "USD", "impressions": 5040000, "vendor_cost": 50400, "final": true, "finalized_at": "2026-04-09T14:32:00Z", "measurement_window": "post_sivt" } ] } ``` Variance: `|5120000 - 5040000| / 5120000 = 1.56%` — well under the 10% threshold. Seller invoices on the buyer's 5.04M impressions × the agreed rate. Both sides retain audit-traceable final records. ## Related * [`measurement-terms`](https://adcontextprotocol.org/schemas/3.1.20/core/measurement-terms.json) — schema * [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) — seller-side finality flags * [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) — buyer-side / third-party finality flags * [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability) — performance standards, makegood remedies, cancellation * [Reporting capabilities and measurement windows](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) # Pricing Models Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models AdCP pricing models — CPM, CPCV, CPP, CPC, CPA, time-based, and DOOH pricing. Publisher-declared, buyer-selected model with rate card support. AdCP supports multiple pricing models to accommodate different advertising channels and business objectives. Publishers declare which pricing models they support, and buyers select from available options. ## Publisher-Declared, Buyer-Selected Model ### How It Works 1. **Publishers declare pricing options** in their products via `pricing_options` array (each with unique `pricing_option_id`) 2. **Buyers discover available options** through `get_products` 3. **Buyers select a specific option** when creating a media buy via `pricing_option_id` 4. **Delivery is measured** according to the declared `delivery_measurement` provider ### Key Benefits * **Flexibility**: Publishers can offer multiple pricing models for the same inventory * **Currency Support**: Publishers specify supported currencies; buyers must match * **Market Standards**: Each channel (TV, video, display, performance) can use its natural pricing unit * **Clear Expectations**: Both parties agree on pricing before campaign launch ## Measurement & Source of Truth ### Measurement Provider as Source of Truth **The product declares the measurement provider, and the buyer accepts that provider as the source of truth for the buy.** Publishers specify their measurement provider in the product: ```json theme={null} { "product_id": "premium_video", "delivery_measurement": { "provider": "Google Ad Manager with IAS viewability verification", "notes": "MRC-accredited viewability measurement. 50% in-view for 1 second (display) or 2 seconds (video)." } } ``` **Common Measurement Providers:** * **Ad Servers**: Google Ad Manager, Freewheel, SpringServe * **Attention Metrics**: Adelaide, Lumen, TVision * **Third-Party Verification**: IAS, DoubleVerify, Scope3 * **TV/Audio Measurement**: Nielsen, Comscore, iSpot.tv, Triton Digital * **DOOH**: Geopath, Vistar, Place Exchange By accepting the product, buyers agree to use the declared measurement provider as the authoritative source for delivery metrics. ### Measurement Terms and Performance Standards Beyond declaring a provider, sellers can publish structured terms on products — and buyers can negotiate them at buy creation. These are two separate concerns: * **`measurement_terms`** — Who counts the billing metric and what happens when thresholds are breached (makegoods) * **`performance_standards`** — What rate thresholds apply (viewability, IVT, completion, brand safety, attention) ```json theme={null} { "product_id": "premium_guaranteed_video", "delivery_measurement": { "provider": "Google Ad Manager with DoubleVerify verification", "notes": "MRC-accredited viewability. DV IVT filtering enabled." }, "measurement_terms": { "billing_measurement": { "vendor": { "domain": "admanager.google.com" }, "max_variance_percent": 10 }, "makegood_policy": { "available_remedies": ["additional_delivery", "credit", "invoice_adjustment"] } }, "performance_standards": [ { "metric": "viewability", "threshold": 0.70, "standard": "mrc", "vendor": { "domain": "doubleverify.com" } }, { "metric": "ivt", "threshold": 0.05, "vendor": { "domain": "doubleverify.com" } }, { "metric": "completion_rate", "threshold": 0.80, "vendor": { "domain": "doubleverify.com" } } ] } ``` **Measurement terms fields:** * **`billing_measurement`** — Which vendor's count of the billing metric (determined by `pricing_model`) governs invoicing. `max_variance_percent` defines the threshold at which the non-billing party's count divergence triggers resolution. * **`makegood_policy`** — Closed menu of remedies available when any performance standard or billing variance is breached. Three remedy types: `additional_delivery` (extend/add impressions, like-for-like), `credit` (toward future buys), `invoice_adjustment` (reduce current invoice). Seller proposes from this menu; buyer accepts or disputes. **Performance standards fields:** Each entry in the `performance_standards` array specifies: * **`metric`** — What is measured: `viewability`, `ivt`, `completion_rate`, `brand_safety`, `attention_score` * **`threshold`** — Rate as a decimal (0-1). Whether this is a floor or ceiling depends on the metric: viewability, completion\_rate, brand\_safety, attention\_score are floors (must exceed); ivt is a ceiling (must not exceed). * **`standard`** — Required for viewability (`"mrc"` or `"groupm"`). Omit for other metrics. * **`vendor`** — Who measures it, identified by domain. When agreed on a confirmed package, creatives MUST include tracker assets from specified vendors. **Negotiation flow:** 1. Seller publishes `measurement_terms` and `performance_standards` on the product (defaults) 2. Buyer proposes overrides on the package request at `create_media_buy` 3. Seller accepts (echoes on confirmed package), rejects (`TERMS_REJECTED`), or adjusts (returns modified terms) 4. If a threshold is breached, seller proposes a remedy from the agreed `makegood_policy`; buyer accepts or disputes When the buyer omits these fields, the product's defaults apply. ### Cancellation Policy Guaranteed products can declare a `cancellation_policy` with the minimum notice period required before cancellation takes effect: ```json theme={null} { "product_id": "premium_guaranteed_video", "delivery_type": "guaranteed", "cancellation_policy": { "notice_period": { "interval": 30, "unit": "days" }, "cancellation_fee": { "type": "percent_remaining", "rate": 0.5 } } } ``` **Cancellation fee types:** * **`percent_remaining`** — Percentage of remaining committed spend (requires `rate`, e.g., `0.5` for 50%) * **`full_commitment`** — Buyer owes the full committed budget regardless of delivery * **`fixed_fee`** — Flat monetary amount (requires `amount` in the buy's currency) * **`none`** — No cancellation fee (cancellation with notice is free) Unlike `measurement_terms`, cancellation policy is not a negotiation surface — the seller declares it and the buyer accepts it by creating a media buy against the product. A guaranteed buy canceled without sufficient notice incurs the declared cancellation fee. ### Best Practices **For Publishers:** * Clearly identify your measurement provider (ad server and any third-party verification) * Explain your measurement methodology in plain language * For DOOH, specify your audience measurement source (e.g., Geopath, venue sensors) **For Buyers:** * Review the measurement provider before committing budget * Ensure the provider meets your campaign requirements * Negotiate audit rights in contracts if needed ## Supported Pricing Models ### CPM (Cost Per Mille) **Cost per 1,000 impressions** - Traditional display advertising pricing. **Use Cases**: Display, native, banner advertising **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpm-option.json", "pricing_option_id": "cpm_usd_guaranteed", "pricing_model": "cpm", "fixed_price": 12.50, "currency": "USD", "min_spend_per_package": 5000 } ``` **Billing**: Charged per 1,000 ad impressions served *** ### vCPM (Viewable Cost Per Mille) **Cost per 1,000 viewable impressions** - Payment only for impressions meeting MRC viewability standard. **Use Cases**: Display, native, video advertising with viewability guarantee **Viewability Standard**: MRC (Media Rating Council) standard requires: * **Display ads**: 50% of pixels in-view for at least 1 continuous second * **Video ads**: 50% of pixels in-view for at least 2 continuous seconds **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/vcpm-option.json", "pricing_option_id": "vcpm_usd_guaranteed", "pricing_model": "vcpm", "fixed_price": 18.50, "currency": "USD", "min_spend_per_package": 5000 } ``` **Billing**: Charged per 1,000 viewable impressions (impressions meeting MRC viewability threshold). Viewability is measured by the declared measurement provider. **Measurement**: When available, publishers declare their viewability measurement provider in the optional `delivery_measurement` field. Common providers include IAS (Integral Ad Science), DoubleVerify, MOAT, and Google Active View. *** ### CPCV (Cost Per Completed View) **Cost per 100% video/audio completion** - Payment only for fully completed views. **Use Cases**: Video campaigns, audio ads, pre-roll video **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpcv-option.json", "pricing_option_id": "cpcv_usd_guaranteed", "pricing_model": "cpcv", "fixed_price": 0.15, "currency": "USD" } ``` **Billing**: Charged only when viewer completes 100% of the video/audio ad. Completion is measured by the declared measurement provider. *** ### CPV (Cost Per View) **Cost per view at threshold** - Payment when viewer reaches publisher-defined threshold. **Use Cases**: Video campaigns with shorter completion requirements **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpv-option.json", "pricing_option_id": "cpv_usd_50pct", "pricing_model": "cpv", "fixed_price": 0.08, "currency": "USD", "parameters": { "view_threshold": 0.5 } } ``` **Billing**: Charged when viewer reaches threshold (e.g., 50% completion, 30 seconds) **Parameters**: * `view_threshold`: Decimal from 0.0 to 1.0 (e.g., 0.5 = 50% completion) *** ### CPP (Cost Per Point) **Cost per Gross Rating Point** - Traditional TV/radio buying metric. **Use Cases**: Connected TV, linear TV, radio, audio streaming **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpp-option.json", "pricing_option_id": "cpp_usd_a18-49", "pricing_model": "cpp", "fixed_price": 250.00, "currency": "USD", "parameters": { "demographic": "A18-49", "min_points": 50 }, "min_spend_per_package": 12500 } ``` **Billing**: Charged per rating point delivered to target demographic **Parameters**: * `demographic`: Target demographic (e.g., "A18-49", "W25-54", "M35+") * `min_points`: Minimum GRP commitment required **Metrics Reported**: * `grps`: Total Gross Rating Points delivered * `reach`: Unique individuals reached * `frequency`: Average frequency per individual **Measurement Requirements**: CPP pricing requires **certified demographic measurement**. Publishers should declare their measurement provider: ```json theme={null} { "pricing_model": "cpp", "fixed_price": 250.00, "delivery_measurement": { "provider": "Nielsen DAR", "notes": "Panel-based demographic measurement for A18-49. GRP reports available weekly." } } ``` **Common Measurement Providers for CPP**: * **Nielsen DAR/TV**: Industry-standard TV measurement * **Comscore**: Campaign Ratings for CTV * **iSpot.tv**: Advanced TV analytics * **Triton Digital**: Audio/streaming measurement Buyers should verify the measurement provider meets their campaign requirements before accepting CPP deals. *** ### CPC (Cost Per Click) **Cost per click** - Performance-based pricing for engagement. **Use Cases**: Direct response campaigns, search ads, social advertising **Example**: ```json theme={null} { "pricing_model": "cpc", "currency": "USD", "floor_price": 0.50, "max_bid": true, "price_guidance": { "p50": 1.20, "p75": 2.00 } } ``` **Billing**: Charged only when user clicks the ad *** ### CPA (Cost Per Acquisition) **Cost per conversion event** - Advertiser pays when a defined conversion occurs. **Use Cases**: Retail media (pay per order), lead generation, app install campaigns, commerce media **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/cpa-option.json", "pricing_option_id": "cpa_usd_purchase", "pricing_model": "cpa", "event_type": "purchase", "fixed_price": 5.00, "currency": "USD" } ``` **Billing**: Charged a fixed price when the specified `event_type` fires. The pricing option declares what event triggers billing — this is independent of `optimization_goals`, which controls delivery optimization. **Parameters**: * `event_type` (required): The conversion event that triggers billing. Uses the standard event type enum (e.g., `purchase`, `lead`, `app_install`, `add_to_cart`, `subscribe` for paid subscriptions, `follow` for free durable opt-ins). * `event_source_id` (optional): When present, only events from this specific source count toward billing. Must match an event source configured via `sync_event_sources`. When omitted, any event of the specified `event_type` counts. **Example** (different rates by event source): ```json theme={null} { "pricing_options": [ { "pricing_option_id": "cpa_online_purchase", "pricing_model": "cpa", "event_type": "purchase", "event_source_id": "website_pixel", "fixed_price": 5.00, "currency": "USD" }, { "pricing_option_id": "cpa_instore_purchase", "pricing_model": "cpa", "event_type": "purchase", "event_source_id": "instore_attribution", "fixed_price": 3.00, "currency": "USD" } ] } ``` **Pricing vs. optimization**: The CPA pricing option's `event_type` (what triggers billing) is independent of the package's `optimization_goals` (what the platform optimizes delivery toward). For example, a package can use CPA pricing on `lead` events while setting an event goal with `event_sources` containing purchase events and `target: { kind: "per_ad_spend", value: 4.0 }` — billing fires on leads, but delivery is optimized for downstream purchase return. **Refunds and adjustments**: Refund handling and conversion adjustment policies are commercial terms between buyer and seller. The protocol does not govern clawbacks or billing credits for refunded conversions. **Note**: CPA replaces the need for separate "CPO" (Cost Per Order) or "CPL" (Cost Per Lead) pricing models. A seller can offer multiple CPA options with different event types, event sources, and prices on the same product. *** ### Flat Rate **Fixed cost** - Single payment regardless of delivery volume. **Use Cases**: Sponsorships, takeovers, exclusive placements, branded content, DOOH fixed slots **Example** (sponsorship): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/flat-rate-option.json", "pricing_option_id": "flat_rate_usd_sponsorship", "pricing_model": "flat_rate", "fixed_price": 50000.00, "currency": "USD" } ``` **Example** (DOOH slot with parameters): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/flat-rate-option.json", "pricing_option_id": "flat_rate_usd_dooh_morning", "pricing_model": "flat_rate", "fixed_price": 5000.00, "currency": "USD", "parameters": { "type": "dooh", "daypart": "morning_commute", "duration_hours": 4, "sov_percentage": 25, "loop_duration_seconds": 15, "estimated_impressions": 120000 } } ``` **Billing**: Fixed cost for the entire campaign period regardless of delivery volume. **DOOH parameters** (`parameters.type: "dooh"`): * `sov_percentage`: Guaranteed share of voice as a percentage (0-100) * `loop_duration_seconds`: Duration of ad loop rotation in seconds * `min_plays_per_hour`: Minimum guaranteed plays per hour * `venue_package`: Named collection of screens * `duration_hours`: Duration of the slot in hours (e.g., 24 for a full-day takeover) * `daypart`: Named daypart (e.g., `morning_commute`, `evening_rush`) * `estimated_impressions`: Audience impression estimate (informational, not a delivery guarantee) Sponsorship flat\_rate options omit `parameters` — the fixed price covers the placement without DOOH-specific constraints. *** ### Time (Cost Per Time Unit) **Cost per time unit** - Rate scales with campaign duration, enabling self-serve sponsorships. **Use Cases**: Homepage takeovers, section sponsorships, premium placements where price depends on booking duration **Example**: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/pricing-options/time-option.json", "pricing_option_id": "time_usd_daily", "pricing_model": "time", "fixed_price": 50000.00, "currency": "USD", "parameters": { "time_unit": "day", "min_duration": 1, "max_duration": 30 } } ``` **Billing**: Cost = `fixed_price` x number of `time_unit`s in the campaign flight. For example, a 3-day campaign at \$50,000/day = \$150,000 total. **Parameters**: * `time_unit` (required): `"hour"`, `"day"`, `"week"`, or `"month"` * `min_duration` (optional): Minimum booking duration in time units * `max_duration` (optional): Maximum booking duration in time units **Time Unit Calculation**: | Time Unit | Calculation | | --------- | ----------------------------------------- | | `hour` | rate x hours in flight | | `day` | rate x calendar days in flight | | `week` | rate x weeks (seller-defined rounding) | | `month` | rate x months (seller-defined pro-rating) | **Comparison with Flat Rate**: | Aspect | Flat Rate | Time | | ------------------- | ----------------------- | ----------------------- | | Semantics | Fixed total cost | Rate x duration | | `fixed_price` means | Total campaign cost | Cost per time unit | | Buyer flexibility | Must negotiate duration | Self-serve any duration | | Use case | Fixed sponsorships | Scalable sponsorships | *** ## Digital Out-of-Home (DOOH) Pricing DOOH advertising uses existing pricing models—typically **CPM** or **flat\_rate**—with optional parameters to describe the inventory allocation. ### Basic Concepts * **CPM for DOOH**: Priced per thousand impressions, where impressions are calculated based on venue traffic (e.g., Geopath data) * **Flat rate for DOOH**: Fixed cost for specific duration or allocation (hourly, daily, or exclusive takeover) ### Simple Example: Billboard Takeover ```json theme={null} { "product_id": "billboard_takeover", "name": "Premium Billboard - 24 Hour Takeover", "pricing_options": [{ "pricing_model": "flat_rate", "fixed_price": 50000.00, "currency": "USD" }], "delivery_measurement": { "provider": "Geopath", "notes": "Venue traffic data updated monthly. Estimated 2.5M impressions over 24 hours." } } ``` ### DOOH parameters (optional) For flat\_rate DOOH inventory, publishers may include a `parameters` object with `"type": "dooh"`. See the [flat\_rate pricing model](#flat-rate) section above for a complete example and full field list. **Note**: DOOH measurement and buying practices vary by market. Publishers should clearly explain their measurement methodology and inventory allocation in the product description and `delivery_measurement` field. *** ## Price Breakdown When buyer and seller have negotiated discounts and commissions, the `price_breakdown` object discloses how `fixed_price` was derived from the rate card. This is optional — sellers who don't need breakdowns omit it entirely, with zero overhead to existing implementations. `price_breakdown` is a planning-layer construct. It exists on pricing options and confirmed packages within AdCP — it does not propagate into impression-level OpenRTB bid requests or responses. ### Structure ```json theme={null} { "pricing_option_id": "cpm_eur_premium", "pricing_model": "cpm", "fixed_price": 11.90, "currency": "EUR", "price_breakdown": { "list_price": 14.00, "adjustments": [ { "kind": "discount", "name": "volume", "rate": 0.15, "description": "12x frequency discount" }, { "kind": "commission", "name": "agency", "rate": 0.15, "description": "Agency commission" }, { "kind": "settlement", "name": "cash_discount", "rate": 0.02, "description": "2% discount for payment within 10 days" } ] } } ``` * `list_price` — the rate card or base price before any adjustments * `adjustments` — ordered list of adjustments, each categorized by `kind` * `fixed_price` (on the parent pricing option) — must equal the result of applying all `fee` and `discount` adjustments sequentially to `list_price` Implementations should ignore unrecognized fields in `price_breakdown` and its adjustments to support forward compatibility. ### Adjustment Kinds Adjustments fall into four kinds based on their economic effect: | Kind | Effect on buyer price | Effect on publisher revenue | When applied | | ------------ | ------------------------- | --------------------------- | ------------------------------------------------------------- | | `fee` | Increases it | Increases it | Before quoting — walks `list_price` up toward `fixed_price` | | `discount` | Reduces it | Reduces it | Before quoting — walks `list_price` down toward `fixed_price` | | `commission` | None — budget includes it | Reduces it (revenue split) | Revenue allocation between intermediary and publisher | | `settlement` | None — post-invoice | Reduces actual payment | At invoicing or payment time | **Fees** increase what the buyer pays. Ad serving fees, data/targeting surcharges, and brand safety verification costs are additive to the base price. Without fee adjustments, these would be opaque within `list_price`. **Discounts** reduce what the buyer pays. Volume discounts, negotiated rates, and early booking discounts all lower both the buyer's cost and the publisher's revenue. **Commissions** are revenue splits. The buyer's price and budget don't change — commission determines how the payment is divided between the intermediary (e.g., agency) and the publisher. Budgets are always managed inclusive of commissions. Multiple commission adjustments compound sequentially, like discounts — e.g., a 15% agency commission followed by a 5% trading desk fee means the publisher receives `budget × 0.85 × 0.95`. **Settlement adjustments** are applied at invoicing or payment time (e.g., a cash discount for early payment). They don't affect the committed price or budget. Retroactive rebates and performance incentives are out of scope for `price_breakdown` and would be handled through reconciliation workflows. ### Beneficiary Each adjustment may include an optional `beneficiary` field identifying who receives the adjustment's value. This is most useful for commission adjustments in multi-intermediary chains: ```json theme={null} { "kind": "commission", "name": "agency", "rate": 0.15, "beneficiary": "mediaagency.example.com" }, { "kind": "commission", "name": "trading_desk", "rate": 0.05, "beneficiary": "tradingdesk.example.com" } ``` The value can be a sellers.json domain, an AdCP account ID, or a human-readable party name. ### Invariant The invariant is: `list_price` with all `fee` and `discount` adjustments applied sequentially equals `fixed_price`. Commission and settlement adjustments don't participate — they're disclosed for transparency. If no fee or discount adjustments are present, `fixed_price` must equal `list_price`. This invariant applies only when `fixed_price` is present on the parent object. For auction-based packages (where only `bid_price` exists), `price_breakdown` is informational — see [On Confirmed Packages](#on-confirmed-packages). Fee and discount adjustments compound in array order using these formulas: ``` For rate-based fees: running = running × (1 + rate) For amount-based fees: running = running + amount For rate-based discounts: running = running × (1 − rate) For amount-based discounts: running = running − amount ``` All monetary values are rounded to the precision of the currency (e.g., 2 decimal places for EUR/USD) at each step. The invariant holds after rounding. Example with mixed adjustments: ``` list_price: 12.00 fee, amount: 2.00 → 12.00 + 2.00 = 14.00 discount, rate: 0.15 → 14.00 × 0.85 = 11.90 fixed_price: 11.90 ✓ ``` For the structure example above: 14.00 × (1 − 0.15) = 11.90 ✓ (no fee adjustments in that example) ### Rate vs. Amount Each adjustment must include exactly one of `rate` or `amount`: * `rate` — decimal proportion, strictly between 0 and 1 exclusive (0 \< rate \< 1), e.g., 0.15 for 15% * `amount` — positive number (> 0) in the pricing option's currency ```json theme={null} { "kind": "discount", "name": "volume", "rate": 0.15 } { "kind": "discount", "name": "negotiated", "amount": 2.00, "description": "Flat rate reduction" } ``` ### Budgets and Commission Budgets are always denominated at the `fixed_price` level, inclusive of commissions. If a buyer commits €10,000 at an €11.90 CPM, that €10,000 is the buyer's cost. The agency takes their commission from that amount; the publisher receives the remainder. This means a buyer agent comparing rates across sellers can use `fixed_price` directly — it's the actual cost per unit, regardless of the underlying commission structure. ### On Confirmed Packages The `price_breakdown` field also appears on the package response (confirmed line item). It is seller-populated — buyer agents should treat it as read-only. **Fixed-price packages** echo the pricing option's breakdown: ```json theme={null} { "package_id": "pkg_12345", "product_id": "premium_display", "pricing_option_id": "cpm_eur_premium", "fixed_price": 11.90, "budget": 10000, "price_breakdown": { "list_price": 14.00, "adjustments": [ { "kind": "discount", "name": "volume", "rate": 0.15, "description": "12x frequency discount" }, { "kind": "commission", "name": "agency", "rate": 0.15, "description": "Agency commission" }, { "kind": "settlement", "name": "cash_discount", "rate": 0.02, "description": "2% for payment within 10 days" } ] } } ``` **Auction-based packages** use `price_breakdown` to disclose commission and settlement terms against the clearing price. For auction packages, `list_price` is set to the clearing price since there is no rate card derivation to disclose. The discount invariant does not apply — the breakdown is informational only: ```json theme={null} { "package_id": "pkg_67890", "product_id": "premium_display", "pricing_option_id": "cpm_eur_auction", "bid_price": 12.50, "budget": 10000, "price_breakdown": { "list_price": 12.50, "adjustments": [ { "kind": "commission", "name": "agency", "rate": 0.15, "description": "Agency commission" }, { "kind": "settlement", "name": "cash_discount", "rate": 0.02, "description": "2% for payment within 10 days" } ] } } ``` *** ## Eligible Adjustments Publishers can declare which adjustment kinds apply to a pricing option using `eligible_adjustments`. This tells buyer agents upfront whether discounts, commissions, or settlement terms are available — before any negotiation begins. ```json theme={null} { "pricing_option_id": "cpm_eur_standard", "pricing_model": "cpm", "fixed_price": 14.00, "currency": "EUR", "eligible_adjustments": ["fee", "discount", "commission", "settlement"] } ``` When `eligible_adjustments` is present, the buyer knows which kinds of adjustments to expect or request. When absent, no adjustments are pre-declared — the buyer should check `price_breakdown` if present for any adjustments that were already applied. This field pairs with `price_breakdown`: `eligible_adjustments` signals what is *possible*, while `price_breakdown` shows what was *applied*. *** ## Multi-Currency Support Publishers can offer the same product in multiple currencies: ```json theme={null} { "product_id": "premium_video", "pricing_options": [ { "pricing_option_id": "cpm_usd_guaranteed", "pricing_model": "cpm", "fixed_price": 45.00, "currency": "USD" }, { "pricing_option_id": "cpm_eur_guaranteed", "pricing_model": "cpm", "fixed_price": 40.00, "currency": "EUR" }, { "pricing_option_id": "cpm_gbp_guaranteed", "pricing_model": "cpm", "fixed_price": 35.00, "currency": "GBP" } ] } ``` **Buyer Responsibility**: Buyers must select a currency that the publisher supports. ## Fixed vs. Auction Pricing ### Fixed Pricing (`fixed_price` present) * Publisher sets a fixed price * Price is guaranteed and predictable * Common for guaranteed inventory * Include `fixed_price` field ### Auction Pricing (`fixed_price` absent) * Final price determined through auction * Publisher provides `floor_price` (hard minimum) and optional `price_guidance` (percentile hints) * Bid-based auction models (`cpm`, `vcpm`, `cpc`, `cpcv`, `cpv`) may also include optional `max_bid: true` to explicitly signal `bid_price` is treated as buyer maximum willingness to pay (ceiling mode) * Common for non-guaranteed inventory * Buyer submits `bid_price` in media buy request **Auction Example**: ```json theme={null} { "pricing_option_id": "cpcv_usd_auction", "pricing_model": "cpcv", "currency": "USD", "floor_price": 0.08, "max_bid": true, "price_guidance": { "p25": 0.10, "p50": 0.12, "p75": 0.15, "p90": 0.18 } } ``` ## Buyer Selection Process Each package specifies its own pricing option, which determines currency and pricing model: ```json theme={null} { "start_time": "2025-01-01T00:00:00Z", "end_time": "2025-01-31T23:59:59Z", "brand": { "domain": "acmecorp.com" }, "brief": "Q1 Brand Campaign", "packages": [{ "product_id": "premium_ctv", "format_ids": [{"agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s"}], "pricing_option_id": "cpcv_usd_auction", "budget": 50000, "pacing": "even", "bid_price": 0.16 }] } ``` **How it works:** 1. Package selects `pricing_option_id` from product (e.g., "cpcv\_usd\_auction") 2. Pricing option determines currency, pricing model, and fixed vs auction 3. Package `budget` is in the pricing option's currency 4. Auction-based pricing requires `bid_price`; it is the exact honored price unless `max_bid: true`, which switches it to a maximum-willingness-to-pay ceiling 5. Sellers validate currency compatibility across packages ## Reporting Metrics by Pricing Model Different pricing models report different primary metrics: | Pricing Model | Primary Metric | Secondary Metrics | | ------------- | --------------------- | ------------------------------------------------------ | | CPM | impressions | clicks, ctr, spend | | vCPM | viewable\_impressions | impressions, viewability\_rate, spend | | CPCV | completed\_views | impressions, completion\_rate, spend | | CPV | views | impressions, quartile\_data, spend | | CPP | grps | reach, frequency, spend | | CPC | clicks | impressions, ctr, spend | | CPA | conversions | conversion\_value, cost\_per\_acquisition, roas, spend | | Flat Rate | N/A | impressions, reach, frequency | | Time | N/A | impressions, reach, frequency | ## Example: Multi-Model CTV Product A publisher offering Connected TV inventory with multiple pricing options: ```json theme={null} { "product_id": "ctv_premium_sports", "name": "Premium Sports CTV", "description": "High-engagement sports content on CTV devices", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_15s" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_30s" } ], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "cpm_usd_guaranteed", "pricing_model": "cpm", "fixed_price": 55.00, "currency": "USD", "min_spend_per_package": 15000 }, { "pricing_option_id": "cpcv_usd_guaranteed", "pricing_model": "cpcv", "fixed_price": 0.22, "currency": "USD", "min_spend_per_package": 15000 }, { "pricing_option_id": "cpp_usd_m18-49", "pricing_model": "cpp", "fixed_price": 300.00, "currency": "USD", "parameters": { "demographic": "M18-49", "min_points": 50 }, "min_spend_per_package": 15000 } ] } ``` A buyer could choose CPP pricing if they're planning TV buys, CPCV if optimizing for engagement, or CPM for reach-based campaigns. ## Negotiated and Premium Pricing Premium sellers — particularly in OOH, CTV sponsorships, and direct deals — often finalize pricing through negotiation rather than publishing fixed rates. AdCP supports this workflow through the existing brief-to-proposal path without requiring a separate quote or price-on-request mechanism. ### Sellers are not required to publish wholesale prices Product discovery (`get_products`) does not require sellers to disclose wholesale or clearing prices. Sellers may publish products with limited or no public pricing detail — for example, a CTV sponsorship product with audience and format information but no `pricing_options`. The product is discoverable, but the buyer must submit a brief to receive a proposal with actual pricing. ### Selective brief response Sellers control which briefs they respond to and what pricing they offer. When a seller receives a brief for inventory they choose not to transact on, the protocol-native options are: * **Omit the product from the proposal entirely** — the buyer sees no proposal for that inventory, signaling unavailability for this request * **Return `PROPOSAL_NOT_COMMITTED`** — an explicit signal that the seller reviewed the brief but is not offering a committable proposal at this time Neither requires the seller to disclose why they declined. This is the protocol-native equivalent of "price not available without negotiation." ### The existing flow covers negotiated deals The standard path for negotiated-price inventory is: 1. **Seller publishes products** with descriptive metadata (audience, formats, properties) but without wholesale pricing 2. **Buyer submits a brief** via `get_products` describing their campaign goals and budget parameters 3. **Seller returns a proposal** with negotiated pricing tailored to the specific brief — or declines to propose 4. **Buyer refines or accepts** the proposal, then passes the `proposal_id` into `create_media_buy` This mirrors how premium direct deals work today: the seller sees the brief, decides whether to engage, and offers terms specific to the opportunity. ## Best Practices ### For Publishers 1. **Offer relevant pricing models** - Match pricing to your inventory type and buyer expectations 2. **Set appropriate minimums** - Use `min_spend_per_package` to ensure campaign viability 3. **Provide price guidance** - For auction pricing, give realistic floor and percentile data 4. **Consider multi-currency** - Support currencies of your target markets 5. **Document parameters** - Clearly explain thresholds, demographics, and action types ### For Buyers 1. **Select appropriate model** - Choose pricing that aligns with campaign objectives 2. **Match currency** - Ensure you select a currency the publisher supports 3. **Set realistic budgets** - Account for minimum spend requirements 4. **Align goals with pricing** - Set delivery goals that match your pricing model 5. **Monitor relevant metrics** - Focus on the metrics that matter for your pricing model ## Related Documentation * [Media Products](/dist/docs/3.1.20/media-buy/product-discovery/media-products) - Product model reference * [Creating Media Buys](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) - How to select pricing when buying * [Delivery Reporting](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) - Understanding metrics by pricing model * [Glossary](/dist/docs/3.1.20/reference/glossary) - Pricing and metric definitions # Sandbox mode Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/advanced-topics/sandbox AdCP sandbox mode — test product discovery, campaign creation, creatives, and delivery with simulated data. No real spend or production side effects. ## Overview Sandbox mode lets buyers test the full media buying lifecycle — discovery, campaign creation, creatives, and delivery — without real platform calls or spending real money. Responses contain simulated but realistic data. Sandbox is **account-level**, not per-request. Once a request references a sandbox account, the entire request is treated as sandbox. This eliminates the risk of accidentally mixing real and test traffic in a multi-step flow. ## Capabilities discovery Sellers declare sandbox support in `get_adcp_capabilities`: ```json theme={null} { "account": { "sandbox": true } } ``` Check this before using sandbox mode. If `account.sandbox` is not declared or is `false`, the seller does not support sandbox. ## Two paths to sandbox How you enter sandbox mode depends on the seller's account model (`require_operator_auth`). The two paths are completely different — make sure you follow the right one. ### Buyer-declared accounts (`require_operator_auth: false`) The seller trusts the agent and does not require per-operator authentication. Sandbox is part of the **natural key** — the same brand/operator pair can have both a production and a sandbox account, distinguished by `sandbox: true`. **Setup:** Declare a sandbox account via `sync_accounts` with `sandbox: true` on the account entry: ```json theme={null} // sync_accounts — declare a sandbox account { "accounts": [{ "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "billing": "operator", "sandbox": true }] } ``` **Usage:** Reference the sandbox account by natural key with `sandbox: true` on every request: ```json theme={null} // get_products — implicit sandbox { "account": { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "sandbox": true }, "brief": "Premium CTV inventory for Q2 campaign" } ``` ### Account-id namespaces (`require_operator_auth: true`) The seller requires each operator to authenticate directly. Sandbox accounts are **pre-existing test accounts on the seller's platform** — think Stripe test mode, Google Ads sandbox accounts, or Snap test advertiser accounts. You do not create them; you discover them. **Setup:** Discover sandbox accounts via `list_accounts` with the `sandbox: true` filter: ```json theme={null} // list_accounts — find sandbox accounts { "sandbox": true } ``` The seller returns pre-existing test accounts: ```json theme={null} { "accounts": [{ "account_id": "acct_sandbox_acme_001", "name": "Acme Test Account", "status": "active", "sandbox": true }] } ``` **Usage:** Reference the sandbox account by `account_id` on every request: ```json theme={null} // get_products — explicit sandbox { "account": { "account_id": "acct_sandbox_acme_001" }, "brief": "Premium CTV inventory for Q2 campaign" } ``` ### Quick reference | | Buyer-declared (`require_operator_auth: false`) | Account-id namespace (`require_operator_auth: true`) | | ---------------------- | ----------------------------------------------- | ---------------------------------------------------- | | **Sandbox accounts** | Declared by buyer via `sync_accounts` | Pre-existing on seller's platform | | **Discovery** | N/A — buyer creates them | `list_accounts` with `sandbox: true` | | **Account reference** | Natural key with `sandbox: true` | `account_id` | | **Real-world analogy** | Self-service test mode | Stripe test mode, Google Ads sandbox | ## Response confirmation Success responses include `sandbox: true` to confirm the request was processed in sandbox mode: ```json theme={null} { "products": [...], "sandbox": true } ``` ## Full lifecycle example (buyer-declared account) This example shows the buyer-declared account path. For account-id namespaces, resolve the sandbox account through `list_accounts` (or out-of-band onboarding for seller-defined IDs), then replace the natural key account reference with `{ "account_id": "acct_sandbox_acme_001" }` in each step. ### 1. Discover products ```json theme={null} // get_products { "account": { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "sandbox": true }, "brief": "CTV inventory for brand awareness" } ``` ### 2. Create a media buy ```json theme={null} // create_media_buy { "account": { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "sandbox": true }, "proposal_id": "prop_abc", "total_budget": { "amount": 50000, "currency": "USD" }, "brand": { "domain": "acme-corp.com" }, "start_time": { "start_type": "asap" }, "end_time": "2026-04-01T00:00:00Z" } ``` The seller returns a simulated media buy with realistic IDs, packages, and creative deadlines — nothing is booked on any real platform. ### 3. Upload creatives ```json theme={null} // sync_creatives { "account": { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "sandbox": true }, "creatives": [{ "creative_id": "hero_video_30s", "name": "Brand Hero Video 30s", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_30s" }, "assets": { "video": { "url": "https://cdn.example.com/hero.mp4", "width": 1920, "height": 1080, "duration_ms": 30000 } } }], "assignments": { "hero_video_30s": ["pkg_001"] } } ``` ### 4. Check delivery ```json theme={null} // get_media_buy_delivery { "account": { "brand": { "domain": "acme-corp.com" }, "operator": "acme-corp.com", "sandbox": true }, "media_buy_ids": ["mb_sandbox_123"] } ``` The seller returns simulated delivery metrics — impressions, spend, pacing — as if the campaign were running. ## Sandbox vs dry run Some sync tasks (`sync_creatives`, `sync_catalogs`) support a `dry_run` parameter. These serve different purposes: | | Sandbox account | `dry_run` | | ---------------- | ------------------------ | ------------------------------------------------ | | **Meaning** | Nothing is real | Preview changes without applying | | **Scope** | All tasks on the account | Sync tasks only | | **Side effects** | None (simulated) | None (preview only) | | **Use case** | Test the full lifecycle | Check what a sync would change before committing | You can combine them — `dry_run: true` on a sandbox account previews the sync without even updating sandbox state. The `X-Dry-Run`, `X-Test-Session-ID`, and `X-Mock-Time` HTTP headers are **deprecated**. Sandbox mode replaces them as a protocol-level parameter. * **Sellers MUST NOT** alter behavior based on these headers. Sandbox mode is determined solely by the account reference. Sellers SHOULD ignore the headers entirely and MAY log a deprecation warning to help buyers identify stale integrations. * **Buyers MUST NOT** rely on these headers to prevent production side effects. Only `sandbox: true` on the account reference guarantees sandbox semantics. ## Seller implementation When a request references a sandbox account (either via `sandbox: true` in the natural key or via a sandbox `account_id`), agents MUST NOT persist production state or cause real-world side effects: * **MUST NOT** make real ad platform API calls (no real orders, line items, etc.) * **MUST NOT** charge real money or create real billing records * **MUST** validate inputs the same way as production (reject invalid budgets, bad dates, etc.) * **MUST** return realistic response shapes with simulated data * **SHOULD** include `sandbox: true` in success responses Sandbox errors are real validation errors. If a buyer sends an invalid budget using a sandbox account, return a real error — don't simulate fake errors. For **account-id namespace sellers**: ensure your platform has pre-existing sandbox/test accounts that `list_accounts` can return when filtered with `sandbox: true`, or supply sandbox IDs out-of-band. For **buyer-declared account sellers**: accept `sandbox: true` as part of the natural key in `sync_accounts` and account references. Treat `(brand, operator, sandbox: true)` as a distinct account from `(brand, operator)`. ## Protocol compliance Sellers that declare `account.sandbox: true` in capabilities MUST: * Accept sandbox accounts appropriate to their account model * Apply sandbox semantics to all requests referencing a sandbox account * NOT persist production state or cause real-world side effects when processing sandbox requests * Apply normal input validation (sandbox does not bypass validation) Sellers SHOULD include `sandbox: true` in success responses when processing a sandbox account request. # Targeting Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/advanced-topics/targeting AdCP targeting — how natural language briefs replace taxonomy-based audience selection, with geographic overlays and real-time decisioning at impression time. AdCP's targeting philosophy centers on **brief-based targeting** where targeting requirements are communicated through natural language briefs, and publishers return products that include all necessary targeting capabilities. ## Core Principle: Targeting Through Briefs The primary way to specify targeting in AdCP is through campaign briefs. Instead of configuring complex targeting parameters, buyers describe their audience requirements in plain language: ```json theme={null} { "brief": "We want to reach millennial parents (ages 25-40) in major US metro areas who are interested in sustainable products. Focus on mobile and desktop during evening hours when families are planning purchases." } ``` Publishers then return products that include the targeting capabilities to reach this audience, with targeting costs built into the media pricing. When the buyer wants package-level control over specific selectable signals that the seller offers, use `targeting_overlay.signal_targeting_groups` on the package. Buy-time eligibility comes from the selected product's signal targeting contract: `signal_targeting_allowed`, inline `Product.signal_targeting_options` when present, the seller's `get_signals` feed for wholesale products that omit inline options, and `signal_targeting_rules`. Signals are named targetable dimensions, referenced with `signal_ref`: `scope: "product"` for product-local signal options, `scope: "data_provider"` with `data_provider_domain` for signals defined in a data provider's published adagents.json `signals[]`, or `scope: "signal_source"` with `signal_source_url` for source-native signals that are not published in adagents.json `signals[]`. `signal_ref.scope` is the buy-time resolution path, not provenance, and authoritative enrichment lives on the seller, data provider, or source signal definition. Do not overload `audience_include` or `audience_exclude` for this purpose; those fields are only for first-party audiences registered through `sync_audiences`. Products can also expose `included_signals` for signals already bundled into or planned into the product. Those signals are descriptive product metadata, not package-level targeting controls, and buyers do not echo them in `signal_targeting_groups`. ## Why Brief-Based Targeting? ### Eliminates Targeting Conflicts * **Single source**: All targeting comes from the publisher's product definition * **No layering conflicts**: Avoids multiple targeting systems competing * **Pricing consistency**: Targeting costs are transparent and included in media prices ### Simplifies Implementation * **Natural language**: Buyers describe needs in familiar terms * **Publisher expertise**: Publishers know their inventory and audience capabilities best * **Reduced complexity**: No need to learn platform-specific targeting syntax ### Enables Accurate Pricing * **Inclusive pricing**: All targeting costs are built into the product price * **No surprises**: Buyers know the complete cost upfront * **Market-driven**: Pricing reflects true market value of targeted inventory ## Real-Time Decisioning with TMP For targeting decisions that must happen at impression time, AdCP uses the **[Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match)**. TMP is the real-time execution layer that evaluates pre-negotiated packages at serve time across any surface. TMP gives the buyer a real-time look at each eligible impression through two structurally separated operations — Context Match (content relevance) and Identity Match (user eligibility) — without exposing user identity and page context to the buyer simultaneously. **Key capabilities:** * **Cross-publisher frequency capping**: Manage user exposure across multiple publishers via the Identity Match path * **Dynamic audience targeting**: Evaluate audience membership at impression time without sharing PII * **Brand suitability enforcement**: Real-time content evaluation through the Context Match path * **First-party data activation**: Use your customer data without exposing it to publishers **When to use TMP:** * Cross-publisher frequency caps * Suppression lists (existing customers, past converters) * Audience segments that can't be expressed in a brief * Real-time brand suitability beyond static rules * Any impression-time decision across web, mobile, CTV, AI assistants, or retail media See the [TMP documentation](/dist/docs/3.1.20/trusted-match) for the full specification and surface-specific integration guides. ## How Publishers Include Targeting Publishers incorporate targeting capabilities directly into their product definitions: ### Geographic Targeting Products specify geographic coverage: ``` "Chicago metro premium display package" "US national mobile video inventory" "California lifestyle sites network" ``` ### Demographic Targeting Audience characteristics are built into products: ``` "Millennial-focused social media placements" "Premium business professional network" "Family-oriented content sites" ``` ### Contextual Targeting Content alignment is inherent in product descriptions: ``` "Sports content premium video inventory" "Financial news site network" "Entertainment property display package" ``` ### Device & Platform Targeting Technical specifications included in product format: ``` "Mobile-optimized video formats" "Connected TV premium inventory" "Desktop display network" ``` ## Brief Examples for Common Targeting Needs ### Geographic Targeting ```json theme={null} { "brief": "Target users in New York, Los Angeles, and Chicago metro areas with premium display advertising for our luxury retail brand." } ``` ### Demographic Targeting ```json theme={null} { "brief": "Reach parents with children under 10 who are interested in educational content, focusing on weekend and evening viewing times." } ``` ### Contextual Targeting ```json theme={null} { "brief": "Place financial services ads adjacent to business and investment content, targeting affluent professionals during business hours." } ``` ### Behavioral Targeting ```json theme={null} { "brief": "Target users who have shown interest in sustainable products and eco-friendly brands, particularly those researching major purchases." } ``` ## Product Response Targeting Information When publishers return products, they include targeting information buyers need: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/get-products-response.json", "status": "completed", "cache_scope": "public", "products": [ { "product_id": "premium_millennial_mobile", "name": "Premium Millennial Mobile Package", "description": "Mobile display inventory reaching adults 25-40 across lifestyle and entertainment apps in the top 25 US metro areas.", "publisher_properties": [ { "publisher_domain": "pinnacle-media.example", "selection_type": "by_tag", "property_tags": ["lifestyle", "entertainment", "mobile_app"] } ], "channels": ["display"], "delivery_type": "guaranteed", "format_ids": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250_image" } ], "pricing_options": [ { "pricing_option_id": "premium_mobile_cpm", "pricing_model": "cpm", "currency": "USD", "fixed_price": 8.50 } ], "forecast": { "forecast_range_unit": "availability", "method": "modeled", "currency": "USD", "reach_unit": "individuals", "points": [ { "metrics": { "audience_size": { "mid": 2500000 }, "impressions": { "mid": 12000000 } } } ] }, "included_signals": [ { "signal_ref": { "scope": "product", "signal_id": "lifestyle_entertainment_interest" }, "name": "Lifestyle and entertainment interest", "value_type": "binary", "description": "Seller-modeled users with recent lifestyle or entertainment content engagement." } ], "reporting_capabilities": { "available_reporting_frequencies": ["daily"], "expected_delay_minutes": 240, "timezone": "America/New_York", "supports_webhooks": false, "available_metrics": ["impressions", "clicks", "spend", "ctr"], "date_range_support": "date_range" }, "brief_relevance": "Matches the requested millennial audience, mobile app environment, lifestyle/entertainment context, and major US metro coverage." } ] } ``` ## Product Filters vs Targeting Overlays Some targeting dimensions appear in both `get_products` filters and `create_media_buy` targeting overlays. These serve different purposes at different stages: | Filter (`get_products`) | Overlay (`create_media_buy`) | Filter: what it does | Overlay: what it does | | ----------------------- | --------------------------------------- | -------------------------------------------- | ------------------------------------- | | `countries` | `geo_countries` / `_exclude` | Show products serving these countries | Deliver only in these countries | | `regions` | `geo_regions` / `_exclude` | Show products serving these regions | Deliver only in these regions | | `metros` | `geo_metros` / `_exclude` | Show products serving these metros | Deliver only in these metros | | `postal_areas` | `geo_postal_areas` / `_exclude` | Show products serving these postal codes | Deliver only in these postal codes | | `geo_proximity` | `geo_proximity` | Show products with inventory near this point | Deliver only to users near this point | | `keywords` | `keyword_targets` / `negative_keywords` | Show products supporting these search terms | Bid on these specific terms | **Filters** tell the sell-side agent what matters to the buyer so it can curate relevant products. Without a proximity filter, a seller might recommend products that don't support geo\_proximity — and the buyer wouldn't discover the gap until `create_media_buy`. **Overlays** apply the precise functional constraint at execution time. The overlay is what the seller's ad server enforces. Value filters (`countries`, `regions`, `metros`, `postal_areas`, `geo_proximity`, `keywords`) narrow by coverage area — "show me inventory in these places." Capability filters (`required_geo_targeting`, `required_features`) narrow by what the seller can enforce — "only sellers that support zip-level targeting." These compose: use both when you need inventory in a specific area from sellers that can target at that granularity. For buyers: pass filters at discovery time, then apply the same values (or refined versions) as overlays at buy time. Note that overlay schemas are stricter — for example, `keyword_targets` requires `match_type` while the `keywords` filter defaults to `broad`. ### Example: discovery to buy ```json theme={null} // Step 1: get_products — signal intent with filters { "brief": "Coffee shop promotion in downtown Seattle", "filters": { "geo_proximity": [{ "lat": 47.6062, "lng": -122.3321, "label": "Downtown Seattle", "radius": { "value": 5, "unit": "mi" } }], "keywords": [{ "keyword": "coffee" }] } } ``` ```json theme={null} // Step 2: create_media_buy — apply precise constraints as overlays { "targeting": { "geo_proximity": [{ "lat": 47.6062, "lng": -122.3321, "label": "Downtown Seattle", "radius": { "value": 5, "unit": "mi" } }], "keyword_targets": [{ "keyword": "coffee", "match_type": "broad" }] } } ``` Note that `keywords` (filter) becomes `keyword_targets` (overlay), and `match_type` becomes required. ## When to Use Targeting Overlays Targeting overlays in `create_media_buy` and `update_media_buy` are **rare** and should only be used for: ### Geographic Restrictions Use geo fields **only** for: * **RCT testing**: Randomized control trials requiring specific geographic splits * **Regulatory compliance**: Legal requirements for geographic restrictions * **Product refinement**: When a product spans multiple regions and you need to restrict to a subset **Inclusion fields** (restrict delivery to these locations): * `geo_countries`: ISO 3166-1 alpha-2 country codes (e.g., `["US", "GB"]`) * `geo_regions`: ISO 3166-2 subdivision codes (e.g., `["US-CA", "GB-SCT"]`) * `geo_metros`: Structured metro areas with explicit system (e.g., `nielsen_dma`, `uk_itl2`) — not all publishers support metro-level targeting * `geo_postal_areas`: Structured postal areas with explicit country and system (e.g., `US` / `zip`, `GB` / `outward`, `ZA` / `postal_code`) — not all publishers support postal-level targeting **Exclusion fields** (exclude these locations from delivery): * `geo_countries_exclude`: Same format as `geo_countries` * `geo_regions_exclude`: Same format as `geo_regions` * `geo_metros_exclude`: Same format as `geo_metros` * `geo_postal_areas_exclude`: Same format as `geo_postal_areas` **Note**: Inclusion and exclusion can be combined. Metro and postal targeting require specifying the classification system, enabling international support. Not all geographic granularities are supported by all publishers. Country and region are most widely supported. ### Age Restrictions (Compliance) Use for **legal compliance** requirements: * **Alcohol advertising**: Require verified 21+ in the US * **Gambling/Gaming**: Require verified 18+ or 21+ depending on jurisdiction * **Cannabis**: Require verified age per local regulations ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "age_restriction": { "min": 21, "verification_required": true, "accepted_methods": ["facial_age_estimation", "id_document", "world_id"] } } ``` **Verification methods** (defined in [`age-verification-method.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/age-verification-method.json), based on ISO/IEC 27566-1 age assurance standards): * `facial_age_estimation` - AI-based age estimation (Yoti, etc.) * `id_document` - Government ID scan * `digital_id` - Verified digital identity credentials * `credit_card` - Payment card age gate * `world_id` - World ID orb verification **Note**: "Inferred" age (guessing from behavior/profile) is **not** accepted for regulatory compliance. Platforms declare their supported verification methods in `get_adcp_capabilities`. ### Device Platform (Technical Compatibility) Use for **technical requirements**: * **App install campaigns**: iOS-only app requires `device_platform: ["ios"]` * **CTV campaigns**: Target specific TV operating systems ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "device_platform": ["ios", "android"] } ``` **Available platforms** (defined in [`device-platform.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/device-platform.json), based on Sec-CH-UA-Platform standard extended for CTV): * Browser: `ios`, `android`, `windows`, `macos`, `linux`, `chromeos` * CTV: `tvos`, `tizen`, `webos`, `fire_os`, `roku_os` * Other: `unknown` ### Device type (form factor) Use for **performance optimization** targeting by hardware category rather than OS: * **Mobile campaigns**: Target all mobile devices regardless of OS * **CTV campaigns**: Target connected TVs across all platforms * **Exclude form factors**: Skip CTV for app-install campaigns ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "device_type": ["mobile", "tablet"] } ``` **Exclusion** — use `device_type_exclude` to exclude specific form factors: ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "device_type_exclude": ["dooh"] } ``` **Available types** (defined in [`device-type.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/device-type.json)): * `desktop`, `mobile`, `tablet`, `ctv`, `dooh`, `unknown` **Device type vs device platform**: `device_type` targets form factors (mobile, desktop, CTV). `device_platform` targets operating systems (iOS, Android, tvOS). Use `device_type` for performance optimization; use `device_platform` for technical compatibility. ### Language (Localization) Use for **localization requirements**: * Creative is in a specific language * Campaign targets specific language speakers ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "language": ["es", "en"] } ``` **Format**: ISO 639-1 two-letter language codes (e.g., `en`, `es`, `fr`, `de`, `zh`). ### Frequency Capping Two frequency controls can be used independently or together: **Cooldown between exposures** — `suppress` prevents back-to-back delivery: ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "frequency_cap": { "suppress": { "interval": 60, "unit": "minutes" } } } ``` **Impression cap per entity per window** — `max_impressions` + `per` + `window` limits total exposure: ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "frequency_cap": { "max_impressions": 5, "per": "households", "window": { "interval": 7, "unit": "days" } } } ``` Both can be combined. The `per` field uses the same entity types as `reach_unit` on reach optimization goals — use matching values when layering a hard cap on top of a reach campaign. ### Example Geographic Overlay (RCT Testing) For RCT testing, exclusion targeting is often simpler than inclusion. Instead of listing hundreds of DMAs to include, exclude the holdout markets from a national campaign. When inclusion and exclusion are combined, exclusion fields subtract from the included set (e.g., "US minus these 3 DMAs"): ```json theme={null} { "packages": [ { "product_id": "national_video", "targeting_overlay": { "geo_countries": ["US"], "geo_metros_exclude": [ { "system": "nielsen_dma", "values": ["501", "803", "602"] } ] } }, { "product_id": "national_video", "targeting_overlay": { "geo_metros": [ { "system": "nielsen_dma", "values": ["501", "803", "602"] } ] } } ] } ``` Inclusion targeting works the same way for cases where you want to specify exact markets: ```json theme={null} { "packages": [ { "product_id": "national_video", "targeting_overlay": { "geo_metros": [ { "system": "nielsen_dma", "values": ["501", "602", "803"] } ] } }, { "product_id": "national_video", "targeting_overlay": { "geo_metros": [ { "system": "nielsen_dma", "values": ["504", "505", "506"] } ] } } ] } ``` ## What NOT to Use Targeting Overlays For **Express these in briefs instead:** * **Demographic preferences** (age, gender, income) - "Target millennials" or "high-income households" in brief text * **Device preferences** - "Mobile users" or "CTV viewers" in brief text (use `device_platform` overlay only for technical compatibility) * **Content categories** - "Sports content" or "News sites" in brief text * **General audience preferences** - "Auto intenders" or "Luxury shoppers" in brief text. If the buyer wants specific named signals applied to this package, use `signal_targeting_groups`. * **Daypart preferences** - "Morning commute hours" or "prime time evening" in brief text **Overlays vs Briefs:** | Use Case | Overlay | Brief | | -------------------------------------------------------------- | ----------------------------------------- | ---------------------------------- | | Age for compliance (alcohol, gambling) | ✅ `age_restriction` | | | Age for audience targeting | | ✅ "Target millennials" | | Device for app compatibility | ✅ `device_platform` | | | Device for audience preference | | ✅ "Mobile users" | | Language for creative localization | ✅ `language` | | | Language for audience preference | | ✅ "Spanish-speaking audiences" | | First-party CRM audience (retargeting, suppression) | ✅ `audience_include` / `audience_exclude` | | | Audience preference (interest targeting) | | ✅ "Auto intenders" in brief | | Specific named signal offered by the seller | ✅ `signal_targeting_groups` | | | Search/retail media keyword targeting | ✅ `keyword_targets` / `negative_keywords` | | | Broad thematic intent ("people searching for shoes") | | ✅ "Reach in-market shoe shoppers" | | Proximity to specific coordinates (within 2hr drive of a city) | ✅ `geo_proximity` | | | Nearby audience ("people near coffee shops") | | ✅ "Reach people near coffee shops" | **Why briefs work better for preferences:** * Natural language captures intent more clearly * Publishers know their inventory and can target effectively * Avoids channel-specific complexity (DOOH has no browsers) * Simpler API with fewer edge cases ## Available Targeting Overlay Parameters Geographic targeting supports both inclusion (restrict to) and exclusion (exclude from) for all geo dimensions. Inclusion and exclusion fields can be combined — for example, include a country but exclude specific metros within it. ### Exclusion Semantics **Exclusion without inclusion.** When an exclusion field is present without a corresponding inclusion field, the exclusion applies to the product's full geographic coverage. For example, if a product covers the entire US and the buyer specifies only `geo_metros_exclude`, the excluded metros are removed from the product's national footprint. **Cross-level resolution.** Geographic levels form a hierarchy: country > region > metro > postal. Sellers SHOULD resolve hierarchical conflicts such that exclusion at a higher level takes precedence over inclusion at a more specific level. For example, `geo_countries_exclude: ["US"]` combined with `geo_regions: ["US-CA"]` SHOULD result in no US delivery — the country-level exclusion takes precedence. **Same-value overlap.** Sellers SHOULD reject requests where the same value appears in both the inclusion and exclusion field at the same level (e.g., `geo_countries: ["US"]` with `geo_countries_exclude: ["US"]`) and return a descriptive error. **Capabilities.** Sellers that declare geographic targeting support in `get_adcp_capabilities` SHOULD support both inclusion and exclusion at that level. If a seller only supports one direction, it MUST return a validation error for unsupported fields rather than silently ignoring them. ### geo\_countries * **Description**: Restrict delivery to specific countries * **Format**: ISO 3166-1 alpha-2 country codes * **Examples**: `["US", "CA"]`, `["GB", "FR", "DE"]` * **Use cases**: Regulatory compliance, country-specific campaigns ### geo\_countries\_exclude * **Description**: Exclude specific countries from delivery * **Format**: ISO 3166-1 alpha-2 country codes * **Examples**: `["RU", "CN"]` * **Use cases**: Regulatory compliance, sanctions ### geo\_regions * **Description**: Restrict delivery to specific regions/states * **Format**: ISO 3166-2 subdivision codes * **Examples**: `["US-CA", "US-NY"]`, `["GB-SCT", "GB-ENG"]` * **Use cases**: State-level compliance, regional testing ### geo\_regions\_exclude * **Description**: Exclude specific regions/states from delivery * **Format**: ISO 3166-2 subdivision codes * **Examples**: `["US-CA"]`, `["CA-QC"]` * **Use cases**: Regulatory compliance (e.g., cannabis restrictions by province), RCT holdout regions, regions where product is unavailable ### geo\_metros * **Description**: Restrict delivery to specific metro areas * **Format**: Array of objects, each with a `system` and `values` * **Systems**: `nielsen_dma` (US), `uk_itl1` / `uk_itl2` (UK), `eurostat_nuts2` (EU), `custom` * **Example**: `[{ "system": "nielsen_dma", "values": ["501", "803"] }]` * **Use cases**: Local campaigns, metro-level RCT testing * **Note**: Seller must declare supported systems in `get_adcp_capabilities` ### geo\_metros\_exclude * **Description**: Exclude specific metro areas from delivery * **Format**: Array of objects, each with a `system` and `values` * **Example**: `[{ "system": "nielsen_dma", "values": ["602"] }]` * **Use cases**: RCT holdout markets, competitive exclusion zones, markets where product is unavailable * **Note**: Seller must declare supported systems in `get_adcp_capabilities` ### geo\_postal\_areas * **Description**: Restrict delivery to specific postal areas * **Format**: Array of objects, each with a `country`, `system`, and `values` * **Systems**: country-local values such as `zip`, `zip_plus_four`, `outward`, `full`, `fsa`, `plz`, `code_postal`, `postcode`, `pin`, and `postal_code` * **Example**: `[{ "country": "US", "system": "zip", "values": ["10001", "10002"] }]` * **Use cases**: Hyper-local campaigns, postal-level restrictions * **Note**: Seller must declare supported systems in `get_adcp_capabilities`. During the 3.x migration, deprecated country-fused systems such as `us_zip` remain accepted for compatibility and SDK backfill. ### geo\_postal\_areas\_exclude * **Description**: Exclude specific postal areas from delivery * **Format**: Array of objects, each with a `country`, `system`, and `values` * **Example**: `[{ "country": "US", "system": "zip", "values": ["90210"] }]` * **Use cases**: RCT holdout zip codes, restricted delivery areas * **Note**: Seller must declare supported systems in `get_adcp_capabilities`; the deprecated legacy form remains accepted during the 3.x migration. ### axe\_include\_segment * **Description**: Segment ID for inclusion targeting (legacy AXE field) * **Format**: String segment identifier * **Examples**: `"seg_auto_intenders_q1"`, `"audience_lapsed_buyers_30d"` * **Use cases**: Dynamic audience targeting, first-party data activation * **Note**: This field is from the legacy AXE integration. New implementations should use [TMP](/dist/docs/3.1.20/trusted-match), where audience targeting is handled through the Identity Match path. ### axe\_exclude\_segment * **Description**: Segment ID for exclusion targeting (legacy AXE field) * **Format**: String segment identifier * **Examples**: `"seg_existing_customers"`, `"audience_past_converters"` * **Use cases**: Customer suppression, frequency management * **Note**: This field is from the legacy AXE integration. New implementations should use [TMP](/dist/docs/3.1.20/trusted-match), where suppression is handled through the Identity Match path. ### audience\_include * **Description**: Restrict delivery to users who are members of these first-party CRM audiences. Only people on the uploaded list are eligible to see the ad. * **Format**: Array of `audience_id` strings from [`sync_audiences`](/dist/docs/3.1.20/media-buy/task-reference/sync_audiences) * **Example**: `["lapsed_subscribers", "high_value_prospects"]` * **Use cases**: Retargeting known users, loyalty campaigns targeting existing members, CRM-based inclusion on closed platforms (LinkedIn, Meta, TikTok, Google Ads) * **Not for lookalike/expansion**: To find new users *similar to* an audience, describe the intent in your campaign brief ("reach people like our existing customers") — the seller handles expansion strategy * **Prerequisite**: Audiences must be registered and `ready` via `sync_audiences` before use * **Note**: Seller must declare support in `get_adcp_capabilities` ### audience\_exclude * **Description**: Suppress delivery to users who are members of these first-party CRM audiences. Matched users are excluded regardless of other targeting. * **Format**: Array of `audience_id` strings from [`sync_audiences`](/dist/docs/3.1.20/media-buy/task-reference/sync_audiences) * **Example**: `["existing_customers", "recent_purchasers"]` * **Use cases**: Customer suppression in acquisition campaigns, excluding recent converters, suppressing opted-out users * **Prerequisite**: Audiences must be registered and `ready` via `sync_audiences` before use * **Note**: Seller must declare support in `get_adcp_capabilities` ### signal\_targeting\_groups * **Description**: Basic Boolean grouping for seller-offered data signals. Use this for both simple include-only signal targeting and grouped include/exclude expressions such as `(A OR B) AND NOT (C OR D)`. * **Discovery**: Wholesale products can set `signal_targeting_allowed: true` and omit inline `signal_targeting_options`; buyers then use `get_signals` as the selectable signal feed. Products return inline `signal_targeting_options` when they need product-specific pricing, activation handles, defaults, grouping hints, or a relevant subset for a brief/refine response. * **Format**: Object with required top-level `operator: "all"` and a `groups` array. The top-level operator is always present, even though v1 only supports `all`. * **Child groups**: Each child group has `operator: "any"` or `operator: "none"` and a `signals` array of package signal targeting objects. Each signal carries `signal_ref`, `value_type`, and the value fields, plus optional commercial and activation handles. * **Legacy flat targeting**: `targeting_overlay.signal_targeting` remains schema-valid during the SignalRef migration window for older clients, but it is deprecated. New package-level signal selection uses `signal_targeting_groups` so sellers can apply include/exclude groups, product rules, and per-signal pricing consistently. * **Semantics**: `any` means the user must match at least one signal in that group. `none` means the user must match none of the signals in that group. With top-level `all`, every child group must pass. For simple include-only targeting, send one child group with `operator: "any"`. * **Resolution model**: `signal_targeting_rules.resolution_model` tells buyers how the seller applies selected signals to inventory. `direct_targeting` means selected signals behave like package targeting predicates. `seller_planned` means selected signals are inputs to seller-managed planning against product-specific inventory, timing, availability, reach, or pacing constraints; buyers should not attempt to decompose the selected audience into lower-level inventory or schedule decisions. * **Selection groups**: When the product declares `signal_targeting_rules.selection_group_rules`, each child group MUST contain signals from exactly one `selection_group` and one targeting mode, and buyers MUST send at most one child group for each `(selection_group, targeting_mode)` pair. Sellers MUST reject duplicate, mixed, or collapsed child groups that combine distinct selection-group rules into the same `any` or `none` group. `selection_group` is a product-defined composability bucket, not a backend identity type: for example, a GAM-backed seller may expose both audience segments and key-values as ordinary `signal_ref` options, using one `selection_group` when they are freely OR-combinable and separate `selection_group`s when they must be trafficked as separate ANDed clauses. * **Pricing**: Include `pricing_option_id` when the selected product's `signal_targeting_options` entry has `pricing_options`; omit only when the signal is bundled into the product price or has no incremental cost. Product-scoped pricing in `signal_targeting_options` is authoritative for that product. If the product option has no product-specific price, sellers MAY use the default pricing exposed in `get_signals`. * **Signal reference**: Use `signal_ref: { "scope": "product", "signal_id": "..." }` for a product-local signal option. Use `signal_ref: { "scope": "data_provider", "data_provider_domain": "...", "signal_id": "..." }` for a signal defined in a data provider's published adagents.json `signals[]`. `signal_ref` identifies the signal definition; `signal_agent_segment_id` identifies a resolved segment or execution handle when the product option exposes one. Echo an exposed `signal_agent_segment_id` verbatim on the package entry, and prefer it over reconstructing identity from categorical values because providers can namespace handles to distinguish definitions such as real-time rain versus forecast high-precipitation. If the product option has `activation_status: "requires_activation"`, it MUST include `signal_agent_segment_id`; activate the signal first, then include `activation_key` if the seller requires it. * **Provider-published signals**: For a provider-published signal, `signal_ref.data_provider_domain` identifies the upstream data provider and `signal_ref.signal_id` identifies the public signal definition. Buyers can verify the seller's right to offer the signal by checking the provider's `adagents.json` `authorized_agents` entry for the seller. * **Product gating**: Sellers SHOULD reject a signal entry when the product does not advertise that signal inline or through `get_signals`, `signal_targeting_allowed` is false for package-level selection, the signal violates the product's `signal_targeting_rules`, the signal's `allowed_targeting_modes` does not allow the requested child group operator, the signal is not active for the account, or the requested value is outside the signal definition. `allowed_targeting_modes: ["include"]` maps to `any` groups; `["exclude"]` maps to `none` groups. Binary package signal entries use `value: true`; use the parent `none` group for exclusion, not `value: false`. Signal targeting limits are product-scoped, not declared in seller-wide `get_adcp_capabilities`, because products may be backed by different ad servers or platforms. If `selection_mode` is `fixed`, buyers SHOULD omit edits to `signal_targeting_groups`; sellers apply the fixed/default selections and MUST echo them on the resulting package state. * **Updates**: Because `targeting_overlay` is shared by `create_media_buy` and `update_media_buy`, sellers MAY reject mid-flight signal group changes with `REQUOTE_REQUIRED` when the selected signal, group expression, or `pricing_option_id` changes the priced envelope. * **Not for first-party audiences**: Use `audience_include` / `audience_exclude` for buyer-uploaded audiences from `sync_audiences`. The backend targeting primitive is intentionally hidden behind `signal_ref`. Buyers should not need a separate identity system for "audience segment" versus "key-value"; the selected product's `signal_targeting_rules` describe whether those options can be composed together. If a product exposes both a `gam_audience_segments` group and a `gam_key_values` group with `targeting_mode: "include"`, the buyer composes two `any` child groups under the top-level `all`, not one collapsed mixed group. For products where inventory and audience planning are inseparable, such as linear broadcast schedules, sellers use `resolution_model: "seller_planned"`. Mandatory audience selection still lives in `selection_mode: "required"` or a required `selection_group_rules` entry; cross-product audience consistency comes from a shared `scope: "data_provider"` signal definition, even when the seller is also the data provider. ```json theme={null} { "packages": [ { "product_id": "retail_video_premium", "pricing_option_id": "media_cpm_usd", "budget": 25000, "targeting_overlay": { "signal_targeting_groups": { "operator": "all", "groups": [ { "operator": "any", "signals": [ { "signal_ref": { "scope": "product", "signal_id": "high_intent_shoppers" }, "value_type": "binary", "value": true }, { "signal_ref": { "scope": "product", "signal_id": "loyalty_members" }, "value_type": "binary", "value": true } ] }, { "operator": "none", "signals": [ { "signal_ref": { "scope": "product", "signal_id": "recent_purchasers" }, "value_type": "binary", "value": true } ] } ] } } } ] } ``` ### frequency\_cap * **Description**: Limit ad exposure frequency per entity. Two optional controls can be used independently or together. * **Cooldown control**: `suppress` — minimum duration between consecutive exposures to the same entity. `suppress_minutes` (number) is also accepted for backwards compatibility. * **Impression cap**: `max_impressions` + `per` + `window` — total impression ceiling per entity per time window. All three fields are required together. * **Use cases**: User experience management, ad fatigue prevention, complementing reach optimization goals with a hard ceiling * **Examples**: `{"suppress": {"interval": 60, "unit": "minutes"}}`, `{"max_impressions": 5, "per": "households", "window": {"interval": 7, "unit": "days"}}` ### age\_restriction * **Description**: Require minimum age for compliance * **Format**: Object with `min` (required), `verification_required`, and `accepted_methods` * **Examples**: `{"min": 21, "verification_required": true}`, `{"min": 18, "accepted_methods": ["world_id"]}` * **Use cases**: Alcohol (21+), gambling (18+), cannabis regulations * **Note**: Platforms declare supported verification methods in `get_adcp_capabilities` ### device\_platform * **Description**: Restrict to specific operating system platforms * **Format**: Array of platform identifiers from Sec-CH-UA-Platform standard * **Examples**: `["ios"]`, `["ios", "android"]`, `["tvos", "fire_os"]` * **Use cases**: App install campaigns (iOS-only app), CTV-specific campaigns * **Values**: `ios`, `android`, `windows`, `macos`, `linux`, `chromeos`, `tvos`, `tizen`, `webos`, `fire_os`, `roku_os` ### device\_type * **Description**: Restrict to specific device form factors * **Format**: Array of device type identifiers * **Examples**: `["mobile"]`, `["mobile", "tablet"]`, `["ctv"]` * **Use cases**: Mobile-only promotions, CTV campaigns targeting all TV platforms, excluding DOOH from certain campaigns * **Values**: `desktop`, `mobile`, `tablet`, `ctv`, `dooh`, `unknown` * **Note**: Seller must declare `device_type: true` in `get_adcp_capabilities` targeting ### device\_type\_exclude * **Description**: Exclude specific device form factors from delivery * **Format**: Array of device type identifiers * **Examples**: `["dooh"]`, `["ctv", "dooh"]` * **Use cases**: Exclude CTV for app-install campaigns, exclude DOOH for direct-response campaigns * **Note**: Supported when seller declares `device_type: true` in `get_adcp_capabilities` ### language * **Description**: Restrict to users with specific language preferences * **Format**: Array of ISO 639-1 two-letter language codes * **Examples**: `["en"]`, `["es", "en"]`, `["zh", "ja", "ko"]` * **Use cases**: Localized creative, language-specific campaigns ### keyword\_targets * **Description**: Target specific keywords for search and retail media platforms. Restricts delivery to queries matching the specified keywords. * **Format**: Array of objects with `keyword`, `match_type` (`broad`, `phrase`, or `exact`), and optional `bid_price` * **Identity**: Each keyword is identified by the tuple `(keyword, match_type)`. The same keyword string with different match types are distinct targets. Duplicate pairs in a single request SHOULD be rejected by sellers. * **Match types**: * `broad` — matches related and synonym queries * `phrase` — matches queries containing the keyword phrase in order * `exact` — matches the keyword query only * **Per-keyword bid**: The optional `bid_price` overrides the package-level `bid_price` for that keyword. Inherits the `max_bid` interpretation from the pricing option: when `max_bid` is true, this is the keyword's bid ceiling; when false, this is the exact bid. If omitted, the package `bid_price` applies. * **Use cases**: Search campaigns, retail media sponsored products, keyword-based intent targeting * **Note**: Seller must declare `execution.targeting.keyword_targets` in `get_adcp_capabilities` with the `supported_match_types` it accepts. Only use match types the seller declares — sellers must reject unsupported match types. Use `keyword_targets_add` and `keyword_targets_remove` in `update_media_buy` to add or update keywords incrementally after launch. Keyword-level delivery data (`by_keyword` in reporting) requires `reporting_capabilities.supports_keyword_breakdown: true` on the product — these are independent capabilities. `by_keyword` is keyword-grain (one row per keyword+match\_type pair), not search-term-grain. ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "keyword_targets": [ { "keyword": "running shoes", "match_type": "broad", "bid_price": 0.45 }, { "keyword": "trail running shoes womens", "match_type": "phrase", "bid_price": 0.85 }, { "keyword": "acme cloudrunner 5", "match_type": "exact", "bid_price": 1.20 } ] } ``` ### negative\_keywords * **Description**: Exclude specific keywords from delivery. Queries matching these keywords will not trigger the ad. * **Format**: Array of objects with `keyword` and `match_type` (`broad`, `phrase`, or `exact`) * **Use cases**: Prevent wasteful spend on irrelevant queries, exclude competitor brand terms * **Note**: Seller must declare `execution.targeting.negative_keywords` in `get_adcp_capabilities` with the `supported_match_types` it accepts. Use `negative_keywords_add` and `negative_keywords_remove` in `update_media_buy` to add/remove negatives incrementally after launch. ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "negative_keywords": [ { "keyword": "free", "match_type": "broad" }, { "keyword": "used running shoes", "match_type": "phrase" } ] } ``` ### store\_catchments * **Description**: Target users within store catchment areas from a synced store catalog * **Format**: Array of objects, each referencing a store-type catalog synced via `sync_catalogs` * **Required fields**: `catalog_id` * **Optional fields**: `store_ids` (narrow to specific stores), `catchment_ids` (narrow to specific zones like `"walk"` or `"drive"`) * **Use cases**: Drive-to-store campaigns, local inventory ads, proximity targeting ```json theme={null} { "targeting_overlay": { "store_catchments": [ { "catalog_id": "retail-locations", "store_ids": ["store_nyc_001", "store_nyc_002"], "catchment_ids": ["drive"] } ] } } ``` When `store_ids` is omitted, all stores in the catalog are targeted. When `catchment_ids` is omitted, all catchment zones are targeted. The seller must declare support for store catchment targeting in `get_adcp_capabilities`. ### geo\_proximity * **Description**: Target users within travel time, distance, or a custom boundary around arbitrary geographic points * **Format**: Array of objects, each with exactly one method: `travel_time` + `transport_mode`, `radius`, or `geometry` * **Required fields**: `lat` + `lng` (for travel\_time and radius methods), or `geometry` (for pre-computed boundaries) * **Optional fields**: `label` (human-readable name for the entry) * **Use cases**: Tourism campaigns (within 2hr drive of a city), event targeting (near a venue), airport catchment areas * **Semantics**: Multiple entries use OR — a user within range of any listed point is eligible. Intersects with other geo targeting fields (e.g., combining with `geo_countries` restricts proximity to those countries) Travel time (isochrone) example: ```json theme={null} { "targeting_overlay": { "geo_proximity": [ { "lat": 51.2277, "lng": 6.7735, "label": "Düsseldorf", "travel_time": { "value": 2, "unit": "hr" }, "transport_mode": "driving" } ] } } ``` Radius-based example: ```json theme={null} { "targeting_overlay": { "geo_proximity": [ { "lat": 51.4700, "lng": -0.4543, "label": "Heathrow Airport", "radius": { "value": 30, "unit": "km" } } ] } } ``` Pre-computed geometry example (buyer provides the polygon): ```json theme={null} { "targeting_overlay": { "geo_proximity": [ { "label": "2hr drive from Düsseldorf", "geometry": { "type": "Polygon", "coordinates": [[[5.87, 50.35], [8.23, 50.35], [8.23, 52.10], [5.87, 52.10], [5.87, 50.35]]] } } ] } } ``` For travel time entries, the platform resolves the isochrone to a geographic boundary based on actual transportation networks. Transport modes: `driving`, `walking`, `cycling`, `public_transport`. The `geometry` method allows buyers who have already computed isochrones (via TravelTime, Mapbox, etc.) to pass the polygon directly — this also enables sellers without routing engines to participate. For campaigns targeting 10+ locations, consider using `store_catchments` with a location catalog instead, which supports ongoing management and per-location reporting. `geo_proximity` does not have an exclusion variant — this is by design, as excluding "everyone near a point" is rarely a meaningful targeting constraint. Sellers SHOULD enforce minimum area thresholds consistent with their privacy policies and applicable regulations. The seller must declare `geo_proximity` support in `get_adcp_capabilities`, specifying which methods (`radius`, `travel_time`, `geometry`) and transport modes are supported. Validated examples: ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "geo_proximity": [ { "lat": 51.2277, "lng": 6.7735, "label": "Düsseldorf", "travel_time": { "value": 2, "unit": "hr" }, "transport_mode": "driving" } ] } ``` ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "geo_proximity": [ { "lat": 51.4700, "lng": -0.4543, "label": "Heathrow Airport", "radius": { "value": 30, "unit": "km" } } ] } ``` ```json theme={null} { "$schema": "/schemas/3.1.20/core/targeting.json", "geo_proximity": [ { "label": "2hr drive from Düsseldorf", "geometry": { "type": "Polygon", "coordinates": [[[5.87, 50.35], [8.23, 50.35], [8.23, 52.10], [5.87, 52.10], [5.87, 50.35]]] } } ] } ``` ## Benefits for Different Stakeholders ### For Buyers * **Simpler planning**: Describe audience needs naturally * **Transparent pricing**: All costs included upfront * **Reduced complexity**: No targeting configuration required * **Better outcomes**: Publisher expertise optimizes delivery ### For Publishers * **Pricing control**: Bundle targeting into product pricing * **Expertise utilization**: Apply knowledge of inventory and audiences * **Simplified integration**: Fewer technical targeting parameters * **Market positioning**: Differentiate through targeting capabilities ### For Platforms * **Reduced conflicts**: Single targeting source eliminates layering issues * **Cleaner implementation**: Less complex targeting logic required * **Better performance**: Optimized for publisher inventory characteristics ## Real-Time Targeting Signals Orchestrators can provide **real-time targeting signals** to publishers for dynamic, high-cardinality targeting beyond what can be expressed in static overlays. These signals enable: * **Brand safety** - Real-time content filtering and adjacency controls * **Brand suitability** - Contextual alignment with brand values * **Audience targeting** - Dynamic audience segments updated in real-time * **Contextual targeting** - Page-level or moment-level targeting decisions Real-time signals are provided through the [AdCP Signals Protocol](/dist/docs/3.1.20/signals/overview), which allows orchestrators to supply targeting data at impression time. ### Key Differences: Signals vs Overlays * Signals are **evaluated at impression time**, not campaign setup * Signals support **higher cardinality** (thousands of values vs. dozens) * Signals can be **updated continuously** without modifying the media buy * Signals enable **sophisticated contextual targeting** that briefs cannot express ### When to Use Real-Time Signals ✅ **Use Real-Time Signals For:** * Brand safety filtering (block unsafe content) * Brand suitability scoring (prefer suitable contexts) * Dynamic audience targeting (real-time segment membership) * Contextual targeting (page-level or moment-level decisions) * High-cardinality targeting (thousands of values) * Targeting that changes during campaign flight ## Managing keywords after launch Both keyword targets and negative keywords support incremental operations in `update_media_buy`, avoiding the need to replace the full `targeting_overlay`: * **`keyword_targets_add`** — upserts by `(keyword, match_type)` identity. Adds new keywords or updates `bid_price` on existing ones. * **`keyword_targets_remove`** — removes matching `(keyword, match_type)` pairs. * **`negative_keywords_add`** — appends negatives. Duplicates are no-ops. * **`negative_keywords_remove`** — removes matching pairs. Missing entries are no-ops. ```json theme={null} { "packages": [ { "package_id": "pkg_sponsored_search_001", "keyword_targets_add": [ { "keyword": "trail running shoes", "match_type": "phrase", "bid_price": 0.95 } ], "keyword_targets_remove": [ { "keyword": "running shoes", "match_type": "broad" } ], "negative_keywords_add": [ { "keyword": "diy", "match_type": "broad" }, { "keyword": "how to make running shoes", "match_type": "phrase" } ] } ] } ``` Sellers SHOULD return a validation error if `targeting_overlay.keyword_targets` is present in the same request as `keyword_targets_add` or `keyword_targets_remove` (and likewise for negative keywords). The incremental operations and the full overlay replacement are mutually exclusive within a single update. To remove all keyword targeting while preserving other overlay fields, send the full `targeting_overlay` without the `keyword_targets` field. ## Implementation Requirements ### Publishers MUST: 1. **Support Geographic Targeting**: Handle geographic inclusion and exclusion parameters (`geo_countries`, `geo_countries_exclude`, `geo_regions`, `geo_regions_exclude`, `geo_metros`, `geo_metros_exclude`, `geo_postal_areas`, `geo_postal_areas_exclude`) to the extent your platform supports them. Declare supported metro and postal systems in `get_adcp_capabilities` 2. **Interpret Briefs**: Use briefs to determine appropriate audience and content targeting 3. **Validate Targeting**: Reject media buys with targeting that cannot be supported 4. **Document Limitations**: Clearly communicate any geographic targeting limitations in product descriptions ### Buyers SHOULD: 1. **Use Briefs First**: Express most targeting needs in natural language briefs 2. **Minimize Overlays**: Only use technical targeting for geographic restrictions or RCT testing 3. **Trust Publishers**: Let publishers apply their inventory knowledge to brief interpretation 4. **Validate Early**: Check product capabilities before applying technical targeting ## Best Practices 1. **Default to briefs** - Start with natural language descriptions 2. **Write Clear Briefs**: Be specific about audience and context requirements 3. **Trust Publisher Expertise**: Publishers know their inventory capabilities best 4. **Use signals for dynamic targeting** - Real-time signals handle complex, high-cardinality targeting better than overlays 5. **Minimize Technical Overlays**: Use only for geographic restrictions or compliance 6. **Validate Audience Fit**: Ensure product descriptions match campaign goals 7. **Inclusive pricing** - Expect targeting costs to be built into product rates ## Future Evolution * **Enhanced Brief Processing**: More sophisticated natural language understanding * **Audience Discovery**: Better tools for exploring available audiences * **Deeper Signal Integration**: More sophisticated real-time targeting capabilities * **Performance Optimization**: AI-driven audience refinement based on campaign results ## Related Documentation * **[Trusted Match Protocol (TMP)](/dist/docs/3.1.20/trusted-match)** - Real-time execution layer for impression-time targeting, frequency capping, and brand suitability * **[Signals Protocol](/dist/docs/3.1.20/signals/overview)** - Real-time targeting signals for brand suitability and contextual targeting * **[Product Discovery](/dist/docs/3.1.20/media-buy/product-discovery/)** - How briefs lead to targeted product recommendations * **[Example Briefs](/dist/docs/3.1.20/media-buy/product-discovery/example-briefs)** - Real examples of effective targeting briefs * **[Policy Compliance](/dist/docs/3.1.20/media-buy/media-buys/policy-compliance)** - Automated compliance checking and enforcement # Implementing Standard Format Support Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/capability-discovery/implementing-standard-formats Implementing standard creative formats in AdCP — reference IAB formats from the AdCP creative reference agent instead of redefining display, video, and native specs. This guide is for **sales agents** implementing creative format support. Before creating custom format definitions, you should check what formats are already available in the AdCP ecosystem. ## Implementation Philosophy: Check First, Then Extend Most publishers support some combination of standard formats - whether IAB standard sizes, common video specs, or widely-used native formats. Rather than defining these yourself: 1. **Check the reference creative agent** to see if your formats are already defined 2. **Reference the formats** if they match your needs 3. **Only create custom formats** when you have truly unique requirements This approach: * **Reduces maintenance burden** - No need to maintain format definitions that already exist * **Enables creative portability** - Buyers can reuse creatives across publishers * **Improves ecosystem consistency** - Everyone uses the same specifications for common formats ## The Reference Creative Agent **URL:** `https://creative.adcontextprotocol.org` **Status:** Production service - this is a real, working AdCP creative agent The reference creative agent provides authoritative definitions for common creative formats used across the advertising industry: * IAB standard display sizes (300x250, 728x90, 320x50, etc.) * Standard video formats (15s, 30s, 60s pre-roll, etc.) * Audio formats for streaming and podcast insertion * DOOH formats for digital out-of-home * Native formats for responsive placements * Carousel formats for multi-product displays **Before creating custom formats,** query the reference creative agent to see if the formats you need already exist. ## Why Use Standard Formats? ### For Sales Agents * **No maintenance burden**: Don't replicate IAB standard format definitions * **Ecosystem consistency**: Everyone uses the same format specifications * **Focus on differentiation**: Spend time on custom formats unique to your inventory ### For Buyers * **Portability**: One creative works across multiple publishers * **Predictability**: Format requirements are consistent * **Faster launches**: No custom creative production per publisher ## Implementation Steps ### Step 1: Discover What Formats You Need List the creative formats your inventory accepts. For example: * Display: 300x250, 728x90, 320x50 * Video: 15-second pre-roll, 30-second pre-roll * Native: Responsive native format ### Step 2: Check the Reference Creative Agent Query `https://creative.adcontextprotocol.org` using `list_creative_formats` to see which of your formats already exist. The reference agent maintains formats for: * All IAB standard display sizes * Common video durations and aspect ratios * Standard audio formats * DOOH specifications * Native ad formats ### Step 3: Decide What to Reference vs Define **Reference formats when:** * The format matches your technical requirements exactly * You accept creatives built to standard IAB specifications * You want creative portability across publishers **Define custom formats when:** * You have unique technical requirements (custom dimensions, special asset needs) * You need publisher-specific validation or assembly logic * You offer premium, differentiated ad experiences ### Step 4: Implement Your Response When implementing `list_creative_formats`, include the reference creative agent if you support any standard formats: **Most Common: Reference Standard Formats** If your inventory accepts standard formats (which most publishers do): ```json theme={null} { "formats": [], "creative_agents": [ "https://creative.adcontextprotocol.org" ] } ``` This tells buyers: "We support all standard formats from the reference creative agent." **With Custom Formats: Combine Both** If you have unique formats PLUS standard format support: ```json theme={null} { "formats": [ { "format_id": { "agent_url": "https://youragent.com", "id": "homepage_takeover" }, "name": "Homepage Takeover", "type": "rich_media", "assets": [...] } ], "creative_agents": [ "https://creative.adcontextprotocol.org" ] } ``` This tells buyers: "We support our custom homepage takeover format, PLUS all standard formats from the reference creative agent." **Only Custom Formats: Skip the Reference** If you ONLY support custom formats with truly unique requirements (rare): ```json theme={null} { "formats": [ { "format_id": { "agent_url": "https://youragent.com", "id": "custom_holographic_display" }, "name": "Holographic Display Format", "type": "dooh", "assets": [...] } ] } ``` **Note:** This is uncommon. Most publishers accept at least some standard formats (300x250, etc.) and should include the reference creative agent URL. ## What Standard Formats Are Included? The reference creative agent provides formats across all major channels: * **[Display Formats](/dist/docs/3.1.20/creative/channels/display)** - IAB standard banner sizes (300x250, 728x90, 320x50, etc.) * **[Video Formats](/dist/docs/3.1.20/creative/channels/video)** - Standard video ad specifications (15s, 30s, vertical, CTV) * **[Audio Formats](/dist/docs/3.1.20/creative/channels/audio)** - Streaming audio and podcast insertion formats * **[DOOH Formats](/dist/docs/3.1.20/creative/channels/dooh)** - Digital out-of-home billboard and transit specs * **[Carousel Formats](/dist/docs/3.1.20/creative/channels/carousels)** - Multi-product and slideshow formats Each format includes: * Precise technical requirements (dimensions, duration, file types) * Required and optional assets with specifications * Universal macro support * Preview and validation capabilities ## Format Discovery Flow When buyers discover formats from your sales agent: 1. **Buyer calls** `list_creative_formats` on your sales agent 2. **Your response includes** custom formats plus `creative_agents: ["https://creative.adcontextprotocol.org"]` 3. **Buyer recursively queries** the reference agent to discover standard formats 4. **Buyer sees combined list** of your custom formats plus all standard formats The buyer tracks which URLs they've queried to avoid infinite loops. ## Best Practices for Sales Agents ### ✅ DO Reference Standard Formats ```json theme={null} { "creative_agents": [ "https://creative.adcontextprotocol.org" ] } ``` **When:** Your inventory accepts standard IAB sizes with no special requirements **Why:** Reduces maintenance, ensures consistency, buyers already have compatible creatives ### ✅ DO Define Custom Formats ```json theme={null} { "formats": [ { "format_id": { "agent_url": "https://youragent.com", "id": "native_feed_card" }, "type": "native" } ] } ``` **When:** You have unique inventory experiences or specific technical requirements **Why:** Enables differentiation and premium inventory ### ❌ DON'T Replicate Standard Formats ```json theme={null} { "formats": [ {"format_id": {"agent_url": "https://youragent.com", "id": "display_300x250"}}, {"format_id": {"agent_url": "https://youragent.com", "id": "display_728x90"}}, {"format_id": {"agent_url": "https://youragent.com", "id": "display_320x50"}}, // ... copying 50+ standard formats ] } ``` **Why not:** Maintenance burden, version drift, inconsistency across ecosystem **Exception:** You need custom validation/preview logic for these formats ### ✅ DO Use Both When Appropriate ```json theme={null} { "formats": [ // Your differentiating formats ], "creative_agents": [ "https://creative.adcontextprotocol.org" ] } ``` **Result:** Buyers see your custom formats plus all standard formats ## Format ID Namespacing To prevent conflicts when multiple agents define formats, AdCP uses a **namespace pattern** for format identifiers. ### Namespace Pattern: `{domain}:{format_id}` **Structure:** ``` domain:format_id ``` **Examples:** * `creative.adcontextprotocol.org:display_300x250` * `creative.adcontextprotocol.org:video_30s_hosted` * `youragent.com:homepage_takeover_2024` * `publisher.example:native_feed_card` ### Domain Requirements **The domain in a namespaced format\_id MUST:** 1. **Host a valid agent card** at `{domain}/.well-known/adagents.json` 2. **Declare MCP endpoint** in the agent card extension 3. **Declare A2A endpoint** in the standard agent card section **Example agent card at** `https://youragent.com/.well-known/adagents.json`: ```json theme={null} { "agents": [ { "agent_url": "https://youragent.com", "agent_name": "Your Creative Agent", "protocols": ["mcp", "a2a"], "mcp_endpoint": "https://youragent.com/mcp", "a2a_endpoint": "https://youragent.com/a2a", "capabilities": ["list_creative_formats", "preview_creative"] } ] } ``` This ensures the domain in the namespace is a valid, discoverable agent that can provide format specifications and validation. ### When to Use Namespaces **Always use namespaced format\_ids** when defining formats: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/list-creative-formats-response.json", "status": "completed", "formats": [ { "format_id": { "agent_url": "https://youragent.com", "id": "homepage_takeover" }, "name": "Homepage Takeover", "type": "rich_media" } ] } ``` **Benefits:** * **No collisions** - Each agent owns its namespace * **Clear ownership** - Domain identifies the authoritative agent * **Discoverable** - Buyers can query the domain's agent card * **Verifiable** - Domain must prove ownership via agent card ### Namespace Examples by Agent Type **Reference Creative Agent:** ```json theme={null} { "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" } } ``` **Publisher Sales Agent:** ```json theme={null} { "format_id": { "agent_url": "https://youragent.com", "id": "custom_format" } } ``` **DCO Platform:** ```json theme={null} { "format_id": { "agent_url": "https://dco.example", "id": "dynamic_creative_v2" } } ``` ### Conflict Resolution With namespaced format\_ids, conflicts **cannot occur** - each domain controls its own namespace. **No conflict:** ```json theme={null} // Two different formats, both valid { "format_id": { "agent_url": "https://publisher-a.com", "id": "video_30s" } } { "format_id": { "agent_url": "https://publisher-b.com", "id": "video_30s" } } ``` If a buyer encounters the same namespaced format\_id from multiple sources, they are **the same format** - the namespace guarantees identity. ### Validation Rules 1. **Domain MUST match agent\_url domain:** ```json theme={null} // ✅ Valid - domain matches { "format_id": { "agent_url": "https://youragent.com", "id": "format_x" } } // ❌ Invalid - domain mismatch { "format_id": { "agent_url": "https://otheragent.com", "id": "format_x" } } ``` 2. **Domain MUST have valid agent card:** * Agent card must exist at `{domain}/.well-known/adagents.json` * Must declare MCP and/or A2A endpoints * Endpoints must be functional 3. **Format\_id MUST follow pattern:** * `{domain}:{format_id}` structure * Domain is valid DNS hostname * Format\_id is alphanumeric with underscores/hyphens ### Migration from Unnamespaced IDs If you previously used simple IDs like `display_300x250`, migrate to namespaced versions: **Before:** ```json theme={null} { "format_id": { "agent_url": "https://youragent.com", "id": "display_300x250_old" } } ``` **After:** ```json theme={null} { "format_id": { "agent_url": "https://youragent.com", "id": "display_300x250" } } ``` Support both during transition if needed, but new implementations should use namespaced IDs from the start. ## Reference Agent as Format Authority Each format includes a `format_id` field with an `agent_url` indicating its authoritative source: ```json theme={null} { "$schema": "/schemas/3.1.20/core/format.json", "format_id": { "agent_url": "https://creative.adcontextprotocol.org", "id": "display_300x250" }, "name": "Medium Rectangle", "type": "display" } ``` The creative agent at that URL is the definitive source for: * Complete format specifications * Asset requirements and validation rules * Preview generation * Rendering instructions ## When NOT to Use Standard Formats Define your own formats when: 1. **Unique technical requirements** - Your platform needs different specs than IAB standards 2. **Custom validation** - You require additional creative review or approval 3. **Proprietary assembly** - Your rendering pipeline has special requirements 4. **Premium experiences** - Differentiated ad products not covered by standard formats Even in these cases, you can reference standard formats for basic inventory while defining custom formats for premium placements. ## Implementation Notes ### Format Authority Pattern The `agent_url` field enables a **distributed format authority** model: * Reference agent is authoritative for IAB standards * Each publisher is authoritative for their custom formats * Buyers can discover and validate against the correct authority ### Version Management The reference creative agent maintains format versions and compatibility: * Format definitions evolve with industry standards * Backward compatibility is maintained * Buyers can rely on stable format\_id values ### What Makes a Format "Standard" **Standard formats** are those defined by the reference creative agent at `https://creative.adcontextprotocol.org` based on: 1. **Industry specifications** - IAB standards, VAST/VPAID specs, common ad unit sizes 2. **Cross-platform compatibility** - Work across multiple publishers without customization 3. **Stable definitions** - Versioned and maintained for consistency across the ecosystem **Protocol perspective:** At the protocol level, standard formats are just formats like any other - there's no special API treatment. The `agent_url` field identifies the reference agent as the authoritative source, just as it would for any custom format. **Ecosystem perspective:** Standard formats enable portability. A buyer can build one creative and use it across many publishers who reference the same format definitions. ## Related Documentation * [Creative Protocol Overview](/dist/docs/3.1.20/creative) - How formats, manifests, and agents work together * [Creative Formats](/dist/docs/3.1.20/creative/formats) - Understanding format specifications and discovery * [Channel Guides](/dist/docs/3.1.20/creative/channels/video) - Detailed format documentation by media type * [list\_creative\_formats Task](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) - API reference for format discovery # Capability Discovery Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/capability-discovery/index AdCP capability discovery — understand what creative formats a seller supports and which properties they represent before buying media. Before you can effectively buy advertising through AdCP, you need to understand two fundamental capabilities: **what creative formats are supported** and **which properties sales agents are authorized to represent**. This section covers the tools and concepts that form the foundation of AdCP's advertising ecosystem. ## What You'll Learn ### [Implementing Standard Format Support](/dist/docs/3.1.20/media-buy/capability-discovery/implementing-standard-formats) 🎨 Learn how sales agents can support standard creative formats through the reference creative agent. Learn how to: * Reference standard IAB formats without replicating them * Implement custom formats for unique inventory * Use format ID namespacing to prevent conflicts * Combine standard and custom format support * Validate format compatibility with advertising products * Leverage the Standard Creative Agent for standard formats * Work with publisher-specific creative agents for custom formats ### [Understanding Authorization](/dist/docs/3.1.20/governance/property/authorized-properties) 🔐 Learn how AdCP prevents unauthorized resale and ensures sales agents are legitimate. Understand: * The problem of unauthorized resale in digital advertising * How publishers authorize sales agents via `adagents.json` * How to validate sales agent authorization before purchasing * Property tags and large-scale authorization management **Cross-Protocol Foundation**: The `adagents.json` authorization system applies across ALL AdCP protocols (Media Buy, Signals, and future Curation). See the [adagents.json specification](/dist/docs/3.1.20/governance/property/adagents) for complete implementation details. ## Foundation Tasks These capability discovery tasks provide the reference data needed for effective AdCP workflows: ### [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) The primary capability discovery task. Returns protocol versions, supported features, portfolio information, and governance capabilities in a single call. Use this as your first interaction with any AdCP agent. ### [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) Discover all supported creative formats with detailed specifications including dimensions, file types, duration limits, and technical requirements. ## Integration Pattern Capability discovery typically happens early in your AdCP workflow: 1. **Discover Capabilities**: Call [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) to learn what the agent supports 2. **Understand Formats**: Call [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) to learn supported creative types 3. **Validate Authorization**: Check the `media_buy.portfolio` from capabilities, then verify via publisher `adagents.json` 4. **Discover Products**: Search for advertising inventory with [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) 5. **Plan Creatives**: Match discovered products to available formats for production planning 6. **Execute Campaigns**: Create media buys with confidence in format compatibility and authorization ## Why This Matters ### Creative Formats * **Production Planning**: Know requirements before creating assets * **Creative Agents**: Leverage AI-powered agents to build and validate creatives * **Platform Compatibility**: Ensure creatives work across advertising platforms * **Cost Efficiency**: Avoid recreating assets due to specification mismatches * **Quality Assurance**: Meet technical standards for optimal performance * **Preview Capabilities**: Test creative rendering before campaign launch ### Authorized Properties * **Fraud Prevention**: Avoid unauthorized sellers and inventory fraud * **Brand Safety**: Ensure you're buying from legitimate property owners * **Legal Compliance**: Maintain clear audit trails of authorized transactions * **Trust Building**: Create confidence in the advertising supply chain Together, these capabilities provide the foundation for safe, efficient, and effective advertising through AdCP. ## Related Documentation * **[Task Reference](/dist/docs/3.1.20/media-buy/task-reference/)** - Complete API documentation * **[Product Discovery](/dist/docs/3.1.20/media-buy/product-discovery/)** - Finding advertising inventory * **[Creatives](/dist/docs/3.1.20/media-buy/creatives/)** - Creative asset management * **[Creative Protocol](/dist/docs/3.1.20/creative/)** - Creative agents and manifests * **[Creative Channel Guides](/dist/docs/3.1.20/creative/channels/video)** - Format examples and patterns * **[Creative Manifests](/dist/docs/3.1.20/creative/creative-manifests)** - Understanding creative specifications * **[adagents.json Specification](/dist/docs/3.1.20/governance/property/adagents)** - Publisher authorization system (applies across all AdCP protocols) # Conversion Tracking & Optimization Goals Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/conversion-tracking/index AdCP conversion tracking — configure pixels and event sources with sync_event_sources, send conversion events with log_event, and set optimization goals for campaign delivery. Conversion tracking in AdCP connects advertising spend to business outcomes. Two tasks handle the lifecycle: [`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) configures where events come from, and [`log_event`](/dist/docs/3.1.20/media-buy/task-reference/log_event) sends the events themselves. Event data feeds into delivery reporting (conversions, ROAS, cost per acquisition) and enables optimization goals on media buy packages. ## The flow ```mermaid theme={null} sequenceDiagram participant B as Buyer participant S as Seller rect rgb(240, 248, 255) Note over B,S: Setup B->>S: sync_event_sources (configure sources) S->>B: Setup instructions (snippets, pixel URLs) B->>B: Install snippets on site/app end rect rgb(240, 255, 240) Note over B,S: Event collection B->>S: log_event (send conversions) S->>S: Match users, attribute conversions end rect rgb(255, 248, 240) Note over B,S: Optimization B->>S: create_media_buy (with optimization_goals) S->>S: Optimize delivery toward conversions B->>S: get_media_buy_delivery S->>B: Conversion metrics (ROAS, CPA) end ``` This shows the recommended order. In practice, media buys can be created before events are flowing — the seller begins optimizing once sufficient event history accumulates. ## Event source An event source represents a channel through which conversion or marketing events are collected — a website pixel, mobile SDK, server-to-server integration, CRM import, or seller- or platform-native owned property such as a channel, profile, feed, podcast, or newsletter list. Configure event sources with [`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources). You provide an `event_source_id`, optional `name`, `event_types`, `action_source`, `surface`, and `allowed_domains`. The response includes additional fields for each source: | Field | Type | Description | | --------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `seller_id` | string | Seller-assigned identifier in their ad platform | | `action` | string | What happened: `created`, `updated`, `unchanged`, `deleted`, `failed` | | `managed_by` | string | `buyer` (you configured it) or `seller` (always-on, seller-managed) | | `action_source` | [ActionSource](#action-sources) | Flat source type for legacy and broad compatibility (website pixel, app SDK, in-store, etc.) | | `surface` | [EventSurface](#event-surfaces) | Optional structured surface, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store | | `setup` | object | Implementation details — snippet code, snippet type, instructions | ### Buyer-managed vs seller-managed **Buyer-managed** sources are ones you configure via `sync_event_sources`. You control the event types, domains, and lifecycle. **Seller-managed** sources are always-on and appear in the response with `managed_by: "seller"`. These are common in commerce media where the retailer provides built-in attribution (e.g., purchase tracking on their own platform). Products with `conversion_tracking.platform_managed: true` indicate the seller provides these sources. To discover all sources on an account (including seller-managed), call `sync_event_sources` without an `event_sources` array: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-event-sources-request.json", "idempotency_key": "c9f3d6a7-5678-48e4-5678-9012345abcde", "account": { "account_id": "acct_12345" } } ``` ## Event An event represents a user action — a purchase, lead submission, page view, app install, or any of the [standard event types](#event-types). Send events with [`log_event`](/dist/docs/3.1.20/media-buy/task-reference/log_event): ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/event.json", "event_id": "evt_purchase_12345", "event_type": "purchase", "event_time": "2026-01-15T14:30:00Z", "action_source": "website", "event_source_url": "https://www.example.com/checkout/confirm", "user_match": { "hashed_email": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "click_id": "abc123def456", "click_id_type": "gclid" }, "custom_data": { "value": 149.99, "currency": "USD", "order_id": "order_98765", "num_items": 3 } } ``` | Field | Type | Required | Description | | ------------------- | ------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `event_id` | string | Yes | Unique identifier for deduplication (scoped to event\_type + event\_source\_id). Max 256 chars. | | `event_type` | [EventType](#event-types) | Yes | Standard event type | | `event_time` | date-time | Yes | ISO 8601 timestamp when the event occurred | | `user_match` | [UserMatch](#user-match) | No | User identifiers for attribution matching | | `custom_data` | [CustomData](#custom-data) | No | Event-specific data (value, currency, items) | | `action_source` | [ActionSource](#action-sources) | No | Where the event occurred | | `surface` | [EventSurface](#event-surfaces) | No | Structured surface context for the event, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store | | `event_source_url` | uri | No | URL where the event occurred (required when action\_source is `website`) | | `custom_event_name` | string | No | Name for custom events (when event\_type is `custom`) | Events are deduplicated by `event_id` + `event_type` + `event_source_id`. Sending the same event multiple times is safe. ## User match User identifiers enable the seller to attribute conversions to ad impressions. Provide the strongest identifiers available — more identifiers means higher match rates. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/user-match.json", "hashed_email": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", "uids": [ { "type": "uid2", "value": "AbC123XyZ..." } ], "click_id": "abc123def456", "click_id_type": "gclid" } ``` At least one identifier is required. The hierarchy from strongest to weakest: | Field | Type | Match quality | Description | | ------------------- | ------ | ------------- | ----------------------------------------------------------------------- | | `uids` | UID\[] | Deterministic | Universal ID values (`rampid`, `id5`, `uid2`, `euid`, `pairid`, `maid`) | | `hashed_email` | string | Deterministic | SHA-256 hash of lowercase, trimmed email (64-char hex) | | `hashed_phone` | string | Deterministic | SHA-256 hash of E.164 phone number (64-char hex) | | `click_id` | string | Deterministic | Platform click identifier (fbclid, gclid, ttclid, etc.) | | `click_id_type` | string | — | Type of click identifier | | `client_ip` | string | Probabilistic | Client IP address (requires `client_user_agent`) | | `client_user_agent` | string | Probabilistic | Client user agent (requires `client_ip`) | **Hashing**: Normalize before hashing — emails to lowercase with whitespace trimmed, phone numbers to E.164 format (e.g., `+12065551234`). Hash with SHA-256, output as 64-character lowercase hex. Send multiple identifier types when available. The seller uses the best available match. ## Custom data Event-specific data for attribution and reporting. For purchase events, always include `value` and `currency` to enable ROAS reporting. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/event-custom-data.json", "value": 149.99, "currency": "USD", "order_id": "order_98765", "content_ids": ["SKU-1234", "SKU-5678"], "num_items": 3, "contents": [ { "id": "SKU-1234", "quantity": 2, "price": 49.99, "brand": "Acme" }, { "id": "SKU-5678", "quantity": 1, "price": 50.01, "brand": "Nova" } ] } ``` | Field | Type | Description | | ------------------ | ---------- | ----------------------------------------------------------------------------------------------- | | `value` | number | Monetary value of the event | | `currency` | string | ISO 4217 currency code (e.g., `USD`, `EUR`, `GBP`) | | `order_id` | string | Unique order or transaction identifier | | `content_ids` | string\[] | Product or content identifiers | | `content_type` | string | Category of content (product, service, etc.) | | `content_name` | string | Name of the product or content | | `content_category` | string | Category of the product or content | | `num_items` | integer | Number of items in the event | | `search_string` | string | Search query (for search events) | | `progress_percent` | number | Content progress percentage reached, primarily for `watch_milestone` events | | `progress_seconds` | number | Content progress duration reached in seconds, primarily for time-based `watch_milestone` events | | `contents` | Content\[] | Per-item details: `id` (required), `quantity`, `price`, `brand` | ## Event types Standard marketing event types, aligned with IAB ECAPI: | Event type | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------- | | `page_view` | User viewed a page | | `view_content` | User viewed specific content (product, article, etc.) | | `select_content` | User selected or clicked on content | | `select_item` | User selected a specific product or item from a list | | `search` | User performed a search | | `share` | User shared content via social or messaging | | `add_to_cart` | User added an item to cart | | `remove_from_cart` | User removed an item from cart | | `viewed_cart` | User viewed their shopping cart | | `add_to_wishlist` | User added an item to a wishlist | | `initiate_checkout` | User started checkout process | | `add_payment_info` | User added payment information | | `purchase` | User completed a purchase | | `refund` | A purchase was fully or partially refunded (adjusts ROAS) | | `lead` | User expressed interest (form submission, signup, etc.) | | `qualify_lead` | Lead qualified by sales or scoring criteria | | `close_convert_lead` | Lead converted to a customer or closed deal | | `disqualify_lead` | Lead disqualified or marked as not viable | | `complete_registration` | User completed account registration | | `subscribe` | User started a paid subscription or paid membership | | `follow` | User made a free, durable opt-in to an account, channel, feed, list, podcast, or similar owned property | | `content_view` | User meaningfully consumed a content unit, such as a watch, listen, stream, episode play, or read | | `watch_milestone` | User reached a content progress threshold, such as 25%, 50%, 75%, 100%, or a seconds-viewed milestone | | `start_trial` | User started a free trial | | `app_install` | User installed an application | | `app_launch` | User launched an application | | `contact` | User initiated contact (call, message, etc.) | | `schedule` | User scheduled an appointment or event | | `donate` | User made a donation | | `submit_application` | User submitted an application (loan, job, etc.) | | `custom` | Custom event type (specify in `custom_event_name`) | Use `view_content` for a product, article, or page content view where the view is mostly context for a lower-funnel goal. Use `content_view` when meaningful consumption of the content unit is itself the event being optimized or attributed. For `watch_milestone`, put the threshold in `custom_data.progress_percent` or `custom_data.progress_seconds`; reserve `custom_data.value` for monetary event value. ## Action sources Where the conversion event originated: | Action source | Description | | ------------------ | -------------------------------------------- | | `website` | Event occurred on a website | | `app` | Event occurred in a mobile or desktop app | | `offline` | Event occurred offline (imported data) | | `phone_call` | Event originated from a phone call | | `chat` | Event originated from a chat conversation | | `email` | Event originated from an email interaction | | `in_store` | Event occurred at a physical retail location | | `system_generated` | Event generated by an automated system | | `other` | Other source (specify in `ext`) | ## Event surfaces `action_source` stays intentionally flat for compatibility. Use `surface` when the optimization-relevant source needs more structure, especially for creator and content engagement on owned platform properties. ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/core/event-surface.json", "category": "owned_property", "property_type": "channel", "namespace": "video_platform", "property_id": "channel_123" } ``` | Field | Type | Description | | --------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `category` | enum | Closed generic category: `owned_property`, `website`, `app`, `offline`, `phone_call`, `chat`, `email`, `in_store`, `system_generated`, or `other` | | `property_type` | string | Open vocabulary for the property kind, such as `channel`, `profile`, `feed`, `list`, `podcast`, `playlist`, or `newsletter` | | `namespace` | string | Free-form platform, publisher, or system namespace, such as `video_platform`, `short_video_app`, or `audio_service`; this is not an enum | | `property_id` | string | Optional identifier for the property within `namespace` | For `owned_property`, set both `property_type` and `namespace`, and include `property_id` whenever the platform exposes a stable property identifier. When `surface.category` matches an `action_source` value, producers should set `action_source` to the same value. For `owned_property`, keep the closest compatible flat value for older consumers, commonly `system_generated` for platform-native events or `other` when no closer value applies. Platform-native details such as a raw conversion origin can still ride in `ext`, but shared optimization meaning belongs in `event_type`, `action_source`, and `surface`. ## Event source health Sellers that evaluate event source quality include a `health` object on each source in the [`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) response. This is the AdCP equivalent of platform-specific quality scores like Snap's Event Quality Score (EQS) or Meta's Event Match Quality (EMQ). The `status` field is the AdCP-standardized score — comparable across all sellers: | Status | Meaning | | -------------- | ------------------------------------------------------------------- | | `insufficient` | Setup incomplete or event quality too low — optimization cannot run | | `minimum` | Functional but data quality limits optimization effectiveness | | `good` | Meets quality thresholds for most optimization goals | | `excellent` | Exceeds quality thresholds across all dimensions | Buyer agents should key decisions off `status`, not `detail`. The optional `detail` object contains seller-specific scoring (e.g., Snap's 0-10 EQS, Meta's 0-10 EMQ) for human dashboards or advanced diagnostics, but scales vary by seller and cannot be compared across platforms. | Field | Type | Description | | --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | string | AdCP-standardized health level. Use for cross-seller decisions. | | `detail` | object | Seller-specific `score`, `max_score`, and optional `label`. Only present when the seller has a native quality score. | | `match_rate` | number | Fraction of events matched to ad interactions (0.0-1.0). Low rates indicate weak user\_match identifiers. Only available from sellers that compute match rates (Snap, Meta). | | `last_event_at` | date-time | Timestamp of the most recent event received. | | `evaluated_at` | date-time | When this health assessment was computed. Use to detect stale assessments. | | `events_received_24h` | integer | Events received in the last 24 hours. | | `issues` | array | Actionable problems with `severity` and `message`. Sellers should limit to the top 3-5 most actionable items. Buyer agents should sort by severity rather than relying on array position. | Health is reported per event source, not per account. A buyer with a healthy website pixel and a broken app SDK will see different health on each. **When `health` is absent**, the seller does not evaluate event source quality. Buyer agents should proceed without health gating — the seller handles quality internally. Do not treat absent health as `insufficient`. ### How sellers compute health Sellers with native API-accessible quality scores (Snap EQS, Meta EMQ) relay them directly in `status` and `detail`. Most sellers do not have native scores — they derive `status` from operational metrics: * **`insufficient`**: tag inactive, or `events_received_24h` is 0 * **`minimum`**: tag active, low volume or high error rate * **`good`**: firing steadily, reasonable volume, core event types covered * **`excellent`**: high volume, low errors, enhanced matching enabled When sellers compute health from reporting data, the `evaluated_at` timestamp tells the buyer how fresh the assessment is. Assessments older than 24 hours may not reflect recent changes to tag configuration or event volume. The `detail` object is absent for these sellers — there is no native score to relay. **Schema**: [`/schemas/3.1.20/core/event-source-health.json`](https://adcontextprotocol.org/schemas/3.1.20/core/event-source-health.json) ## Measurement readiness Products that support event-based optimization can include a `measurement_readiness` object in [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) responses. This tells the buyer whether their event setup is sufficient for the product to optimize effectively. | Field | Type | Description | | ---------------------- | ------------ | ----------------------------------------------------------------------- | | `status` | string | AdCP-standardized level: `insufficient`, `minimum`, `good`, `excellent` | | `required_event_types` | EventType\[] | Event types this product needs | | `missing_event_types` | EventType\[] | Required types the buyer hasn't configured | | `issues` | array | Actionable problems with `severity` and `message` | | `notes` | string | Seller explanation or recommendations | Measurement readiness is evaluated per product in the context of the buyer's account. The same product shows different readiness for different buyers depending on their event source configuration. **When `measurement_readiness` is absent**, the product either does not use event-based optimization (CTV awareness, guaranteed display) or the seller does not provide readiness assessments. In both cases, the buyer agent should treat the product as viable. Do not treat absent readiness as `insufficient`. Unlike event source health, measurement readiness has no `evaluated_at` timestamp — it is evaluated fresh on each `get_products` call using the buyer's current event source configuration. ### Cross-seller buyer agent pattern A buyer agent talking to multiple sellers writes one set of rules that works everywhere. Any status other than `insufficient` means the product can optimize — the question is how well. The standardized `status` field means no per-seller integration code: ```javascript test=false theme={null} // Works across all sellers — no seller-specific logic for (const seller of sellers) { const sources = await seller.syncEventSources({ account: seller.account }); // Surface issues from any seller — sort by severity, don't rely on array position for (const source of sources.event_sources) { if (source.health?.status === "insufficient") { surfaceIssues(source.health.issues ?? []); } } const products = await seller.getProducts({ account: seller.account, buying_mode: "brief", brief: campaign.brief, }); for (const product of products.products) { const mr = product.measurement_readiness; // Absent = no event-based optimization needed (CTV, awareness), treat as viable if (!mr) { viable.push(product); continue; } // For DR products, require good or better if (campaign.goal === "conversions" && mr.status === "minimum") { warnings.push({ product, reason: "Event setup is functional but limits optimization" }); viable.push(product); // Still viable, but flag it } else if (mr.status !== "insufficient") { viable.push(product); } else { skipped.push({ product, issues: mr.issues }); } } } ``` **Schema**: [`/schemas/3.1.20/core/measurement-readiness.json`](https://adcontextprotocol.org/schemas/3.1.20/core/measurement-readiness.json) ### Trust boundaries The `issues[].message`, `measurement_readiness.notes`, and `detail.label` fields are seller-provided free text. Buyer agents should treat these as untrusted content — do not pass them directly into LLM system prompts or use them as decision-making inputs without a trust boundary. They are safe to display to humans or include in informational context, but should not influence agent control flow. ## Optimization goals Optimization goals tell the seller what to optimize delivery toward. Set them on a package in [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy#campaign-with-conversion-optimization). A package accepts an array of goals — each with an optional `priority` (1 = highest). Products declare `max_optimization_goals` when they limit how many goals a package can carry (most social platforms accept only 1). **Schema**: [`/schemas/3.1.20/core/optimization-goal.json`](https://adcontextprotocol.org/schemas/3.1.20/core/optimization-goal.json) There are two kinds of goals, discriminated by `kind`: * **`kind: "metric"`** — Optimize for a seller-tracked delivery metric (clicks, views, engagements, etc.). No event source or conversion tracking setup required. The product declares which metrics it supports in `metric_optimization`. * **`kind: "event"`** — Optimize for advertiser-tracked conversion events. Requires event sources registered via `sync_event_sources`. The product declares support in `conversion_tracking`. ### kind: event Optimize for advertiser-tracked conversion events. The `event_sources` array defines which source-type pairs feed this goal. When the seller supports `multi_source_event_dedup` (declared in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities)), they deduplicate by `event_id` across all entries — the same business event reported by multiple sources counts once, using `value_field` and `value_factor` from the first matching entry. When `multi_source_event_dedup` is absent or false, buyers should use a single event source per goal. **Cost per conversion** (single source): ```json theme={null} { "kind": "event", "event_sources": [ { "event_source_id": "website_pixel", "event_type": "lead" } ], "target": { "kind": "cost_per", "value": 25.00 }, "priority": 1 } ``` **Return on ad spend** (multiple sources with refunds): ```json theme={null} { "kind": "event", "event_sources": [ { "event_source_id": "web_pixel", "event_type": "purchase", "value_field": "order_total" }, { "event_source_id": "app_sdk", "event_type": "purchase", "value_field": "order_total" }, { "event_source_id": "web_pixel", "event_type": "refund", "value_field": "refund_amount", "value_factor": -1 } ], "target": { "kind": "per_ad_spend", "value": 4.0 }, "attribution_window": { "post_click": { "interval": 28, "unit": "days" }, "post_view": { "interval": 1, "unit": "days" } }, "priority": 1 } ``` For `per_ad_spend` targets, each event source entry specifies a `value_field` (which field on `custom_data` carries the monetary value) and an optional `value_factor` (multiplier, defaults to 1). The seller computes `sum(value_field * value_factor) / spend` across all deduplicated events. **Maximize conversion value** (no specific ROAS target): ```json theme={null} { "kind": "event", "event_sources": [ { "event_source_id": "web_pixel", "event_type": "purchase", "value_field": "value" } ], "target": { "kind": "maximize_value" }, "priority": 1 } ``` A `maximize_value` target steers spend toward higher-value conversions without committing to a specific return ratio. Requires `value_field` on at least one event source entry. | Field | Type | Required | Description | | ----------------------------------- | ------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `"event"` | Yes | Discriminator | | `event_sources` | array | Yes | Source-type pairs feeding this goal. Seller deduplicates by `event_id` across entries — when the same `event_id` arrives from multiple sources with different `value_field`s, the seller uses the `value_field` and `value_factor` from the first matching entry in this array. | | `event_sources[].event_source_id` | string | Yes | Event source (must be configured via `sync_event_sources`) | | `event_sources[].event_type` | [EventType](#event-types) | Yes | Event type to include (e.g., `purchase`, `lead`, `refund`) | | `event_sources[].custom_event_name` | string | When event\_type is `custom` | Platform-specific custom event name | | `event_sources[].value_field` | string | When target is `per_ad_spend` or `maximize_value` | Which field on `custom_data` carries the monetary value. The seller must use this for value extraction and aggregation — it is not passed directly to underlying platform APIs. | | `event_sources[].value_factor` | number | No | Multiplier the seller must apply to `value_field` before aggregation (default 1). Use -1 for refunds, 0.01 for cents, 0 to zero out a source's value contribution while still counting it for dedup. | | `target.kind` | `"cost_per"` \| `"per_ad_spend"` \| `"maximize_value"` | No | Target type. When omitted, the seller maximizes conversion count within budget (see [default behavior](#default-behavior-for-event-goals)). | | `target.value` | number | Yes (if target set) | Cost per event in buy currency, or return ratio (e.g., 4.0 = \$4 per \$1 spent) | | `attribution_window` | object | No | Click-through and view-through windows. When omitted, the seller uses their default. | | `priority` | integer | No | 1 = highest priority. When omitted, sellers use array position. | ### kind: metric Optimize for a seller-tracked delivery metric. No event source needed — the seller tracks these natively. Products declare which metrics they support in `metric_optimization.supported_metrics`. **Maximize clicks** (no target — seller optimizes for volume within budget): ```json theme={null} { "kind": "metric", "metric": "clicks" } ``` **Cost per click**: ```json theme={null} { "kind": "metric", "metric": "clicks", "target": { "kind": "cost_per", "value": 2.00 }, "priority": 2 } ``` **Minimum click-through rate**: ```json theme={null} { "kind": "metric", "metric": "clicks", "target": { "kind": "threshold_rate", "value": 0.001 }, "priority": 2 } ``` **Minimum attention from a specific vendor** (the `attention_seconds` / `attention_score` enum values on `kind: "metric"` are deprecated — vendor-attested metrics use `kind: "vendor_metric"` instead, which binds the goal to a specific measurement vendor): ```json theme={null} { "kind": "vendor_metric", "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score", "target": { "kind": "threshold_rate", "value": 70 }, "priority": 3 } ``` **Maximize engagements** (social reactions, comments, shares, story opens, overlay taps): ```json theme={null} { "kind": "metric", "metric": "engagements" } ``` **Completed views with duration threshold** (6-second views on TikTok): ```json theme={null} { "kind": "metric", "metric": "completed_views", "view_duration_seconds": 6, "target": { "kind": "cost_per", "value": 0.02 }, "priority": 1 } ``` | Field | Type | Required | Description | | ----------------------- | ---------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `"metric"` | Yes | Discriminator | | `metric` | string | Yes | Seller-native metric (see metrics table below) | | `view_duration_seconds` | number | No | Minimum video view duration (in seconds) that qualifies as a `completed_views` event. Only applicable when metric is `completed_views`. When omitted, the seller uses their platform default. Must be a value listed in the product's `metric_optimization.supported_view_durations` — sellers reject unsupported values. | | `target.kind` | `"cost_per"` \| `"threshold_rate"` | No | Target type. When omitted, the seller maximizes metric volume within budget. | | `target.value` | number | Yes (if target set) | Cost per metric unit in buy currency, or minimum per-impression value | | `priority` | integer | No | 1 = highest priority. When omitted, sellers use array position. | **Metrics**: | Metric | Unit | `threshold_rate` example | Description | | ------------------- | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `clicks` | count/impression | 0.001 (0.1% CTR) | Link clicks, swipe-throughs, CTA taps that navigate away | | `views` | count/impression | 0.70 (70% viewability) | Viewable impressions | | `completed_views` | count/impression | 0.85 (85% VCR) | Video or audio completions. Use `view_duration_seconds` to control the qualifying threshold (e.g., 2s, 6s, 15s). | | `viewed_seconds` | seconds/impression | 3.0 (3s in view) | Time in view per impression. Reported via `viewability.viewed_seconds`; governed by the viewability `standard` (MRC threshold). | | `attention_seconds` | seconds/impression | — | **Deprecated** — has no industry-graduated definition. Use `kind: "vendor_metric"` with `vendor` + `metric_id: "attention_seconds"` instead. | | `attention_score` | score/impression | — | **Deprecated** — has no industry-graduated definition. Use `kind: "vendor_metric"` with `vendor` + `metric_id: "attention_score"` instead. | | `engagements` | count/impression | — | Direct interaction beyond viewing — social reactions/comments/shares, story/unit opens, interactive overlay taps on CTV, companion banner interactions on audio | | `follows` | count/impression | — | New followers, page likes, artist/podcast/channel follows, or free channel/feed subscribes | | `saves` | count/impression | — | Saves, bookmarks, playlist adds, pins — signals of intent to return | | `profile_visits` | count/impression | — | Visits to the brand's in-platform page — profile, artist page, channel, or storefront. Does not include external website clicks (use `clicks` for that). | | `reach` | unique entities/window | — | Unique audience reach within a frequency window. Requires `reach_unit` (e.g., `households`, `individuals`). Use `target_frequency` to set the frequency band for optimization. | ### kind: vendor\_metric For vendor-attested measurement where the metric has no industry-graduated definition — attention (DoubleVerify, IAS, Adelaide, TVision, Lumen), panel-based brand lift (Kantar, Upwave, Cint), emissions (Scope3, Good-Loop — see polarity caveat below), retail-media partner metrics — the goal binds a specific vendor + `metric_id` end-to-end. The seller's bidding stack steers toward that vendor's measurement; delivery reports the value via `vendor_metric_values[]` with the same `(vendor, metric_id)` key. **Direction polarity** (upward-pushing only this minor). `cost_per` and `threshold_rate` are upward-pushing targets — the seller steers delivery toward higher metric values, or toward meeting a minimum threshold. Metrics where the buyer wants to *minimize* (emissions, IVT, latency) currently rely on seller-side polarity interpretation per the vendor's metric definition. First-class minimize semantics (a `direction: "minimize"` field on the goal or a `target.kind: "ceiling_rate"`) are under WG discussion — see [#4644](https://github.com/adcontextprotocol/adcp/issues/4644). ```json theme={null} { "kind": "vendor_metric", "vendor": { "domain": "adelaidemetrics.com" }, "metric_id": "attention_score", "target": { "kind": "threshold_rate", "value": 70 }, "priority": 1 } ``` | Field | Type | Required | Description | | -------------- | ---------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | `"vendor_metric"` | Yes | Discriminator | | `vendor` | BrandRef | Yes | Vendor that defines and computes this metric. Anchored on the vendor's `brand.json` `agents[type='measurement']`. Same shape used across `vendor_metric_values`, `reporting_capabilities.vendor_metrics`, `committed_metrics` (vendor-scope entries), and `performance_standards.vendor`. | | `metric_id` | string | Yes | Identifier in the vendor's vocabulary (e.g., `attention_score`, `awareness_lift`, `gco2e_per_impression`). MUST be in the vendor's published `measurement.metrics[]` catalog. | | `target.kind` | `"cost_per"` \| `"threshold_rate"` | No | Target type. When omitted, the seller maximizes metric value within budget. | | `target.value` | number | Yes (if target set) | Cost per metric unit (currency) or minimum per-impression value. Units are vendor-defined. | | `priority` | integer | No | 1 = highest priority. When omitted, sellers use array position. | **Three preconditions for goal acceptance.** Sellers MUST reject `vendor_metric` goals that fail the capability or reporting-coherence preconditions; sellers SHOULD verify the discovery precondition: 1. **Discovery** (SHOULD this minor; MUST at next minor) — `metric_id` is in the vendor's published `measurement.metrics[]` catalog (queried from the vendor's `brand.json` measurement agent). Softened to SHOULD while measurement-vendor adoption of AdCP-conformant capability publication catches up; tightens to MUST once two-plus vendors ship conformant agents. 2. **Capability** — the `(vendor, metric_id)` pair is in the product's `vendor_metric_optimization.supported_metrics[]`, and the goal's `target.kind` is in the matching entry's `supported_targets`. 3. **Reporting coherence** — the package's `committed_metrics[]` includes a matching `{ scope: "vendor", vendor, metric_id }` entry. **Optimization without committed reporting is unverifiable** — the buyer can't grade performance against a goal whose value the seller isn't contractually obligated to populate. This precondition is what makes the goal meaningful; the seller MUST reject (with `TERMS_REJECTED`) any goal whose metric isn't also committed for reporting on the same package. **Distinct from `metric` kind.** The `metric` kind is for seller-native measurement (clicks, views, completed\_views, reach, engagements, etc.) where no vendor binding is needed — the seller measures the metric natively. The `vendor_metric` kind is for vendor-attested measurement where the same metric name means different things across vendors and must be reconciled to a specific source. The deprecated `attention_seconds` / `attention_score` values on the `metric` kind enum predate this split and route through `vendor_metric` going forward. **Full lifecycle reference.** See [Metric lifecycle](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting#metric-lifecycle) for the complete capability → commitment → optimization → delivery picture across both standard and vendor metric flows. ### Target kinds All target kinds across the three goal kinds: | `target.kind` | Metric goals | Vendor metric goals | Event goals | Description | | ---------------- | ---------------------------- | ------------------------------------------ | ------------------------------- | --------------------------------------------------------------------- | | `cost_per` | Cost per click/view/etc. | Cost per vendor metric unit | Cost per conversion event | `spend / count` | | `threshold_rate` | Minimum per-impression value | Minimum per-impression vendor metric value | — | `at least X per impression` | | `per_ad_spend` | — | — | Target return on ad spend | `sum(value_field * value_factor) / spend` | | `maximize_value` | — | — | Maximize total conversion value | Steers spend toward higher-value conversions. Requires `value_field`. | ### Choosing a strategy | Goal | When to use | What you set | | -------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | Max conversions | As many conversions as possible within budget | `kind: "event"` + event sources, no target. `value_field` may be present for reporting but does not change the objective. | | Target cost per conversion | Specific cost per event | `kind: "event"` + `target: { kind: "cost_per", value: 25.0 }` | | Target return on ad spend | Specific return ratio on event values | `kind: "event"` + `value_field` on sources + `target: { kind: "per_ad_spend", value: 4.0 }` | | Maximize conversion value | Steer toward higher-value conversions without a ROAS target | `kind: "event"` + `value_field` on sources + `target: { kind: "maximize_value" }` | | Max clicks | Maximize clicks within budget | `kind: "metric"`, `metric: "clicks"`, no target | | Target cost per click | Specific cost per click | `kind: "metric"`, `metric: "clicks"` + `target: { kind: "cost_per", value: 2.0 }` | | Target CTR | Minimum click-through rate | `kind: "metric"`, `metric: "clicks"` + `target: { kind: "threshold_rate", value: 0.001 }` | | Target viewability | Minimum viewability rate | `kind: "metric"`, `metric: "views"` + `target: { kind: "threshold_rate", value: 0.70 }` | | Target attention (vendor-bound) | Minimum attention from a specific vendor | `kind: "vendor_metric"`, `vendor: { domain: "adelaidemetrics.com" }`, `metric_id: "attention_score"` + `target: { kind: "threshold_rate", value: 70 }` | | Target brand lift (vendor-bound) | Maximize awareness lift from a specific panel provider | `kind: "vendor_metric"`, `vendor: { domain: "kantar.com" }`, `metric_id: "awareness_lift"`, no target (maximize) | | Target VCR | Minimum video completion rate | `kind: "metric"`, `metric: "completed_views"` + `target: { kind: "threshold_rate", value: 0.85 }` | | Completed views with duration | Video views with specific duration threshold | `kind: "metric"`, `metric: "completed_views"` + `view_duration_seconds: 6` | | Max engagements | Maximize social interactions within budget | `kind: "metric"`, `metric: "engagements"`, no target | | Max follows | Maximize new followers, page likes, or free channel/feed subscribes | `kind: "metric"`, `metric: "follows"`, no target | | Max saves | Maximize saves/bookmarks/playlist adds | `kind: "metric"`, `metric: "saves"`, no target | | Max profile visits | Drive traffic to brand page/profile | `kind: "metric"`, `metric: "profile_visits"`, no target | | Max unique reach | Maximize unique audience within budget | `kind: "metric"`, `metric: "reach"` + `reach_unit: "households"`, no target | | Reach with frequency | Reach at 1-3x/week frequency band | `kind: "metric"`, `metric: "reach"` + `reach_unit` + `target_frequency: { min: 1, max: 3, window: "7d" }` | ### Multiple goals and priority A package can have multiple goals. Priority controls which the seller treats as primary. A common pattern is to use metric goals as proxy signals when event data is sparse: ```json theme={null} "optimization_goals": [ { "kind": "metric", "metric": "clicks", "target": { "kind": "cost_per", "value": 2.00 }, "priority": 2 }, { "kind": "event", "event_sources": [ { "event_source_id": "mobile_sdk", "event_type": "app_install" }, { "event_source_id": "mmp_adjust", "event_type": "app_install" } ], "target": { "kind": "cost_per", "value": 10.00 }, "priority": 1 } ] ``` The seller focuses on the `priority: 1` goal (installs at \$10 cost per, deduplicated across SDK and MMP) and uses clicks as a proxy signal until install data accumulates. ### Default behavior for event goals When `target` is omitted from an event goal, the seller maximizes conversion count within budget. This is true regardless of whether `value_field` is present on event sources — `value_field` without an explicit value-oriented target enables reporting (conversion\_value, ROAS in delivery reports) but does not change the optimization objective. | `target` | `value_field` | Seller behavior | | ---------------- | ------------- | -------------------------------------------------------------------------------- | | omitted | omitted | Maximize event count within budget | | omitted | present | Maximize event count within budget. Value available for reporting only. | | `cost_per` | either | Target cost per conversion. Value used for reporting if present. | | `per_ad_spend` | present | Target return on ad spend. | | `per_ad_spend` | **missing** | **Validation error** — seller must reject. No value dimension to compute return. | | `maximize_value` | present | Steer toward higher-value conversions. | | `maximize_value` | **missing** | **Validation error** — seller must reject. No value dimension to maximize. | ### Blending vs. sequencing goals `value_factor` and `priority` both express "event type A matters more than event type B" but mean different things to the seller's optimization: * **`value_factor`** blends multiple event sources into a **single objective function**. Set per event source entry *within* a single goal's `event_sources` array. The seller sees one goal with a composite value signal. Use this when purchases and page views should be optimized together with explicit relative weights. * **`priority`** sequences **independent goals**. Set on separate goal objects in the `optimization_goals` array. The seller optimizes for goal 1 first; goal 2 is a secondary objective, not blended in. Use this when goals are conceptually separate (e.g., hit a CPA target first, then maximize reach with remaining budget). Use `value_factor` to blend. Use `priority` to sequence. Mixing them up produces subtly wrong optimization — a blended goal that should be sequenced, or sequenced goals that should be blended — and the effect is hard to detect in delivery reports. ### Event type polarity Most event types are positive signals — a purchase, lead, or install is something the buyer wants more of. Some event types are observation signals that should not be standalone optimization targets: | Polarity | Event types | Notes | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | Positive | `purchase`, `lead`, `qualify_lead`, `close_convert_lead`, `app_install`, `complete_registration`, `subscribe`, `follow`, `content_view`, `watch_milestone`, `start_trial`, `contact`, `schedule`, `donate`, `submit_application` | Safe as standalone optimization targets when the event has enough volume and is the buyer's desired outcome | | Upper funnel | `page_view`, `view_content`, `select_content`, `select_item`, `search`, `add_to_cart`, `viewed_cart`, `add_to_wishlist`, `initiate_checkout`, `add_payment_info`, `share`, `app_launch` | Valid optimization targets but typically used as proxy signals (`priority: 2`) when lower-funnel data is sparse | | Observation | `refund`, `remove_from_cart`, `disqualify_lead` | Include in `event_sources` for attribution accuracy and ROAS adjustment, not as standalone optimization targets | `custom` events are not classified here — their polarity depends on the buyer's definition. Buyer agents should apply the same reasoning when choosing whether a custom event is safe as a standalone target. Observation events are useful inside a composite goal — `refund` with `value_factor: -1` adjusts ROAS downward, which is exactly what you want. The risk is a misconfigured buyer agent that creates a standalone goal optimizing toward `refund` or `remove_from_cart` count. This is a buyer-agent implementation concern, not a protocol constraint — the protocol intentionally does not restrict which event types can be optimization targets. ### Volume normalization with `value_factor` When combining event sources at different volume scales (e.g., `page_view` in the tens of thousands vs. `purchase` in the hundreds), the aggregate value in `sum(value_field * value_factor) / spend` will be dominated by the highest-volume type without explicit weighting. Buyers should use `value_factor` to express relative weights across sources: ```json theme={null} { "kind": "event", "event_sources": [ { "event_source_id": "web_pixel", "event_type": "page_view", "value_field": "value", "value_factor": 0.01 }, { "event_source_id": "web_pixel", "event_type": "purchase", "value_field": "value", "value_factor": 1 } ], "target": { "kind": "per_ad_spend", "value": 4.0 } } ``` Here `page_view` contributes 1% of its face value, preventing it from dominating the ROAS calculation despite being \~100x more frequent than `purchase`. Automatic normalization is intentionally out of scope — it requires event history the seller may not have, and would make the ROAS formula opaque. Buyer agents that want to normalize across event types should do so on their end before setting `value_factor`. ### Pricing model vs. optimization goal The pricing model (CPC, CPM, CPA, etc.) determines what the buyer pays. The optimization goal determines how the seller allocates impressions. These are independent — a package can use CPM pricing while optimizing toward a CPA target, or use CPA pricing while optimizing for ROAS. See [Pricing Models](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models) for details on billing. ### Reach and frequency Reach-based optimization uses `metric: "reach"` with two additional fields: * **`reach_unit`** (required): The unit of measurement — must be a value declared in the product's `metric_optimization.supported_reach_units` (e.g., `households`, `individuals`). * **`target_frequency`** (optional): Frequency band that guides optimization. The seller treats impressions toward unreached entities as higher-value and impressions toward already-saturated entities as lower-value. Includes `min`, `max`, and `window` (e.g., `"7d"`, `"campaign"`). When omitted, the seller maximizes unique reach. ```json theme={null} { "kind": "metric", "metric": "reach", "reach_unit": "households", "target_frequency": { "min": 1, "max": 3, "window": "7d" }, "priority": 1 } ``` For GRP-based buys, use [CPP pricing](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#cpp-cost-per-point). For hard frequency limits independent of optimization, use `frequency_cap` on the package. Reach and frequency metrics are available in delivery reporting via [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery). ### Prerequisites **For metric goals** (`kind: "metric"`): 1. **Check product support** — The product must declare `metric_optimization` with the desired metric in `supported_metrics`. No event source or conversion tracking setup is required. 2. **Check target support** — If setting a target, verify the target kind is listed in `metric_optimization.supported_targets`. 3. **Check view durations** — If using `completed_views` with `view_duration_seconds`, verify the value is listed in `metric_optimization.supported_view_durations`. **For event goals** (`kind: "event"`): 1. **Configure event sources** — Call [`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) to set up the event sources referenced in `event_sources`. 2. **Check product support** — The product must declare `conversion_tracking` with the desired target kind in `supported_targets`. 3. **Check dedup support** — If using multiple event sources per goal, verify the seller supports `multi_source_event_dedup` in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities). When unsupported, use a single event source per goal. 4. **Send events** — Use [`log_event`](/dist/docs/3.1.20/media-buy/task-reference/log_event) to send conversion data. The seller needs event history to optimize effectively. ### Attribution windows Attribution windows control how far back the seller looks to credit an ad impression for a conversion. Common options: | Window | Meaning | | ------------------------------------------ | ------------------------------------------ | | `post_click: {interval: 7, unit: "days"}` | Conversions within 7 days of a click | | `post_click: {interval: 28, unit: "days"}` | Conversions within 28 days of a click | | `post_view: {interval: 1, unit: "days"}` | Conversions within 1 day of viewing an ad | | `post_view: {interval: 7, unit: "days"}` | Conversions within 7 days of viewing an ad | Values must match an option in the seller's `conversion_tracking.attribution_windows` capability. When omitted, the seller applies their default window. ## Connection to delivery reporting Once event sources are configured and events are flowing, conversion metrics appear in [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) responses: * **`conversions`** — Post-click or post-view conversions attributed to the campaign * **`conversion_value`** — Monetary value of attributed conversions * **`roas`** — Return on ad spend (conversion\_value / spend) * **`cost_per_acquisition`** — Cost per conversion (spend / conversions) These metrics are reported per-package when the package has `optimization_goals` set. Sellers that support `by_action_source` breakdowns can show conversions split by source (website, app, in\_store, etc.). ## Catalog-item attribution For catalog-driven packages, conversion events carry `content_ids` that identify which catalog items were involved. The catalog's `content_id_type` declares what identifier type to expect (`sku`, `gtin`, `job_id`, etc.). Attribution is broad by design: a user might click on one item (job A) but convert on another (apply to job B). The event fires with the actual `content_id` of the conversion, not the clicked item. Per-item click-to-conversion path analysis is a platform optimization concern, not a protocol concern. The `by_catalog_item` breakdown in [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) shows per-item metrics (impressions, spend, clicks, conversions). ## Related documentation * [`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) — Configure event sources * [`log_event`](/dist/docs/3.1.20/media-buy/task-reference/log_event) — Send conversion events * [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy#campaign-with-conversion-optimization) — Set optimization goals on packages * [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) — Monitor conversion metrics * [Pricing Models](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#cpa-cost-per-acquisition) — CPA billing (pay per conversion) # Creative Lifecycle Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/creatives/index AdCP creative lifecycle — discover supported formats, sync creative assets to sellers, manage libraries, and track approval status across campaigns. Creative management is central to successful media buying campaigns. AdCP manages the complete creative lifecycle from initial format discovery through ongoing optimization, providing comprehensive tools for managing creative assets throughout their entire lifecycle. ## Overview AdCP's creative management system handles: * **Format specifications** for all supported creative types * **Asset lifecycle management** from creation to optimization * **Cross-platform synchronization** of creative libraries * **Standard format support** for consistent delivery ## Key Creative Tasks ### Creative Synchronization Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) to upload and manage creative assets in the creative library hosted by your seller or creative platform. This ensures your creatives are available for assignment across platforms and campaigns. For sellers that advertise `inline_creative_management: true` without `creative.has_creative_library: true`, creatives are supplied as package-scoped `packages[].creatives` on `create_media_buy` or `update_media_buy` instead of through a reusable library. ### Creative Library Management Use [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) to view and manage your creative asset library, including status tracking and performance metadata. ## The Three Main Phases AdCP manages creatives through three main phases: ### Phase 1: Format Discovery Before creating any creative assets, you need to understand **what formats are available and required**. AdCP provides two complementary tools that work together: #### The Discovery Workflow **`get_products`** finds advertising inventory that matches your campaign needs and returns format IDs those products support. **`list_creative_formats`** provides full format specifications with detailed creative requirements. #### Recursive Format Discovery Sales agents can optionally reference creative agents that provide additional formats. This creates a recursive discovery pattern: 1. Call `list_creative_formats` on a sales agent 2. Receive full format definitions for formats the agent directly supports 3. Optionally receive a `creative_agents` array with URLs to other creative agents 4. Recursively call `list_creative_formats` on those creative agents to discover more formats 5. **Buyers must track visited URLs to avoid infinite loops** Each format includes an `agent_url` field indicating its authoritative source. **Note**: `list_creative_formats` does not require authentication, enabling public format discovery. #### Two Common Approaches: **1. Inventory-First** - "What products match my campaign, and what formats do they need?" ```javascript theme={null} // Find products for your campaign const products = await get_products({ brand: { domain: "acmecorp.com" }, brief: "Acme Corp product launch campaign" }); // Products return: format_ids: ["video_15s_hosted", "homepage_takeover_2024"] // Get full creative specs (returns complete format objects, not just IDs) const response = await list_creative_formats({}); const formatSpecs = response.formats.filter(f => products.products.flatMap(p => p.format_ids).includes(f.format_id) ); // Now you have full specs: video_15s_hosted needs MP4 H.264, 15s, 1920x1080 // homepage_takeover_2024 needs hero image + logo + headline // Optionally discover formats from linked creative agents if (response.creative_agents) { for (const agent of response.creative_agents) { const agentFormats = await list_creative_formats({ agent_url: agent.agent_url }); formatSpecs.push(...agentFormats.formats); } } ``` **2. Creative-First** - "What video formats does this publisher support?" ```javascript theme={null} // Browse all available formats (returns full format objects immediately) const response = await list_creative_formats({ type: "video", category: "standard" }); // response.formats contains: full format objects for video_15s_hosted, video_30s_vast, etc. // Recursively discover formats from creative agents if needed const allFormats = [...response.formats]; if (response.creative_agents) { for (const agent of response.creative_agents) { const agentResponse = await list_creative_formats({ agent_url: agent.agent_url, type: "video" }); allFormats.push(...agentResponse.formats); } } // Find products supporting your creative capabilities const products = await get_products({ brand: { domain: "acmecorp.com" }, brief: "Acme Corp product launch campaign", filters: { format_ids: allFormats.map(f => f.format_id) } }); ``` #### Why Both Tools Matter * **Without `list_creative_formats`**: Format IDs from products are opaque identifiers * **Without `get_products`**: You don't know which formats actually have available inventory * **Together**: You understand both what's available AND what's required to meet specifications ### Phase 2: Creative Production Once you understand format requirements, create the actual creative assets according to the specifications discovered in Phase 1. ### Phase 3: Creative Library Management AdCP uses creative libraries hosted by creative-capable agents, where assets are uploaded once and assigned to multiple campaigns. This approach enables: * Upload creatives to account-level library * Assign creatives to specific campaigns/packages * Reuse creatives across multiple media buys * Track performance across all assignments Asset management is handled through [brand identity](/dist/docs/3.1.20/brand-protocol/brand-json), which provides brand-level assets with tags for discovery. ## Platform Considerations Different platforms have varying creative requirements: ### Google Ad Manager * Supports standard IAB formats * Requires policy compliance review * Creative approval typically within 24 hours ### Kevel * Supports custom template-based creatives * Real-time creative decisioning * Flexible format support ### Triton Digital * Audio-specific platform * Supports standard audio formats * Station-level creative targeting ## Response Times Creative operations have varying response times: * **Format listings**: \~1 second (database lookup) * **Creative sync**: Minutes to days (asset processing and approval) * **Library queries**: \~1 second (database lookup) ## Best Practices 1. **Format Planning**: Review supported formats before creative production 2. **Early Upload**: Submit creatives well before campaign launch 3. **Adaptation Acceptance**: Consider publisher suggestions for better performance 4. **Asset Organization**: Use clear naming conventions for creative IDs 5. **Performance Monitoring**: Track creative effectiveness and iterate 6. **Quality Control**: Follow format specifications exactly 7. **File Optimization**: Optimize file sizes for fast loading 8. **Testing**: Test assets across different devices and platforms ## Related Documentation * **[`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives)** - Bulk creative management with upsert semantics * **[`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives)** - Advanced creative library querying and filtering * **[`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats)** - Understanding format requirements * **[Brand identity](/dist/docs/3.1.20/brand-protocol/brand-json)** - Brand identity and asset management * **[Creative Formats](/dist/docs/3.1.20/creative/formats)** - Understanding format specifications and discovery * **[Creative Channel Guides](/dist/docs/3.1.20/creative/channels/video)** - Format examples across video, display, audio, DOOH, and carousels * **[Asset Types](/dist/docs/3.1.20/creative/asset-types)** - Understanding asset roles and specifications # Policy Compliance Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/media-buys/policy-compliance AdCP policy compliance — how publishers enforce brand safety and regulatory checks during product discovery and media buy creation. Includes policy types and violation handling. AdCP includes comprehensive policy compliance features to ensure brand safety and regulatory compliance across all advertising operations. This document explains how publishers should implement and enforce policy checks throughout the media buying lifecycle. ## Overview Policy compliance in AdCP centers around the `brand` field - a required reference to the advertiser brand. This enables publishers to: * Filter inappropriate advertisers before showing inventory * Enforce category-specific restrictions * Maintain brand safety standards * Comply with regulatory requirements ## Brand All product discovery and media buy creation requests must include a `brand` field that identifies the advertiser brand: ```json theme={null} { "brand": { "domain": "acmecorp.com" } } ``` The brand domain is used to look up the brand's published identity (via `brand.json`), which provides: * **Brand name and identity** for verification * **Industry category** for policy filtering * **Brand assets** for creative compliance Combined with the `brief` field (which describes what's being promoted), publishers have full context for policy decisions. For comprehensive guidance on briefs and brand information, see [Brief Expectations](/dist/docs/3.1.20/media-buy/product-discovery/brief-expectations). ## Policy Check Implementation Publishers must implement policy checks at two key points in the workflow: ### 1. During Product Discovery (`get_products`) When a `get_products` request is received, the publisher should: 1. Validate that the `brand` field is present and meaningful 2. Extract brand and category information 3. Check against publisher policies 4. Filter out unsuitable products **Example Policy Check Flow:** ```python theme={null} def check_brand_policy(brand: dict) -> PolicyResult: # Look up brand identity from domain domain = brand.get("domain") brand_identity = fetch_brand_json(domain) # Verify brand identity if needed if not verify_brand_domain(domain, brand_identity): return PolicyResult( status="blocked", message="Brand verification failed" ) category = brand_identity.get("category") # Check blocked categories if category in BLOCKED_CATEGORIES: return PolicyResult( status="blocked", message=f"{category} advertising is not permitted on this publisher" ) # Check restricted categories if category in RESTRICTED_CATEGORIES: return PolicyResult( status="restricted", message=f"{category} advertising requires manual approval", contact="sales@publisher.com" ) return PolicyResult(status="allowed", category=category) ``` ### 2. During Media Buy Creation (`create_media_buy`) When creating a media buy: 1. Validate the `brand` against publisher policies 2. Ensure consistency with the campaign brief 3. Flag for manual review if needed 4. Return appropriate errors for violations ## Policy Compliance Responses The protocol defines three compliance statuses: ### `allowed` The brand passes initial policy checks. Products are returned normally. ```json theme={null} { "products": [...], "policy_compliance": { "status": "allowed" } } ``` ### `restricted` The brand category requires manual approval before products can be shown. ```json theme={null} { "products": [], "policy_compliance": { "status": "restricted", "message": "Cryptocurrency advertising is restricted but may be approved on a case-by-case basis.", "contact": "sales@publisher.com" } } ``` ### `blocked` The brand category cannot be supported by this publisher. ```json theme={null} { "products": [], "policy_compliance": { "status": "blocked", "message": "Publisher policy prohibits alcohol advertising without age verification capabilities." } } ``` ## Creative Validation All uploaded creatives should be validated against the declared brand identity: 1. **Automated Analysis**: Use creative recognition to verify brand consistency 2. **Human Review**: Manual verification for sensitive categories 3. **Continuous Monitoring**: Ongoing checks during campaign delivery This ensures: * Creative content matches the declared brand * No misleading or deceptive advertising * Brand safety for all parties ## Common Policy Categories Publishers typically implement restrictions for: ### Blocked Categories * Illegal products or services * Prohibited content (varies by region) * Categories requiring special licensing ### Restricted Categories (Manual Approval) * Alcohol (may require age-gating) * Gambling/Gaming * Cryptocurrency/Financial services * Political advertising * Healthcare/Pharmaceuticals * Dating services ### Special Requirements * Political ads may require disclosure * Healthcare may need disclaimers * Financial services need compliance review ## Implementation Best Practices 1. **Clear Communication**: Provide specific reasons for restrictions 2. **Contact Information**: Include sales contact for restricted categories 3. **Consistent Enforcement**: Apply policies uniformly across all advertisers 4. **Documentation**: Maintain clear policy documentation for advertisers 5. **Appeals Process**: Allow advertisers to request policy exceptions ## Error Handling For policy violations during media buy creation: ```json theme={null} { "error": { "code": "POLICY_VIOLATION", "message": "Brand category not permitted on this publisher", "field": "brand", "suggestion": "Contact publisher for category approval process" } } ``` ## Integration with HITL Policy decisions can trigger Human-in-the-Loop workflows: 1. Restricted categories create `pending_manual` tasks 2. Human reviewers assess the campaign 3. Approval or rejection is communicated back 4. Campaign proceeds or is terminated based on decision ## Related Documentation * [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) - Product discovery with policy checks * [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) - Media buy creation with validation * [Accounts & Security](/dist/docs/3.1.20/media-buy/advanced-topics/accounts-and-security) - Authentication and authorization # Media Buy Specification Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/specification AdCP media buy specification — transport layer, task definitions, JSON schemas, authentication, and compliance requirements for agent-to-agent advertising. **AdCP 3.0 Proposal** - This specification is under development for AdCP 3.0. Feedback welcome via [GitHub Discussions](https://github.com/adcontextprotocol/adcp/discussions). **Status**: Request for Comments **Last Updated**: February 2026 The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). ## Abstract The Media Buy Protocol defines a standard interface for AI-powered advertising automation. This protocol enables AI agents to discover advertising inventory, create and manage campaigns, synchronize creative assets, and track performance through natural language interactions. ## Protocol Overview The Media Buy Protocol provides: * Natural language inventory discovery based on campaign briefs * Campaign creation with package-level budget and targeting * Creative asset management and synchronization * Performance tracking and optimization feedback * Human-in-the-loop approval workflows ## Transport Requirements Sales agents MUST support at least one of the following transports: | Transport | Protocol | Description | | --------- | ---------------------- | ----------------------------------- | | MCP | Model Context Protocol | Tool-based interaction via JSON-RPC | | A2A | Agent-to-Agent | Message-based interaction | Sales agents SHOULD support MCP as the preferred transport. Sales agents MUST declare Media Buy Protocol support via `get_adcp_capabilities`: ```json theme={null} { "$schema": "https://adcontextprotocol.org/schemas/3.1.20/protocol/get-adcp-capabilities-response.json", "status": "completed", "adcp": { "major_versions": [2], "idempotency": { "supported": true, "replay_ttl_seconds": 86400 } }, "supported_protocols": ["media_buy"], "account": { "supported_billing": ["operator", "agent"] } } ``` ## Core Concepts ### Request Roles Every media buy request involves three entities: * **Orchestrator**: The platform making the API request (e.g., DSP, trading desk) * **Account**: The billing relationship—who gets billed and what rates apply (identified by `account_id`) * **Agent**: The entity placing the buy (identified by authentication token) ### Sales Agent Types **Publisher Sales Agents** — represent a single publisher's inventory: * Sales agents MUST only return products for inventory they are authorized to sell * Sales agents MUST validate authorization via `adagents.json` when applicable **Aggregator Sales Agents** — represent multiple publishers: * Sales agents MUST clearly identify the source publisher for each product * Sales agents MUST NOT misrepresent inventory provenance ### Identifiers * **`media_buy_id`**: Unique identifier for a media buy. Sales agents MUST return this on successful creation. Orchestrators MUST use this for all subsequent operations on the media buy. A `media_buy_id` is a stable handle to any order in the seller's ad server that the authenticated account owns — not only orders originally booked via AdCP. * **`package_id`**: Unique identifier for a package within a media buy. Sales agents MUST return this for each package created. * **`idempotency_key`**: Client-generated unique key for safe retries. Sales agents that receive a duplicate key for the same account MUST return the original response rather than re-executing. ### Account Ownership vs. Creation Surface AdCP is a protocol onto the seller's ad operations, not a shadow ledger beside them. Account-scoped tasks (`get_media_buys`, `get_media_buy_delivery`, `update_media_buy`, creative sync where applicable) are scoped by **account ownership**, not by the surface through which a resource was created. A sales agent MUST NOT partition its own inventory into "AdCP-managed" and "non-AdCP" subsets for the purpose of these tasks. Concretely: * `get_media_buys` MUST return every media buy owned by the authenticated account — whether created via `create_media_buy`, via the seller's native APIs or UI, via manual trafficking, or via legacy/third-party systems — subject only to the declared `status_filter` and pagination. * Any `media_buy_id` returned by `get_media_buys` MUST be a valid argument to `get_media_buy_delivery` and to `update_media_buy` for every action listed in its `valid_actions`. * A sales agent MUST NOT mark a buy read-only, hide it, or return `MEDIA_BUY_NOT_FOUND` on the basis that it was not originally booked through AdCP. * When a specific action is unavailable for business reasons (contractual obligations, platform constraints, policy), the sales agent expresses that by omitting the action from `valid_actions`, not by refusing the buy from account-scoped listings. * **Creation surface is never a business reason.** Sales agents MUST NOT omit an action from `valid_actions` — or return `INVALID_STATE` on an otherwise-valid update — solely because the buy was created outside AdCP. `valid_actions` omissions are legitimate only when grounded in actual contractual, platform, or policy constraints that would apply equally to an AdCP-created buy in the same state. A seller that returns non-AdCP buys with a systematically empty `valid_actions` is non-conformant, because that behavior is indistinguishable from hiding the buy and defeats the normative intent of the rules above. #### Partitioning belongs at the account boundary When a seller has a legitimate reason to keep a set of buys outside a caller's operational reach — child-seller models, NDA-scoped PMP deals, sandbox-vs-production separation, tenant-level privacy partitions — the correct mechanism is **account partitioning**, not within-account filtering: * Expose the hidden subset as a separate account (or sub-account) the caller is not authorized to reference. * Within any account the caller *is* authorized on, return the full set of buys that account owns per the rules above. The account boundary is the AdCP primitive for access partitioning; `get_agent_capabilities` is the introspection surface for the scope granted on a given account. Callers can see what they can see; what they cannot see lives behind an account reference they do not hold. Within-account filtering — returning only a subset of an account's buys to a caller who is authorized on that account — reintroduces the shadow-ledger problem this rule forbids. ### Asynchronous Operations The Media Buy Protocol is asynchronous by design. Operations MAY return immediately or MAY require extended processing: * **Synchronous responses**: Sales agents MAY return completed results immediately * **Asynchronous responses**: Sales agents MAY return `status: "submitted"` or `status: "working"` with a task reference * **Human-in-the-loop**: Sales agents MAY require internal human review (e.g. IO signing) by keeping the task in `status: "submitted"` until the reviewer acts. Sales agents MAY also use `status: "input-required"` when the buyer must respond (e.g. confirm a budget). Human approval is modelled at the task layer — there is no `pending_approval` MediaBuy status (that value exists only on Account.status for account onboarding review) * **Rejection**: Sales agents MAY reject a media buy in `pending_creatives` or `pending_start` status when platform setup reveals the order cannot be fulfilled (e.g., inventory sold out, policy issue discovered during ad server setup). Orchestrators MUST treat `rejected` as a terminal state. If the seller does not want to accept the order at create time, it SHOULD fail `create_media_buy` with an error rather than creating a media buy in `rejected` status. Orchestrators MUST handle all response types and MUST NOT assume synchronous completion. ### Media Buy State Transitions Media buys progress through a defined set of states. Terminal states (`completed`, `rejected`, `canceled`) allow no further transitions. ``` create_media_buy ──┬──▶ pending_creatives ──▶ pending_start ──▶ active ├──▶ active ──(pause)──▶ paused └──▶ paused (when created with paused: true) paused ──(resume)──▶ active active ─────────────▶ completed (terminal) paused ─────────────▶ completed (terminal) pending_creatives ──▶ rejected (terminal) — seller rejects during setup pending_start ──────▶ rejected (terminal) — seller rejects during setup Any non-terminal ──── update(canceled: true) ──▶ canceled (terminal) ``` **Rules:** * Sales agents MAY return `active`, `paused`, `pending_creatives`, or `pending_start` from `create_media_buy` (seller's choice based on platform setup time and the buyer's create-time `paused` request) * Sales agents MUST transition media buys from `pending_start` to `active` when the flight date arrives. Sales agents SHOULD notify orchestrators via webhook when this transition occurs. * A successful committed `create_media_buy` response constitutes **order confirmation**. Sales agents MUST include `confirmed_at` in create/get responses; the value is the seller commitment timestamp, or `null` for a provisional buy that exists but is still waiting for seller commitment. * Sales agents MUST include `revision` in create, get, and update responses. The revision number MUST increment on every mutating state change or update. * `active` ↔ `paused` transitions use `update_media_buy` with `paused: true` or `paused: false` * `create_media_buy` with top-level `paused: true` creates a held media buy when it would otherwise be `active`. Setup blockers still take precedence: no creatives yields `pending_creatives`, and a future flight yields `pending_start` until the start condition is satisfied. The hold is latent while those blockers are visible; once creatives are present and the flight can start, the buy enters `paused` instead of `active`. * `update_media_buy` with `paused: false` resumes a buy that is currently `paused` or clears a latent create-time hold while the visible state is still `pending_creatives` or `pending_start`. Clearing a latent hold does not bypass setup blockers; the visible status remains pending until creatives are supplied and the flight can start. * `active` or `paused` → `completed` when the flight ends, goal is met, or budget is exhausted (seller-initiated) * Buyer-initiated cancellation uses `update_media_buy` with `canceled: true` and optional `cancellation_reason` * Seller-initiated cancellation (e.g., policy violation, inventory withdrawal) transitions the media buy to `canceled` with `cancellation.canceled_by: "seller"`. Sellers MUST notify orchestrators via webhook when performing seller-initiated cancellation. * Seller-initiated rejection (from `pending_creatives` or `pending_start`) MUST also notify orchestrators via `push_notification_config`. The webhook payload MUST include `media_buy_id`, `status: "rejected"`, and `rejection_reason`. * Sales agents MUST include a `cancellation` object with `canceled_at` and `canceled_by` when transitioning a media buy or package to `canceled` * Sales agents MAY reject buyer cancellation of a non-terminal media buy with error code `NOT_CANCELLABLE` (e.g., when the seller contractually refuses mid-flight cancellation) * When a buyer attempts to cancel a media buy already in `canceled` (`canceled: true` on a `canceled` buy), sales agents MUST reject with `NOT_CANCELLABLE` * All other updates to media buys in terminal states (`completed`, `rejected`, `canceled`) — including `canceled: true` attempts against `completed` or `rejected` buys — MUST be rejected with `INVALID_STATE` * Rejection (`rejected` status) is only valid from `pending_creatives` or `pending_start`. Sales agents MUST NOT reject media buys that have already transitioned to `active`. * Seller-initiated cancellation notifications MUST use the `push_notification_config` webhook provided by the orchestrator during `create_media_buy` or `update_media_buy`. The webhook payload MUST include `media_buy_id`, `status: "canceled"`, and a `cancellation` object with `canceled_at`, `canceled_by: "seller"`, and `reason`. * The `canceled` field on update requests uses `"const": true` — only `true` is valid. Sending `canceled: false` fails schema validation. Cancellation is irreversible; there is no "uncancel" operation. * **Creative assignments are released on buy rejection or cancellation.** When a media buy transitions to `rejected` or `canceled`, all package-creative assignments on that media buy are released. For sellers that advertise `creative.has_creative_library: true`, the creatives persist in the creative library per [assignment state and creative state](/dist/docs/3.1.20/creative/creative-libraries#creative-state-and-assignment-state-are-separate) and MAY be referenced by `creative_id` in a subsequent `create_media_buy` or `sync_creatives` call. Inline-only sellers that advertise `inline_creative_management` without a creative library MAY keep submitted creatives package-scoped; they do not advertise cross-buy reuse or `list_creatives` readback. * **Creative review is independent of buy outcome.** Sales agents MUST NOT implicitly reject a creative because its containing buy was rejected; a creative rejection MUST be a deliberate review decision with its own `rejection_reason`. If the buy was rejected because a creative violated content policy, the sales agent MAY reject that creative — but only via the normal review path with its own `rejection_reason`; the buy's `rejected` status is not itself sufficient. * **Observability of released assignments.** The media-buy-level `canceled` or `rejected` transition (surfaced in the buy's `history` and in the webhook notifications required above) IS the audit record for all its released assignments; buyers MUST NOT rely on per-assignment diff observability. Released assignments no longer appear in `get_media_buys` responses for that buy. Buyers confirming library reusability SHOULD call `list_creatives` to verify the creative is still in the library and observe its current status. Inline-only buyers should retain their submitted creative bodies because `get_media_buys` surfaces package-level approval state, not full creative-body retrieval. Package-scoped deadlines (`creative_deadline`) on the prior package have no bearing on a library creative's eligibility for a new buy. * **Retention.** Sales agents with creative libraries SHOULD retain released creatives in the library for at least 90 days after the last assignment is released. A normative retention floor is specified in the creative retention contract tracked under [#2260](https://github.com/adcontextprotocol/adcp/issues/2260); until that contract lands, buyers relying on long-horizon reuse SHOULD verify persistence via `list_creatives` before referencing a released creative on a new buy. #### Revision and confirmation semantics `revision` is the optimistic concurrency token for a media buy. The wire field on `update_media_buy` is named `revision`; implementations may call it `expected_revision` internally, but the semantics are the same: the caller is saying "apply this update only if the current stored media-buy revision still equals this value." Sellers MUST enforce the revision check atomically at the persistence boundary for every mutating update. A read-compare-write sequence in application memory can race with another writer and allow lost updates. The update and compare belong in one database transaction, conditional update, or equivalent atomic primitive. On mismatch, return `CONFLICT` and leave the media buy unchanged. Mutating updates increment `revision` and return the new value. This includes budget, flight date, targeting, status, package, creative-assignment, reporting-webhook, invoice-recipient, and committed-metric changes. Validation-only requests, exact idempotency replay of an already-applied operation, and reads via `get_media_buys` do not bump revision. Buyers SHOULD pass the latest observed `revision` on every `update_media_buy` call intended to change state. The request field remains optional for backward compatibility. When `revision` is present, sellers MUST compare it atomically with the write and reject stale values with `CONFLICT`. On `CONFLICT`, reload the media buy with `get_media_buys`, reconcile against the current state, then retry with the new `revision` and a fresh `idempotency_key`. `confirmed_at` is the seller's commitment timestamp, not a delivery-status timestamp. Once populated, it remains stable through later pause, resume, cancellation, completion, and reporting transitions. Use the `submitted` response branch when no `media_buy_id` is being returned to the buyer. Sellers MAY instead return synchronous success with `media_buy_id`, `packages`, and `confirmed_at: null` for a provisional buy; such buys MUST be retrievable via `get_media_buys` and MUST transition by setting `confirmed_at` exactly once on commitment. A provisional buy with `confirmed_at: null` MUST NOT be `active` and MUST NOT include `packages[].committed_metrics`. **Package-level lifecycle:** Packages follow the same pause/cancel pattern as media buys. Additionally: * Packages MAY have a `creative_deadline` — after this deadline, creative changes to the package are rejected with `CREATIVE_DEADLINE_EXCEEDED`. When absent, the media buy's `creative_deadline` applies. `CREATIVE_REJECTED` is reserved for content policy failures. * Package cancellation (`canceled: true` in a package update) is irreversible and independent of the media buy's status — a single package can be canceled while the media buy remains active. Creative assignments on the canceled package are released per the media-buy-level rule above; creatives assigned to other active packages on the same media buy are unaffected. * Sales agents MUST retain delivery data for canceled packages. When `include_snapshot` is true, sales agents SHOULD return a final snapshot reflecting delivery state at the time of cancellation. * When all packages in a media buy are canceled, the media buy itself remains in its current status (`active` or `paused`). Sellers that support mid-flight package additions advertise `add_packages` in `valid_actions` — the buyer can add new packages via `new_packages` in `update_media_buy`. Otherwise, the buyer SHOULD explicitly cancel the media buy. Sales agents SHOULD notify orchestrators (via `context.notes` in the update response) when the last active package is canceled. Sales agents MAY auto-transition the media buy to `canceled` after a seller-defined grace period if no new activity occurs. ### Creative Approval on Packages **Schema**: [`enums/creative-approval-status.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/creative-approval-status.json) Each package tracks per-creative approval status separately from the creative's library-level status. A creative may be `approved` in the library but `rejected` on a specific package (e.g., wrong format for the placement). | Status | Description | | ---------------- | ---------------------------------------------------------- | | `pending_review` | Creative submitted and awaiting platform review | | `approved` | Creative approved for delivery on this package | | `rejected` | Creative rejected for this package; see `rejection_reason` | Rejection is not terminal — the buyer fixes the creative and resubmits through the seller's advertised creative path, which resets the approval to `pending_review`. The resubmission path: 1. Check `rejection_reason` on `get_media_buys` response 2. Fix the creative (update assets, adjust manifest) 3. Resubmit via `sync_creatives` for library-backed sellers, or via `packages[].creatives` on `update_media_buy` for inline-only sellers 4. Approval resets to `pending_review` **Interaction with `creative_deadline`:** If a creative is rejected after the package's `creative_deadline`, the buyer MAY still resubmit — sellers SHOULD accept resubmissions for rejected creatives even past the deadline, since the buyer is correcting a seller-identified issue. Sellers that cannot accept late resubmissions MUST return `CREATIVE_DEADLINE_EXCEEDED`. ### Verification Tag Enforcement When a confirmed package's `performance_standards` includes any entry with a `vendor`, creatives assigned to that package MUST include at least one URL asset with `url_type: "tracker_script"` or `url_type: "tracker_pixel"` corresponding to each specified vendor. Sales agents SHOULD reject creative assignments that lack required verification tags with `CREATIVE_REJECTED` and a `details` message identifying the missing vendor tag. Buyer agents SHOULD proactively include vendor tags based on the agreed `measurement_terms` before submitting creatives. ## Tasks The Media Buy Protocol defines the following tasks. See task reference pages for complete request/response schemas and examples. ### get\_products **Schema**: [`media-buy/get-products-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-request.json) / [`media-buy/get-products-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-response.json) **Reference**: [`get_products` task](/dist/docs/3.1.20/media-buy/task-reference/get_products) Discover advertising inventory using natural language briefs or explicit wholesale intent. **Requirements:** * Orchestrators MUST set `buying_mode` to `"brief"`, `"wholesale"`, or `"refine"` * Orchestrators MUST include `brief` when `buying_mode` is `"brief"` * Orchestrators MUST NOT include `brief` when `buying_mode` is `"wholesale"` or `"refine"` * Orchestrators MUST include `refine` when `buying_mode` is `"refine"` * Orchestrators MUST NOT include `refine` when `buying_mode` is `"brief"` or `"wholesale"` * Orchestrators MUST provide `scope` and `product_id` for each product entry in `refine`, and `scope` and `proposal_id` for each proposal entry * Orchestrators MAY omit `action` on product and proposal entries (defaults to `"include"`) * Orchestrators MUST NOT include multiple entries for the same product ID or proposal ID in a single `refine` array * Sales agents MUST return products matching the brief criteria when a brief is provided * Sales agents MUST include `product_id` and `pricing_options` for each product * Sales agents SHOULD include relevance scoring when multiple products match **Refinement requirements:** Each `get_products` request with `buying_mode: "refine"` is self-contained — sales agents MUST NOT depend on transport-level session state. The `refine` array and `filters` on each request fully specify the refinement intent. Sellers maintain their own product and proposal registries; "stateless" means the protocol exchange carries no implicit state between calls. This enables stateless implementations and safe retries. * Sales agents MUST treat a missing `action` on product and proposal refine entries as `action: "include"` * Sales agents MUST omit products with `action: "omit"` from the response * Sales agents MUST omit proposals with `action: "omit"` from the response * Sales agents MUST return products with `action: "include"`, with updated pricing * Sales agents SHOULD fulfill the `ask` on product entries with `action: "include"` * Sales agents SHOULD return additional products similar to those with `action: "more_like_this"`, plus the original product * Sales agents SHOULD consider request-level asks (`scope: "request"`) when composing the response — this MAY result in additional products beyond those explicitly referenced. Per-product actions take precedence over request-level direction. * Sales agents SHOULD fulfill the `ask` on proposal entries with `action: "include"` * Sales agents SHOULD include `refinement_applied` in the response, with one entry per change request matched by position * Sales agents that return `refinement_applied` MUST echo `scope` on each entry and MUST echo `product_id` / `proposal_id` for product and proposal scopes, so orchestrators can cross-validate alignment * Sales agents MAY return proposals alongside products in refine mode, even when the orchestrator did not include proposal entries ### list\_creative\_formats **Schema**: [`media-buy/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/list-creative-formats-request.json) / [`media-buy/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/list-creative-formats-response.json) **Reference**: [`list_creative_formats` task](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) Discover creative format requirements and specifications. **Requirements:** * Sales agents MUST return all supported creative formats * Sales agents MUST include technical specifications for each format * Sales agents SHOULD reference standard format IDs from the Creative Protocol when applicable ### create\_media\_buy **Schema**: [`media-buy/create-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-request.json) / [`media-buy/create-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-response.json) **Reference**: [`create_media_buy` task](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) Create a media buy from selected packages or execute a proposal. **Requirements:** * Orchestrators MUST include either `packages` array or `proposal_id` * Orchestrators MUST include `start_time` and `end_time` for the campaign * Sales agents MUST return `media_buy_id` on successful creation * Sales agents MUST return `confirmed_at` on successful creation. The value is the seller commitment timestamp, or `null` when the response creates a provisional buy that is not yet committed. * Sales agents MUST return `revision` on successful creation * Sales agents MUST return `creative_deadline` indicating when creatives must be uploaded * Sales agents MUST validate budget against pricing options * On validation failure, sales agents MUST return an `errors` array ### update\_media\_buy **Schema**: [`media-buy/update-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/update-media-buy-request.json) / [`media-buy/update-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/update-media-buy-response.json) **Reference**: [`update_media_buy` task](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) Modify an existing media buy's budget, targeting, or settings. **Package operations** are structurally explicit — the operation type is determined by where the package appears in the request: | Operation | Request location | Description | | ------------ | ---------------------------------- | ---------------------------------------------------------------- | | **New** | `new_packages[]` | Add a package to the media buy | | **Changed** | `packages[]` | Modify an existing package (budget, targeting, dates, creatives) | | **Canceled** | `packages[]` with `canceled: true` | Cancel an existing package (irreversible) | **Requirements:** * Orchestrators MUST include `media_buy_id` * Sales agents MUST accept any `media_buy_id` returned by `get_media_buys` for the authenticated account and MUST NOT refuse updates on the basis that the buy was originally created outside AdCP. Business constraints on specific operations are expressed by omitting them from `valid_actions`. See [Account Ownership vs. Creation Surface](#account-ownership-vs-creation-surface). * Sales agents MUST apply PATCH semantics: only specified fields are updated; omitted fields remain unchanged * When a buyer attempts to cancel a media buy already in `canceled` (`canceled: true` on a `canceled` buy), sales agents MUST reject with `NOT_CANCELLABLE` * All other updates to media buys in terminal states (`completed`, `rejected`, `canceled`) — including `canceled: true` attempts against `completed` or `rejected` buys — MUST be rejected with `INVALID_STATE` * Orchestrators MAY cancel a media buy by setting `canceled: true` with optional `cancellation_reason` * Sales agents MUST transition the media buy to `canceled` status upon accepting cancellation * Sales agents MAY reject cancellation of a non-terminal media buy with error code `NOT_CANCELLABLE` * Orchestrators MAY cancel individual packages by setting `canceled: true` on a package update * Sales agents MUST reject creative changes to packages past their `creative_deadline` with error code `CREATIVE_DEADLINE_EXCEEDED` * Orchestrators MAY add new packages to an existing media buy via `new_packages`. Sales agents that support this MUST advertise `add_packages` in `valid_actions`. Sales agents that do not support adding packages MUST reject with `UNSUPPORTED_FEATURE`. * When `canceled: true` is present alongside other fields in an update request, sales agents MUST apply cancellation and MUST ignore all other fields except `cancellation_reason`. Cancellation takes precedence over concurrent mutations. Sales agents SHOULD include a warning in the response `context` when non-cancellation fields were present and ignored. * Sales agents MUST return `affected_packages` containing the state of each directly modified package after the update is applied (or the proposed state if pending approval); an empty array is valid when only campaign-level fields (e.g., `paused`, `start_time`) are updated * When manual approval is required, sales agents MUST persist the pending update request, MUST return `implementation_date: null`, and MUST NOT return empty `affected_packages` * Sales agents SHOULD return the updated media buy state ### sync\_catalogs **Schema**: [`media-buy/sync-catalogs-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-catalogs-request.json) / [`media-buy/sync-catalogs-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-catalogs-response.json) **Reference**: [`sync_catalogs` task](/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs) Synchronize catalogs (products, inventory, stores, and vertical feeds) on a seller account. **Requirements:** * Orchestrators MUST include `account_id` * When `catalogs` is provided, it MUST contain at least one catalog * When `catalogs` is omitted, the call is discovery-only and returns existing catalogs without modification * Sales agents MUST return per-catalog outcomes, including what action was taken and any item-level issues * Sales agents SHOULD support `dry_run` for validation without applying changes ### list\_creatives `list_creatives` is defined in the [Creative Protocol](/dist/docs/3.1.20/creative/specification#list_creatives). Sales agents that host creative libraries MAY implement `list_creatives` as part of the Creative Protocol. See the [`list_creatives` task reference](/dist/docs/3.1.20/creative/task-reference/list_creatives). ### sync\_creatives `sync_creatives` is defined in the [Creative Protocol](/dist/docs/3.1.20/creative/specification#sync_creatives). Any agent that advertises `creative.has_creative_library: true` implements `sync_creatives` as part of the Creative Protocol. See the [`sync_creatives` task reference](/dist/docs/3.1.20/creative/task-reference/sync_creatives). ### get\_media\_buys **Schema**: [`media-buy/get-media-buys-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buys-request.json) / [`media-buy/get-media-buys-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buys-response.json) **Reference**: [`get_media_buys` task](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) Retrieve operational media buy state, including package status, creative approvals, missing formats, and optional delivery snapshots. **Requirements:** * Orchestrators MAY filter by `account_id`, `media_buy_ids`, and `status_filter` * Orchestrators SHOULD use cursor pagination (`pagination.max_results` / `pagination.cursor`) for broad scope queries * Sales agents MUST return every media buy owned by the authenticated account that matches the declared filter set, regardless of how the buy was created (AdCP, seller's native APIs/UI, manual trafficking, legacy systems). See [Account Ownership vs. Creation Surface](#account-ownership-vs-creation-surface). * Sales agents MUST return current media buy status and package-level operational state for each matched media buy * Sales agents SHOULD include `status_as_of` when the returned media-buy-level `status` is served from a cache or computed as a rolled-up status from upstream legs. For rolled-up statuses, `status_as_of` MUST NOT be later than the oldest upstream status observation that could affect the returned roll-up, so it never overstates freshness. Omit it or return `null` to make no freshness assertion; buyers MUST NOT infer that an omitted or null value means the status is live. Buyers use `status_as_of` to interpret list-status freshness; `updated_at` remains the media buy's last modification time. * Sales agents SHOULD include `valid_actions` for each media buy, listing the actions the buyer can perform in the current state. This eliminates the need for agents to internalize the state machine. Expected mapping: | Status | Expected `valid_actions` | | ------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `pending_creatives` | `pause`, `cancel`, `sync_creatives` | | `pending_start` | `pause`, `cancel`, `sync_creatives` | | `active` | `pause`, `cancel`, `update_budget`, `update_dates`, `update_packages`, `add_packages`, `sync_creatives` | | `paused` | `resume`, `cancel`, `update_budget`, `update_dates`, `update_packages`, `add_packages`, `sync_creatives` | | `completed` / `rejected` / `canceled` | *(empty array)* | Sellers MAY omit actions based on business rules (e.g., omit `cancel` when contractual obligations prevent it, omit `add_packages` when the platform does not support mid-flight additions). When `pending_creatives` or `pending_start` is masking a create-time hold from `paused: true`, sellers SHOULD also include `resume` so buyers can clear the hold before the setup blocker clears. `valid_actions` contains only mutation operations — read-only operations (`get_media_buys`, `get_media_buy_delivery`) are always permitted regardless of state. Agents SHOULD use `valid_actions` as an optimization hint but MUST handle `INVALID_STATE` errors gracefully, since `valid_actions` may not reflect real-time state changes from concurrent operations. Absence of an action means "not declared by this seller," not necessarily "forbidden." For creative changes, `sync_creatives` in `valid_actions` is a legacy creative-change action label, not proof that the `sync_creatives` task exists. Buyers use the creative path the seller advertises: `sync_creatives` and `creative_assignments` for sellers with `creative.has_creative_library: true`, or `packages[].creatives` on `update_media_buy` for inline-only sellers that advertise `media_buy.features.inline_creative_management: true`. * Orchestrators MAY request revision history by setting `include_history` to the number of most-recent entries desired. Sales agents SHOULD return a `history` array per media buy when `include_history > 0`, containing revision number, timestamp, server-derived actor identity, action type, and optional summary. History entries MUST be ordered most-recent-first. * Sales agents MUST include a media-buy currency and MUST denominate monetary fields consistently (`snapshot.currency` -> `package.currency` -> `media_buy.currency`) * Sales agents SHOULD include creative approval outcomes and pending format requirements when available * If `include_snapshot` is true and snapshot data is omitted for a package, sales agents MUST return `snapshot_unavailable_reason` * When `include_snapshot` is true and snapshots are returned, each snapshot MUST include `as_of` and `staleness_seconds` * Default `status_filter: ["active"]` applies only when `media_buy_ids` is omitted ### get\_media\_buy\_delivery **Schema**: [`media-buy/get-media-buy-delivery-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buy-delivery-request.json) / [`media-buy/get-media-buy-delivery-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buy-delivery-response.json) **Reference**: [`get_media_buy_delivery` task](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) Track performance metrics and campaign delivery. **Requirements:** * Orchestrators MUST include `media_buy_id` * Sales agents MUST accept any `media_buy_id` returned by `get_media_buys` for the authenticated account, regardless of creation surface. See [Account Ownership vs. Creation Surface](#account-ownership-vs-creation-surface). * Sales agents MUST return delivery metrics at the package level * Sales agents SHOULD include dimensional breakdowns when requested * Sales agents MUST include `as_of` timestamp indicating data freshness ### provide\_performance\_feedback **Schema**: [`media-buy/provide-performance-feedback-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-request.json) / [`media-buy/provide-performance-feedback-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-response.json) **Reference**: [`provide_performance_feedback` task](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) Submit performance signals to enable publisher optimization. **Requirements:** * Orchestrators MUST include `media_buy_id` and performance metrics * Sales agents MUST acknowledge receipt of feedback * Sales agents SHOULD use feedback to optimize delivery within campaign constraints ### sync\_event\_sources **Schema**: [`media-buy/sync-event-sources-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-event-sources-request.json) / [`media-buy/sync-event-sources-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-event-sources-response.json) **Reference**: [`sync_event_sources` task](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) Configure event sources on a seller account for conversion tracking with upsert semantics. **Requirements:** * Orchestrators MUST include `account_id` * When `event_sources` is provided, it MUST contain at least one event source * When `event_sources` is omitted, the call is discovery-only and returns all event sources on the account without modification * Sales agents MUST return per-source results with `action` indicating what happened * Sales agents MUST include seller-managed event sources in the response when present * Sales agents SHOULD return `setup` instructions for newly created event sources * Sales agents MAY include `seller_id` for cross-referencing in the seller's platform ### log\_event **Schema**: [`media-buy/log-event-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/log-event-request.json) / [`media-buy/log-event-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/log-event-response.json) **Reference**: [`log_event` task](/dist/docs/3.1.20/media-buy/task-reference/log_event) Send conversion or marketing events for attribution and optimization. **Requirements:** * Orchestrators MUST include `event_source_id` referencing a configured event source * Orchestrators MUST include at least one event with `event_id`, `event_type`, and `event_time` * Sales agents MUST return `events_received` and `events_processed` counts * Sales agents MUST deduplicate events by `event_id` + `event_type` + `event_source_id` * Sales agents SHOULD report `partial_failures` for individually failed events * Sales agents SHOULD return `match_quality` score when user matching is attempted ### sync\_audiences **Schema**: [`media-buy/sync-audiences-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-audiences-request.json) / [`media-buy/sync-audiences-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-audiences-response.json) **Reference**: [`sync_audiences` task](/dist/docs/3.1.20/media-buy/task-reference/sync_audiences) Manage first-party CRM audiences on a seller account. Upload hashed customer lists, check matching status, and reference the resulting audiences in targeting overlays. **Requirements:** * Orchestrators MUST include `account_id` * Orchestrators MUST include at least one audience with hashed member data * Sales agents MUST return per-audience outcomes with matching status * Sales agents SHOULD support `push_notification_config` for async matching completion * Sales agents MUST accept SHA-256 hashed identifiers and MUST reject cleartext email/phone in `hashed_email`/`hashed_phone` fields (PII minimization at the transport boundary; see [Privacy Considerations](/dist/docs/3.1.20/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous) for the retention/consent obligations that hashing does not satisfy) ## Error Handling Sales agents MUST return errors using the [standard AdCP error schema](/dist/docs/3.1.20/building/by-layer/L3/error-handling). Common error codes: * `MEDIA_BUY_NOT_FOUND`: Referenced media buy does not exist * `PACKAGE_NOT_FOUND`: Referenced package does not exist * `PRODUCT_NOT_FOUND`: Referenced product does not exist * `BUDGET_EXCEEDED`: Operation would exceed allocated budget * `CREATIVE_REJECTED`: Creative failed content policy review * `CREATIVE_DEADLINE_EXCEEDED`: Creative change submitted after the package's `creative_deadline` * `INVALID_STATE`: Operation is not permitted for the resource's current status (e.g., updating a completed or canceled media buy) * `NOT_CANCELLABLE`: Media buy or package cannot be canceled in its current state * `GOVERNANCE_DENIED`: A registered governance agent denied the transaction. Buyer may restructure the buy, escalate to human spending authority, or contact the governance agent. * `TERMS_REJECTED`: Buyer-proposed `measurement_terms` were rejected by the seller. Error details SHOULD identify which term failed and the seller's acceptable range or supported vendors. Recovery: adjust the proposed terms and retry, or omit `measurement_terms` to accept the product's defaults. * `REQUOTE_REQUIRED`: An `update_media_buy` request changes the parameter envelope (budget, flight dates, volume, targeting) the original quote was priced against. The `pricing_option` remains locked; the seller is declining the requested shape at that price. Distinct from `TERMS_REJECTED` (measurement) and `POLICY_VIOLATION` (content). Recovery in 3.1 is to adjust the update to fit the current quote, rediscover products/terms, add packages when `add_packages` is available, or create a separate media buy. AdCP 3.1 does not define an amendment-quote artifact that can be attached to `update_media_buy`. Sellers SHOULD populate `error.details.envelope_field` with the field path(s) that breached the envelope (e.g., `packages[0].budget`, `end_time`) so the buyer's agent can autonomously re-discover. * `VALIDATION_ERROR`: Request format or parameter errors * `AUTH_MISSING`: No credentials were presented. Recovery: correctable. * `AUTH_INVALID`: Credentials were presented but rejected (expired / revoked / malformed). Recovery: terminal. ## Security Considerations ### Transport Security All Media Buy Protocol communications MUST use HTTPS with TLS 1.2 or higher. ### Authentication * Orchestrators MUST authenticate with sales agents using valid credentials * Sales agents MUST validate credentials before processing requests * Sales agents MUST use account context to determine inventory access ### Budget Authorization * Sales agents MUST validate that accounts are authorized for requested budget levels * Sales agents MUST NOT exceed authorized budget limits without explicit approval ### Creative Security * Sales agents MUST validate creative content for policy compliance * Sales agents SHOULD scan creatives for malware and malicious content * Sales agents MUST NOT serve creatives that fail security validation ## Conformance ### Sales Agent Conformance A conformant Media Buy Protocol sales agent MUST: 1. Support at least one specified transport (MCP or A2A) 2. Implement all tasks per their schemas 3. Return required fields as defined in response schemas 4. Use specified error codes 5. Handle asynchronous operations appropriately 6. Enforce authentication and authorization See [Required tasks by protocol](/dist/docs/3.1.20/protocol/required-tasks) for a consolidated view of required and optional tasks across all AdCP protocols. ### Orchestrator Conformance A conformant Media Buy Protocol orchestrator MUST: 1. Authenticate with sales agents 2. Include required fields as defined in request schemas 3. Handle asynchronous task-level responses (`submitted`, `working`, `input-required`) including webhook delivery of completion artifacts 4. Use `media_buy_id` to reference media buys in subsequent operations 5. Respect `creative_deadline` for creative uploads ## Implementation Notes ### Response Time Expectations Sales agents SHOULD target the following response times: | Operation Type | Target Latency | | --------------------------------------------- | ---------------- | | Simple lookups (list\_creative\_formats) | \< 1 second | | Discovery with AI/LLM (get\_products) | \< 60 seconds | | Reporting queries (get\_media\_buy\_delivery) | \< 60 seconds | | Campaign operations (create, update, sync) | Async acceptable | ### Idempotency Sales agents SHOULD support idempotent operations using `idempotency_key`: * If an `idempotency_key` has been seen before for the same account, sales agents SHOULD return the existing resource * This enables safe retries without duplicate creation For mutation tasks (`update_media_buy`, `sync_creatives`), orchestrators MAY include an `idempotency_key` (16-255 characters) for safe retries. If a request fails without a response, resending with the same `idempotency_key` guarantees at-most-once execution. ### Human-in-the-Loop Sales agents MAY require human approval for any operation. Approval is modelled at the **task layer**: * When the seller is waiting on an **internal** human (e.g. IO signing, traffic-manager review), sales agents MUST keep the task in `status: "submitted"` until the reviewer acts. On completion, the task transitions to `completed` with the final artifact carrying `media_buy_id` and the full success payload. * When the seller needs the **buyer** to respond (e.g. confirm a budget that exceeds a pre-approved limit), sales agents MUST return `status: "input-required"` with a message describing what is needed. The buyer responds within the same A2A context. * Sales agents SHOULD provide an estimated approval timeline in the task message. * Orchestrators SHOULD implement webhook handlers (via `push_notification_config`) for completion notifications rather than polling. `pending_approval` is not a valid MediaBuy or Task status — that value exists only on `Account.status` (for account onboarding review). Do not repurpose it for media-buy or task-level approval. ## Schema Reference | Schema | Description | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | | [`media-buy/get-products-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-request.json) | get\_products request | | [`media-buy/get-products-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-response.json) | get\_products response | | [`media-buy/create-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-request.json) | create\_media\_buy request | | [`media-buy/create-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-response.json) | create\_media\_buy response | | [`media-buy/update-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/update-media-buy-request.json) | update\_media\_buy request | | [`media-buy/update-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/update-media-buy-response.json) | update\_media\_buy response | | [`media-buy/list-creative-formats-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/list-creative-formats-request.json) | list\_creative\_formats request | | [`media-buy/list-creative-formats-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/list-creative-formats-response.json) | list\_creative\_formats response | | [`creative/list-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-request.json) | list\_creatives request (Creative Protocol) | | [`creative/list-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/list-creatives-response.json) | list\_creatives response (Creative Protocol) | | [`creative/sync-creatives-request.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-request.json) | sync\_creatives request (Creative Protocol) | | [`creative/sync-creatives-response.json`](https://adcontextprotocol.org/schemas/3.1.20/creative/sync-creatives-response.json) | sync\_creatives response (Creative Protocol) | | [`media-buy/get-media-buy-delivery-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buy-delivery-request.json) | get\_media\_buy\_delivery request | | [`media-buy/get-media-buy-delivery-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buy-delivery-response.json) | get\_media\_buy\_delivery response | | [`media-buy/provide-performance-feedback-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-request.json) | provide\_performance\_feedback request | | [`media-buy/provide-performance-feedback-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-response.json) | provide\_performance\_feedback response | # create_media_buy Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/create_media_buy create_media_buy task — create advertising campaigns in AdCP from discovered products. Handles packages, budgets, flight dates, governance rules, and approval workflows. Create a media buy from selected packages or execute a proposal. Handles validation, approval if needed, and campaign creation. Supports two modes: * **Manual Mode**: Provide `packages` array with explicit line item configurations * **Proposal Mode**: Provide `proposal_id` and `total_budget` to execute a proposal from `get_products` **Response Time**: Instant to days (returns `completed`, `working` \< 120s, or `submitted` for hours/days) **Request Schema**: [`/schemas/3.1.20/media-buy/create-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/create-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/create-media-buy-response.json) ## Quick Start Create a simple media buy with two packages: ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { CreateMediaBuyResponseSchema } from '@adcp/sdk'; // Calculate dates dynamically - start tomorrow, end in 90 days const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(0, 0, 0, 0); const endDate = new Date(tomorrow); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ brand: { domain: 'acmecorp.com' }, packages: [ { product_id: 'prod_d979b543', pricing_option_id: 'cpm_usd_auction', format_ids: [ { agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250_image' } ], budget: 2500, bid_price: 5.00 }, { product_id: 'prod_e8fd6012', pricing_option_id: 'cpm_usd_auction', format_ids: [ { agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250_html' } ], budget: 2500, bid_price: 4.50 } ], start_time: tomorrow.toISOString(), end_time: endDate.toISOString() }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } // Validate response against schema const validated = CreateMediaBuyResponseSchema.parse(result.data); // Check for errors (discriminated union response) if ('errors' in validated && validated.errors) { throw new Error(`Failed to create media buy: ${JSON.stringify(validated.errors)}`); } if ('media_buy_id' in validated) { console.log(`Created media buy ${validated.media_buy_id}`); console.log(`Upload creatives by: ${validated.creative_deadline}`); console.log(`Packages created: ${validated.packages.length}`); } ``` ```python Python test=false theme={null} import asyncio import time from datetime import datetime, timedelta, timezone from adcp.testing import test_agent async def create_campaign(): # Calculate dates dynamically - start tomorrow, end in 90 days tomorrow = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) end_date = tomorrow + timedelta(days=90) result = await test_agent.simple.create_media_buy( brand={ 'domain': 'acmecorp.com' }, packages=[ { 'product_id': 'prod_d979b543', 'pricing_option_id': 'cpm_usd_auction', 'format_ids': [ { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250_image' } ], 'budget': 2500, 'bid_price': 5.00 }, { 'product_id': 'prod_e8fd6012', 'pricing_option_id': 'cpm_usd_auction', 'format_ids': [ { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250_html' } ], 'budget': 2500, 'bid_price': 4.50 } ], start_time=tomorrow.isoformat().replace('+00:00', 'Z'), end_time=end_date.isoformat().replace('+00:00', 'Z') ) # Check for errors (discriminated union response) if hasattr(result, 'errors') and result.errors: raise Exception(f"Failed to create media buy: {result.errors}") print(f"Created media buy {result.media_buy_id}") print(f"Upload creatives by: {result.creative_deadline}") print(f"Packages created: {len(result.packages)}") asyncio.run(create_campaign()) ``` ```bash CLI test=false theme={null} npx @adcp/sdk@latest \ https://test-agent.adcontextprotocol.org/sales/mcp \ create_media_buy \ '{"brand":{"domain":"acmecorp.com"},"packages":[{"product_id":"prod_d979b543","pricing_option_id":"cpm_usd_auction","format_ids":[{"agent_url":"https://creative.adcontextprotocol.org","id":"display_300x250_image"}],"budget":30000,"bid_price":5.00},{"product_id":"prod_e8fd6012","pricing_option_id":"cpm_usd_auction","format_ids":[{"agent_url":"https://creative.adcontextprotocol.org","id":"display_300x250_html"}],"budget":20000,"bid_price":4.50}],"start_time":"2025-06-01T00:00:00Z","end_time":"2025-08-31T23:59:59Z"}' \ --auth $ADCP_AUTH_TOKEN ``` ## Request Parameters | Parameter | Type | Required | Description | | ------------------- | ----------------------------------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required for billing and policy evaluation. | | `proposal_id` | string | No\* | ID of a committed proposal from `get_products` to execute. Alternative to providing packages. Draft proposals must be finalized first; sellers reject draft proposal execution with `PROPOSAL_NOT_COMMITTED`. | | `total_budget` | TotalBudget | No\* | Total budget when executing a proposal. Publisher applies allocation percentages. | | `packages` | Package\[] | No\* | Array of package configurations (see below). Required when not using proposal\_id. | | `brand` | BrandRef | Yes | Brand reference — resolved to full identity at execution time. See [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json) | | `start_time` | string | Yes | `"asap"` or ISO 8601 date-time. For new media buys, concrete date-times must not be in the past. | | `end_time` | string | Yes | ISO 8601 date-time (UTC unless timezone specified) | | `paused` | boolean | No | Create the media buy with delivery held. When true and the buy would otherwise be active, `media_buy_status` is `paused`. Setup blockers still take precedence: missing creatives yield `pending_creatives`, and future flights yield `pending_start`; the hold becomes visible as `paused` after those blockers clear. | | `invoice_recipient` | [BusinessEntity](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient) | No | Override the account's default billing entity for this buy. The seller MUST validate the recipient is authorized and include it in `check_governance` when governance agents are configured. | | `po_number` | string | No | Purchase order number | | `idempotency_key` | string | No | Unique key for safe retries. If a request with the same key and account has already been processed, the seller returns the existing media buy. MUST be unique per (seller, request) pair. Min 16 chars. | | `context` | object | No | Opaque correlation data echoed unchanged in the response. Use for internal tracking, trace IDs, or other caller-specific identifiers. | | `reporting_webhook` | ReportingWebhook | No | Automated reporting delivery configuration | \* Either `packages` OR (`proposal_id` + `total_budget`) must be provided. When executing a proposal, `proposal_status` on the returned proposal determines whether `create_media_buy` is valid. `committed` proposals can be executed before `expires_at`; `draft` proposals require a prior `get_products` refine call with `action: "finalize"`. Finalization is seller commitment to firm terms, not buyer acceptance. This `create_media_buy` call is the acceptance/execution step. ### TotalBudget Object | Parameter | Type | Required | Description | | ---------- | ------ | -------- | ---------------------- | | `amount` | number | Yes | Total budget amount | | `currency` | string | Yes | ISO 4217 currency code | ### Package Object | Parameter | Type | Required | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `product_id` | string | Yes | Product ID from `get_products`. Sellers MUST echo this value on every response package object that represents this requested package. | | `pricing_option_id` | string | Yes | Pricing option ID from product's `pricing_options` array | | `format_ids` | FormatID\[] | No | Legacy named-format selector. Format IDs that will be used — must be supported by product. When omitted (and no 3.1+ format-option selector or direct canonical selector is present), defaults to all formats supported by the product. Format-option-aware buyer SDKs targeting a heterogeneous seller population SHOULD dual-emit this alongside `format_option_refs` so legacy-format-only sellers receive an explicit format set rather than defaulting to all-formats. | | `format_option_refs` | FormatOptionRef\[] | No | 3.1+ format-option selector. Structured references to entries in the package's target product `format_options[]`: `{scope: "publisher", publisher_domain, format_option_id}` for publisher-catalog-backed options, or `{scope: "product", format_option_id}` for product-local options (see [canonical formats](/dist/docs/3.1.20/creative/canonical-formats)). When present, `format_option_refs` wins over both `format_ids` and direct `format_kind`/`params`. Sellers MUST reject with `UNSUPPORTED_FEATURE` (field path `packages[i].format_option_refs[j]`) when an entry doesn't match a `format_options[]` entry, when the product is legacy-format-only, or when the product's `format_options[]` entries don't publish selectable `format_option_id` values. | | `format_kind` | CanonicalFormatKind | No | 3.1+ direct canonical selector. Names the canonical format shape this package targets when the buyer is authoring by canonical kind rather than by `format_option_refs[]` or `format_ids[]`. Pair with `params` when the product declaration requires dimensions, duration, sizes, codecs, or other canonical parameters. If `format_ids[]` is also present and `format_option_refs[]` is absent, sellers validate `format_ids[]`; `format_kind` is only an informational echo of the same normalized shape. | | `params` | object | No | Parameters for the direct canonical selector in `format_kind`. Follows the selected canonical's parameter vocabulary. Requires `format_kind`; `params` alone is schema-invalid. A broad selector such as `{format_kind: "image"}` does not satisfy a product whose `format_options[]` fixes `params.width` and `params.height`; sellers reject under-specified direct selectors with `UNSUPPORTED_FEATURE` or an equivalent format-selector error. | | `budget` | number | Yes | Budget in currency specified by pricing option | | `impressions` | number | No | Impression goal for this package | | `paused` | boolean | No | Create package in paused state (default: `false`) | | `pacing` | string | No | `"even"` (default), `"asap"`, or `"front_loaded"` | | `bid_price` | number | No | Bid price for auction pricing. This is the exact bid/price to honor unless the selected pricing option has `max_bid: true`, in which case it is treated as the buyer's maximum willingness to pay (ceiling). | | `optimization_goals` | [OptimizationGoal\[\]](/dist/docs/3.1.20/media-buy/conversion-tracking/#optimization-goals) | No | Optimization targets for this package. Each goal is either `kind: "event"` (conversion events with `event_sources` array, optional `cost_per`, `per_ad_spend`, or `maximize_value` target) or `kind: "metric"` (seller-native metric with optional `cost_per` or `threshold_rate` target). Event goals require `conversion_tracking.supported_targets` on the product; metric goals require `metric_optimization.supported_metrics`. | | `targeting_overlay` | TargetingOverlay | No | Additional targeting criteria (see [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting)) | | `start_time` | string | No | ISO 8601 date-time for this package's flight start. When omitted, inherits the media buy's `start_time`. Must fall within the media buy's date range. Does not support `"asap"`. | | `end_time` | string | No | ISO 8601 date-time for this package's flight end. When omitted, inherits the media buy's `end_time`. Must fall within the media buy's date range. | | `creative_assignments` | CreativeAssignment\[] | No | Assign existing library creatives with optional weights and placement targeting | | `creatives` | CreativeAsset\[] | No | Upload new creative assets inline and assign. Requires `media_buy.features.inline_creative_management: true`; when the seller also advertises `creative.has_creative_library: true`, `creative_id` must not already exist in the library. | | `context` | object | No | Opaque correlation data echoed unchanged in the package response, webhooks, and read surfaces. Use to map seller-assigned `package_id` back to your internal line items, campaign structure, or tracking state. Buyers targeting mixed seller populations SHOULD include a per-package correlation value here, commonly `context.buyer_ref`, for legacy sellers that do not echo `product_id`. | | `measurement_terms` | [MeasurementTerms](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#measurement-terms-and-performance-standards) | No | Buyer's proposed billing measurement and makegood terms. Overrides product defaults. Seller accepts (echoed on confirmed package), rejects with `TERMS_REJECTED`, or adjusts. When omitted, product's `measurement_terms` apply. | | `performance_standards` | [PerformanceStandard\[\]](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#measurement-terms-and-performance-standards) | No | Buyer's proposed performance standards (viewability, IVT, completion rate, brand safety, attention score). Overrides product defaults. Seller accepts, rejects with `TERMS_REJECTED`, or adjusts. When omitted, product's `performance_standards` apply. | | `committed_metrics` | object\[] | No | Buyer's proposed reporting contract — metrics the buyer wants the seller to commit to populating in delivery reports. Same negotiation pattern as `measurement_terms`/`performance_standards`: each entry tags `scope: "standard"` (with `metric_id` from the closed enum) or `scope: "vendor"` (with `vendor` BrandRef + vendor's `metric_id`). Request-side entries do NOT carry `committed_at` — that timestamp is stamped by the seller on accept. Seller accepts (echoes on response with `committed_at`), rejects with `TERMS_REJECTED`, or normalizes (echoes a different but compatible list). When omitted, the seller decides what to commit based on the product's `available_metrics` plus any `required_metrics` filter the buyer passed at discovery. | ## Response ### Success Response | Field | Description | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy_id` | Seller's unique identifier | | `account` | Resolved account billed for this media buy, echoed as a full [Account](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) object (includes `account_id`, `name`, `status`, and the resolved `brand`/`operator`). When the request used implicit resolution (`brand` + `operator`), this confirms the account the seller resolved. Optional. | | `confirmed_at` | ISO 8601 timestamp when the seller committed to the media buy. Stable after it is set. May be `null` in deferred/manual approval flows until seller commitment occurs. | | `creative_deadline` | ISO 8601 timestamp for creative upload deadline | | `revision` | Initial media-buy revision. Use this value as the `revision` token on the next `update_media_buy` call intended to change state. | | `packages` | Array of created packages with complete state. Packages may include per-package `creative_deadline` when different from the media buy deadline, and SHOULD echo every format selector field supplied on create (`format_option_refs`, `format_ids`, and/or `format_kind`/`params`) so read surfaces are lossless even when one selector wins precedence. | `confirmed_at` is seller commitment time, not a delivery-status timestamp. Do not update it when a buy later pauses, resumes, starts delivery, completes, or reports performance. A committed synchronous create stamps it immediately. Use the `submitted` response branch when no `media_buy_id` is being returned to the buyer. Sellers MAY instead return synchronous success with `media_buy_id`, `packages`, and `confirmed_at: null` for a provisional buy; such buys MUST be retrievable via `get_media_buys` and MUST transition by setting `confirmed_at` exactly once on commitment. A provisional buy with `confirmed_at: null` MUST NOT be `active` and MUST NOT include `packages[].committed_metrics`. #### Reporting contract on confirmed packages Each package in the response MAY carry `committed_metrics` — the binding reporting contract the seller has agreed to populate in delivery reports for this package. The field is a unified array carrying both standard metrics (from the closed `available-metric.json` enum) and vendor-defined metrics (anchored on a BrandRef), with each entry tagged by an explicit `scope` discriminator and timestamped via `committed_at`: When `confirmed_at` is `null`, sellers MUST omit `packages[].committed_metrics`. The first response that sets `confirmed_at` MAY include the initial committed-metrics set, and each such entry's `committed_at` MUST equal `confirmed_at`. ```json theme={null} { "package_id": "pkg_001", "committed_metrics": [ { "scope": "standard", "metric_id": "impressions", "committed_at": "2026-04-29T10:53:00Z" }, { "scope": "standard", "metric_id": "completed_views", "committed_at": "2026-04-29T10:53:00Z" }, { "scope": "vendor", "vendor": { "domain": "attentionvendor.example" }, "metric_id": "attention_units", "committed_at": "2026-04-29T10:53:00Z" }, { "scope": "standard", "metric_id": "viewable_rate", "qualifier": { "viewability_standard": "mrc" }, "committed_at": "2026-05-30T14:22:00Z" } ] } ``` **How the contract works:** * **Day-1 entries** share `committed_at = confirmed_at`. The seller stamps the day-1 set on the `create_media_buy` response based on what they're prepared to deliver from the product's `reporting_capabilities`. * **Mid-flight additions** are appended via `update_media_buy` — append-only with their own `committed_at` timestamps. This lets a seller honestly say "Adelaide attention is now part of the contract from day 30 onward" without having to cancel and reissue the buy. * **Existing entries are immutable.** Sellers MUST reject `update_media_buy` requests that attempt to modify or remove existing entries with a `validation_error` (suggested code: `IMMUTABLE_FIELD`). New entries can be appended. * **Qualifiers on standard metrics.** Some metrics have multiple incompatible measurement paths and need disambiguation: * **`viewability_standard`** — when `metric_id` is one of `viewable_impressions`, `viewable_rate`, `measurable_impressions` and the seller commits to a specific viewability standard (MRC and GroupM are materially different thresholds — see the `viewability-standard` enum), the entry MUST carry `qualifier.viewability_standard`. Symmetric on `missing_metrics`: a buyer expecting MRC viewability flags a GroupM-only delivery report as missing the MRC commitment. * **`completion_source`** — when `metric_id` is `completion_rate` and the seller commits to a specific source (the player/ad server's own completion event vs. a third-party measurement vendor anchored on `performance_standard.vendor`), the entry MUST carry `qualifier.completion_source` (`seller_attested` or `vendor_attested`). The two paths can yield materially different rates, particularly in SSAI environments. Symmetric on `missing_metrics`. * **`attribution_methodology`** — when `metric_id` is an outcome metric (`conversions`, `conversion_value`, `roas`, `cost_per_acquisition`, `incremental_sales_lift`, `brand_lift`, `foot_traffic`, `conversion_lift`, `brand_search_lift`, `units_sold`, `new_to_brand_rate`, `new_to_brand_units`, `leads`) and the seller commits to a specific attribution methodology, the entry SHOULD carry `qualifier.attribution_methodology` (`deterministic_purchase` for retail-media closed-loop; `probabilistic`, `panel_based`, or `modeled` for other paths). Two outcome rows under different methodologies are not interchangeable; symmetric on `missing_metrics`. * **`attribution_window`** — when `metric_id` is an outcome metric and the seller commits to a specific lookback window, the entry SHOULD carry `qualifier.attribution_window` as a structured duration (`{ interval: 14, unit: "days" }`). Two outcome rows over different windows are reported as separate rows so buyers don't accidentally aggregate across periods. Without the qualifier, the contract is ambiguous and reconciliation falls back to whatever the delivery report happens to carry. The qualifier vocabulary is closed (`additionalProperties: false`); new keys ship explicitly in subsequent minors. * **Reconciliation:** `missing_metrics` on `get_media_buy_delivery` filters `committed_metrics` to entries where `committed_at < reporting_period.end`, then flags any that aren't populated in the report. A metric committed mid-flight is only audited from its commitment timestamp forward. Qualifiers are matched verbatim — a committed `{viewable_rate, mrc}` is not satisfied by a delivered `viewable_rate` carrying `viewability.standard: groupm`. * **Optional in v1.** Sellers without per-package snapshot infrastructure can adopt incrementally. Absence is conformant but carries a known audit gap: without the snapshot, `missing_metrics` reconciles against the product's live `available_metrics` at report time, which may not reflect what was committed at create time. Sellers that omit `committed_metrics` accept this risk; buyers SHOULD treat absence as "no audit-grade contract" rather than "clean delivery." Expected to become required at the next major. ### Error Response | Field | Description | | -------- | ----------------------------------------- | | `errors` | Array of error objects explaining failure | ### Submitted Response Returned when the buy cannot be confirmed synchronously — e.g., guaranteed buys awaiting IO signing, governance review queued, or batched processing. The completion artifact (delivered via `tasks/get` or push-notification webhook) carries `media_buy_id` and `packages`. There is no proposal-specific acceptance webhook. Proposal execution that needs human approval, IO signing, or asynchronous processing uses this same submitted task envelope and the standard task/webhook completion path. | Field | Description | | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | Literal `"submitted"` — discriminates this shape from the sync success branch, which uses `media_buy_status` for lifecycle state. During the 3.1 migration window, sellers MAY also emit deprecated top-level MediaBuyStatus values on synchronous success for back-compat. | | `task_id` | Handle the buyer polls with `tasks/get` or receives on webhook callbacks. | | `message` | Optional human-readable explanation (e.g., "Awaiting IO signature from sales team"). | | `errors` | Optional advisory warnings (non-blocking). Terminal failures belong in the Error Response. | **Note**: Responses are mutually exclusive across these three shapes. Dispatch on `status` first: `"submitted"` → async envelope, otherwise check `errors` before accessing success fields. ### When to return Submitted vs synchronous Success (normative) The choice between `submitted` and synchronous success is **per-call**, driven by per-product attributes and the seller's policy on each specific create — not a uniform per-seller rule. A sales-guaranteed seller may legitimately return synchronous success on some `create_media_buy` calls and `submitted` on others within the same session; conformant SDK skills MUST NOT instruct agents to return `submitted` for every `create_media_buy` regardless of input. Sellers that uniformly return `submitted` fail the non-IO-approval paths in the `sales-guaranteed` compliance storyboard. Sellers MUST return `submitted` when: * The request references one or more products with `delivery_type: "guaranteed"` **and** the seller declares the `requires_io_approval` capability — the human-approval handshake cannot complete inside the response. The completion artifact is delivered via `tasks/get` or webhook once IO signing finishes. * The request triggers a seller-side governance review that cannot complete synchronously (e.g., manual brand-safety review for a regulated vertical). * The request enters a batched-processing queue the seller cannot drain inside the response timeout. Sellers MUST return synchronous success when: * All referenced products have `delivery_type: "non_guaranteed"`. The buy is created and acknowledged in-line; `media_buy_id` and `packages` are issued immediately. This applies regardless of the seller's specialism — a sales-guaranteed seller serving a non-guaranteed product returns synchronous success. * The request references guaranteed products and the seller does NOT declare `requires_io_approval` (rare; typically retail-SKU or quoted-rate guaranteed flows where the seller has pre-cleared approval). * The buy enters a known non-terminal state immediately observable to the buyer (`pending_creatives` / `pending_start` / `active` / `paused`). The compliance grader observes both paths against the same seller via separate storyboard scenarios: the `create_buy_submitted` scenario seeds a guaranteed product with `requires_io_approval`; four shared scenarios (`measurement_terms_rejected`, `pending_creatives_to_start`, `inventory_list_targeting`, `invalid_transitions`) seed non-guaranteed products and expect synchronous `media_buy_id` returns. Sellers that return `submitted` on the synchronous-expected scenarios fail compliance — see [`sales-guaranteed` specialism](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/specialisms/sales-guaranteed) for the fixture pattern (non-guaranteed products listed first so open-brief `get_products` calls resolve to a synchronous-create path). This rule resolves the skill ↔ storyboard contradiction tracked at [#3822](https://github.com/adcontextprotocol/adcp/issues/3822): an SDK skill that instructs agents to "return a task envelope for every `create_media_buy`" is non-conformant; the correct skill instructs agents to dispatch on per-product `delivery_type` and the seller's `requires_io_approval` capability. ## Common Scenarios ### Campaign with Targeting Add geographic restrictions and frequency capping: ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { CreateMediaBuyResponseSchema } from '@adcp/sdk'; // Calculate end date dynamically - 90 days from now const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ brand: { domain: 'acmecorp.com' }, packages: [{ product_id: 'prod_d979b543', pricing_option_id: 'cpm_usd_auction', format_ids: [{ agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250_image' }], budget: 2500, bid_price: 5.00, targeting_overlay: { geo_countries: ['US'], geo_regions: ['US-CA', 'US-NY'], frequency_cap: { suppress: { interval: 60, unit: 'minutes' } } } }], start_time: 'asap', end_time: endDate.toISOString() }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = CreateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Creation failed: ${JSON.stringify(validated.errors)}`); } if ('media_buy_id' in validated) { console.log(`Campaign ${validated.media_buy_id} created with targeting`); } ``` ```python Python test=false theme={null} import asyncio import time from datetime import datetime, timedelta, timezone from adcp.testing import test_agent async def create_targeted_campaign(): # Calculate end date dynamically - 90 days from now end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( brand={ 'domain': 'acmecorp.com' }, packages=[{ 'product_id': 'prod_d979b543', 'pricing_option_id': 'cpm_usd_auction', 'format_ids': [{ 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250_image' }], 'budget': 2500, 'bid_price': 5.00, 'targeting_overlay': { 'geo_countries': ['US'], 'geo_regions': ['US-CA', 'US-NY'], 'frequency_cap': { 'suppress': {'interval': 60, 'unit': 'minutes'} } } }], start_time='asap', end_time=end_date.isoformat().replace('+00:00', 'Z') ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Creation failed: {result.errors}") print(f"Campaign {result.media_buy_id} created with targeting") asyncio.run(create_targeted_campaign()) ``` ### Campaign with Conversion Optimization Set a per\_ad\_spend target for conversion-optimized delivery. The product must declare support in `conversion_tracking.supported_targets`, and you must have an event source configured via `sync_event_sources`: ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { CreateMediaBuyResponseSchema } from '@adcp/sdk'; const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ brand: { domain: 'acmecorp.com' }, packages: [{ product_id: 'prod_retail_sp', pricing_option_id: 'cpc_usd_auction', budget: 10000, bid_price: 1.20, optimization_goals: [{ kind: 'event', event_sources: [ { event_source_id: 'retailer_sales', event_type: 'purchase', value_field: 'value' } ], target: { kind: 'per_ad_spend', value: 4.0 }, priority: 1 }] }], start_time: 'asap', end_time: endDate.toISOString() }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = CreateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Creation failed: ${JSON.stringify(validated.errors)}`); } if ('media_buy_id' in validated) { console.log(`Campaign ${validated.media_buy_id} created with per_ad_spend target`); } ``` ```python Python test=false theme={null} import asyncio import time from datetime import datetime, timedelta, timezone from adcp.testing import test_agent async def create_optimized_campaign(): end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( brand={ 'domain': 'acmecorp.com' }, packages=[{ 'product_id': 'prod_retail_sp', 'pricing_option_id': 'cpc_usd_auction', 'budget': 10000, 'bid_price': 1.20, 'optimization_goals': [{ 'kind': 'event', 'event_sources': [ { 'event_source_id': 'retailer_sales', 'event_type': 'purchase', 'value_field': 'value' } ], 'target': { 'kind': 'per_ad_spend', 'value': 4.0 }, 'priority': 1 }] }], start_time='asap', end_time=end_date.isoformat().replace('+00:00', 'Z') ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Creation failed: {result.errors}") print(f"Campaign {result.media_buy_id} created with per_ad_spend target") asyncio.run(create_optimized_campaign()) ``` ### Catalog-driven packages A catalog-driven package allocates a single budget envelope to an entire catalog of items. Instead of creating separate packages per item, the platform optimizes delivery across all catalog items based on performance. This is the AdCP equivalent of catalog-based campaign types such as Google Performance Max or Meta Dynamic Product Ads. Include the `catalogs` field in a package to make it catalog-driven. Each catalog should have a distinct type (e.g., one product catalog, one store catalog). The referenced catalogs must already be synced via [`sync_catalogs`](/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs). **Job campaign with synced job catalog:** ```json test=false theme={null} { "brand": { "domain": "acme-restaurants.com" }, "packages": [{ "product_id": "prod_job_board", "pricing_option_id": "cpc_eur_auction", "budget": 5000, "bid_price": 2.50, "catalogs": [{ "catalog_id": "chef-vacancies", "type": "job" }] }], "start_time": "asap", "end_time": "2026-06-30T23:59:59Z" } ``` **Retail media with product catalog and store catchment targeting:** ```json test=false theme={null} { "brand": { "domain": "acmecorp.com" }, "packages": [{ "product_id": "prod_retail_sp", "pricing_option_id": "cpc_usd_auction", "budget": 10000, "bid_price": 1.20, "catalogs": [{ "catalog_id": "gmc-primary", "type": "product", "tags": ["summer"] }], "targeting_overlay": { "store_catchments": [{ "catalog_id": "retail-locations", "catchment_ids": ["drive"] }] } }], "start_time": "asap", "end_time": "2026-09-30T23:59:59Z" } ``` The platform distributes budget across catalog items based on performance. For per-item reporting, use [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) which returns `by_catalog_item` breakdowns. Creative variants for catalog-driven packages represent individual catalog items rendered as ads. **Package with explicit signal targeting:** Use `targeting_overlay.signal_targeting_groups` when the buyer wants seller-offered signals applied to a specific package. The selected product must set `signal_targeting_allowed: true` and make the signal eligible through inline `signal_targeting_options` when present, through `get_signals` for wholesale products that omit inline options, and through `signal_targeting_rules`. The grouped expression shape is always used: top-level `operator: "all"` with child groups using `operator: "any"` for include groups and `operator: "none"` for exclusion groups. For simple include-only targeting, send one `any` group. For binary signals, send `value: true` in both include and exclusion groups; exclusion is expressed by the parent `none` group, not by `value: false`. Signals are referenced with `signal_ref`: use `scope: "product"` for a product-local signal option, `scope: "data_provider"` with `data_provider_domain` for a signal defined in a data provider's published adagents.json `signals[]`, or `scope: "signal_source"` with `signal_source_url` for a source-native signal. This is distinct from `audience_include` / `audience_exclude`, which only reference first-party audiences registered through `sync_audiences`. Send `signal_agent_segment_id` only when the selected product option or `get_signals` result exposed it as a separate execution handle required by the seller. When a creative carries a build-time `signal_condition` (from `build_creative`'s `signal_conditions` fan-out, [#5240](https://github.com/adcontextprotocol/adcp/issues/5240)), assigning it to a package whose signal targeting is incompatible — e.g. a sun creative to a rain-targeted package — is rejected with `SIGNAL_TARGETING_INCOMPATIBLE`. Compatibility is matched on the same shared `signal_ref` identity used here. See the [signals specification](/dist/docs/3.1.20/signals/specification#creative-signal-fan-out-and-trafficking-compatibility) for the normative trafficking-compatibility contract. ```json test=false theme={null} { "brand": { "domain": "acmecorp.com" }, "packages": [{ "product_id": "retail_video_premium", "pricing_option_id": "media_cpm_usd", "budget": 25000, "targeting_overlay": { "signal_targeting_groups": { "operator": "all", "groups": [{ "operator": "any", "signals": [{ "signal_ref": { "scope": "data_provider", "data_provider_domain": "pinnacle-data.example", "signal_id": "auto_intenders" }, "value_type": "binary", "value": true, "pricing_option_id": "signal_cpm_usd_250", "signal_agent_segment_id": "seller_sig_auto_intenders" }] }] } } }], "start_time": "asap", "end_time": "2026-09-30T23:59:59Z" } ``` Include plus exclusion example: ```json test=false theme={null} { "brand": { "domain": "acmecorp.com" }, "packages": [{ "product_id": "retail_video_premium", "pricing_option_id": "media_cpm_usd", "budget": 25000, "targeting_overlay": { "signal_targeting_groups": { "operator": "all", "groups": [ { "operator": "any", "signals": [ { "signal_ref": { "scope": "product", "signal_id": "high_intent_shoppers" }, "value_type": "binary", "value": true }, { "signal_ref": { "scope": "product", "signal_id": "loyalty_members" }, "value_type": "binary", "value": true } ] }, { "operator": "none", "signals": [ { "signal_ref": { "scope": "product", "signal_id": "recent_purchasers" }, "value_type": "binary", "value": true } ] } ] } } }], "start_time": "asap", "end_time": "2026-09-30T23:59:59Z" } ``` ### Campaign with Inline Creatives Upload creatives at the same time as creating the campaign: ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { CreateMediaBuyResponseSchema } from '@adcp/sdk'; // Calculate end date dynamically - 90 days from now const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ brand: { domain: 'acmecorp.com' }, packages: [{ product_id: 'prod_d979b543', pricing_option_id: 'cpm_usd_auction', format_ids: [{ agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250_image' }], budget: 2500, bid_price: 5.00, creatives: [{ creative_id: 'hero_video_30s', name: 'Hero Video', format_id: { agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250_image' }, assets: { image: { url: 'https://cdn.example.com/hero-banner.jpg', width: 300, height: 250 } } }] }], start_time: 'asap', end_time: endDate.toISOString() }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = CreateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Creation failed: ${JSON.stringify(validated.errors)}`); } if ('packages' in validated) { console.log(`Campaign created with ${validated.packages[0].creative_assignments.length} creatives`); } ``` ```python Python test=false theme={null} import asyncio import time from datetime import datetime, timedelta, timezone from adcp.testing import test_agent async def create_with_creatives(): # Calculate end date dynamically - 90 days from now end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( brand={ 'domain': 'acmecorp.com' }, packages=[{ 'product_id': 'prod_d979b543', 'pricing_option_id': 'cpm_usd_auction', 'format_ids': [{ 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250_image' }], 'budget': 2500, 'bid_price': 5.00, 'creatives': [{ 'creative_id': 'hero_video_30s', 'name': 'Hero Video', 'format_id': { 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250_image' }, 'assets': { 'image': { 'url': 'https://cdn.example.com/hero-banner.jpg', 'width': 300, 'height': 250 } } }] }], start_time='asap', end_time=end_date.isoformat().replace('+00:00', 'Z') ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Creation failed: {result.errors}") print(f"Campaign created with {len(result.packages[0].creative_assignments)} creatives") asyncio.run(create_with_creatives()) ``` ### Campaign with Reporting Webhook Receive automated reporting notifications: ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { CreateMediaBuyResponseSchema } from '@adcp/sdk'; // Calculate end date dynamically - 90 days from now const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ brand: { domain: 'acmecorp.com' }, packages: [{ product_id: 'prod_d979b543', pricing_option_id: 'cpm_usd_auction', format_ids: [{ agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250_image' }], budget: 2500, bid_price: 5.00 }], start_time: 'asap', end_time: endDate.toISOString(), reporting_webhook: { url: 'https://buyer.example.com/webhooks/reporting', authentication: { schemes: ['Bearer'], credentials: 'secret_token_xyz_minimum_32_chars' }, reporting_frequency: 'daily', requested_metrics: ['impressions', 'spend', 'completed_views'] } }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = CreateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Creation failed: ${JSON.stringify(validated.errors)}`); } if ('media_buy_id' in validated) { console.log(`Campaign created - daily reports will be sent to webhook`); } ``` ```python Python test=false theme={null} import asyncio import time from datetime import datetime, timedelta, timezone from adcp.testing import test_agent async def create_with_reporting(): # Calculate end date dynamically - 90 days from now end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( brand={ 'domain': 'acmecorp.com' }, packages=[{ 'product_id': 'prod_d979b543', 'pricing_option_id': 'cpm_usd_auction', 'format_ids': [{ 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250_image' }], 'budget': 2500, 'bid_price': 5.00 }], start_time='asap', end_time=end_date.isoformat().replace('+00:00', 'Z'), reporting_webhook={ 'url': 'https://buyer.example.com/webhooks/reporting', 'authentication': { 'schemes': ['Bearer'], 'credentials': 'secret_token_xyz_minimum_32_chars' }, 'reporting_frequency': 'daily', 'requested_metrics': ['impressions', 'spend', 'completed_views'] } ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Creation failed: {result.errors}") print('Campaign created - daily reports will be sent to webhook') asyncio.run(create_with_reporting()) ``` ### Executing a Proposal Execute a proposal from `get_products` without manually constructing packages: ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { CreateMediaBuyResponseSchema } from '@adcp/sdk'; // Calculate end date dynamically - 90 days from now const endDate = new Date(); endDate.setDate(endDate.getDate() + 90); const result = await testAgent.createMediaBuy({ proposal_id: 'swiss_balanced_v1', // From get_products response total_budget: { amount: 50000, currency: 'USD' }, brand: { domain: 'acmecorp.com' }, start_time: 'asap', end_time: endDate.toISOString() }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = CreateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Creation failed: ${JSON.stringify(validated.errors)}`); } if ('media_buy_id' in validated) { // Publisher converted proposal allocations to packages console.log(`Created media buy ${validated.media_buy_id}`); console.log(`Packages created: ${validated.packages.length}`); } ``` ```python Python test=false theme={null} import asyncio import time from datetime import datetime, timedelta, timezone from adcp.testing import test_agent async def execute_proposal(): # Calculate end date dynamically - 90 days from now end_date = datetime.now(timezone.utc) + timedelta(days=90) result = await test_agent.simple.create_media_buy( proposal_id='swiss_balanced_v1', # From get_products response total_budget={ 'amount': 50000, 'currency': 'USD' }, brand={ 'domain': 'acmecorp.com' }, start_time='asap', end_time=end_date.isoformat().replace('+00:00', 'Z') ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Creation failed: {result.errors}") # Publisher converted proposal allocations to packages print(f"Created media buy {result.media_buy_id}") print(f"Packages created: {len(result.packages)}") asyncio.run(execute_proposal()) ``` When executing a proposal: * The publisher converts allocation percentages to actual budgets using `total_budget` * Packages are created automatically based on the proposal's allocations * All other fields (brand, start\_time, end\_time, etc.) work the same as manual mode See [Proposals](/dist/docs/3.1.20/media-buy/product-discovery/media-products#proposals) for the complete workflow. ### Context for Correlation The `context` field is an opaque object that sellers echo unchanged in responses and webhooks. Use it to map seller-assigned IDs back to your internal systems without needing to maintain a separate lookup table. Context works at two levels: * **Media buy level** — echoed in the `create_media_buy` response * **Package level** — echoed in each package's response, webhooks, and read surfaces, useful for mapping `package_id` back to your internal line items. For explicit package requests, sellers MUST also echo `product_id`. When targeting mixed seller populations, include package context such as `context.buyer_ref` as a legacy-safe fallback for older sellers that may not echo `product_id`. **Mapping to internal campaign and line item IDs:** ```json test=false theme={null} { "brand": { "domain": "acmecorp.com" }, "context": { "campaign_id": "camp-2026-q3-awareness", "planner": "media-team-west", "trace_id": "req-8f3a-4b2c" }, "packages": [ { "product_id": "prod_d979b543", "pricing_option_id": "cpm_usd_auction", "budget": 15000, "bid_price": 5.00, "context": { "line_item_id": "li-001", "flight": "june-awareness" } }, { "product_id": "prod_e8fd6012", "pricing_option_id": "cpm_usd_auction", "budget": 10000, "bid_price": 4.50, "context": { "line_item_id": "li-002", "flight": "june-retargeting" } } ], "start_time": "2026-06-01T00:00:00Z", "end_time": "2026-08-31T23:59:59Z" } ``` The seller's response echoes your context back alongside the seller-assigned IDs: ```json test=false theme={null} { "media_buy_id": "mb_12345", "context": { "campaign_id": "camp-2026-q3-awareness", "planner": "media-team-west", "trace_id": "req-8f3a-4b2c" }, "packages": [ { "package_id": "pkg_001", "product_id": "prod_d979b543", "context": { "line_item_id": "li-001", "flight": "june-awareness" } }, { "package_id": "pkg_002", "product_id": "prod_e8fd6012", "context": { "line_item_id": "li-002", "flight": "june-retargeting" } } ] } ``` Sellers must never parse or act on context data — it exists purely for the buyer's internal use. ## Error Handling Common errors and resolutions: | Error Code | Description | Resolution | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PRODUCT_NOT_FOUND` | Invalid product\_id | Verify product exists via `get_products` | | `UNSUPPORTED_FEATURE` | Format not supported by the product — covers legacy named-format selectors (`format_ids[]` not in the product's accepted formats), 3.1+ format-option selectors (`format_option_refs[]` entries that do not resolve against the product's `format_options[]`, legacy-format-only products with no `format_options[]`, or product `format_options[]` entries that do not publish selectable `format_option_id` values), and direct canonical selectors (`format_kind`/`params` outside or under-specifying the product declaration) | Check the product's `format_ids` and/or `format_options[]` from `get_products` — re-author against a supported format, add the required canonical parameters, or pick a `format_option_ref` from the product's published `format_options[]` | | `BUDGET_TOO_LOW` | Budget below product minimum | Increase budget or choose different product | | `TARGETING_TOO_NARROW` | Targeting yields zero inventory | Broaden geographic or audience criteria | | `POLICY_VIOLATION` | Brand/product violates policy | Review publisher's content policies | | `INVALID_PRICING_OPTION` | pricing\_option\_id not found | Use ID from product's `pricing_options` | | `CREATIVE_ID_EXISTS` | Creative ID already exists in the seller's creative namespace | For library-backed sellers, assign existing creatives via `creative_assignments` or update via `sync_creatives`; for inline-only sellers, use a different package-scoped `creative_id` | Example error response: ```json theme={null} { "errors": [{ "code": "UNSUPPORTED_FEATURE", "message": "Product 'prod_d979b543' does not support format 'display_728x90'", "field": "packages[0].format_ids[0]", "suggestion": "Use display_300x250_image, display_300x250_html, or display_300x250_generative formats" }] } ``` ## Key Concepts ### Format Specification Each package SHOULD specify the formats it will use, via `format_ids[]` (legacy named-format path — structured `{agent_url, id}` references), `format_option_refs[]` (3.1+ format-option path — references into the product's `format_options[]`), or a direct canonical selector (`format_kind` plus optional `params`). Omitting all format selectors defaults to all formats supported by the product. Specifying lets the system: * Publish placeholder creatives in ad servers * Pin exactly what creative assets are needed * Validate that the product supports the requested formats * Track which assets are missing Selector precedence is deterministic: `format_option_refs[]` wins when present; otherwise `format_ids[]` wins when present; otherwise direct `format_kind`/`params` is used; otherwise the package defaults to all product formats. Use `format_option_refs[]` when the buyer's codebase reads `Product.format_options[]` and the product publishes selectable `format_option_id` values; use `format_ids[]` when integrating with legacy-format-only sellers or libraries. Use direct `format_kind` only when the buyer has enough canonical parameters to satisfy the target product declaration without a product-local reference. Dual-emission of `format_option_refs[]` and `format_ids[]` is allowed and recommended for buyer SDKs targeting a mixed seller population — see the `format_ids` row above. 3.1+ format-option example (buyer authoring against a publisher-scoped `Product.format_options[]` entry): ```json test=false theme={null} { "packages": [ { "product_id": "prod_d979b543", "pricing_option_id": "cpm_usd_auction", "format_option_refs": [ { "scope": "publisher", "publisher_domain": "daily-pulse.example", "format_option_id": "daily_pulse_homepage_image" } ], "budget": 2500, "bid_price": 5.00 } ] } ``` See [Format Workflow](#format-workflow) below for complete details. ### Brand reference The `brand` field identifies the advertiser for policy compliance and business purposes. ```json theme={null} { "brand": { "domain": "acmecorp.com" } } ``` Full brand identity data (colors, fonts, product catalog) is resolved from brand.json at execution time. See [brand.json](/dist/docs/3.1.20/brand-protocol/brand-json). ### Pricing & Currency Each package specifies its `pricing_option_id`, which determines: * Currency (USD, EUR, etc.) * Pricing model (CPM, CPCV, CPP, etc.) * Rate and whether it's fixed or auction-based Packages can use different currencies when sellers support it. See [Pricing Models](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models). ### Budget & Pacing Controls Budget constraints are set **per package**, not at the media-buy level. The one exception is `total_budget`, which applies only when [executing a proposal](#executing-a-proposal) — the seller then derives per-package budgets from the proposal's allocation percentages. | Field | Level | Type | Controls | | ----------------------------------------------- | --------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `total_budget.amount` + `total_budget.currency` | Media buy | TotalBudget | Total spend when executing a proposal. Not used when supplying explicit `packages`. | | `budget` | Package | number | Total spend cap for the package, in the currency the package's `pricing_option_id` specifies. Delivery stops when the package budget is exhausted. | | `impressions` | Package | number | Impression goal for the package. An alternative volume target to `budget`; a seller may pace to whichever binds first. | | `pacing` | Package | enum | How the seller spreads the package budget across the flight: `even` (default), `asap`, or `front_loaded`. | | `bid_price` | Package | number | For auction pricing options — the exact bid, or the ceiling when the pricing option has `max_bid: true`. | | `start_time` / `end_time` | Package | string | The flight window the budget is spread across. When omitted, the package inherits the media buy's flight dates. | **Pacing modes** (from [`/schemas/3.1.20/enums/pacing.json`](https://adcontextprotocol.org/schemas/3.1.20/enums/pacing.json)): | Mode | Behavior | | -------------- | ---------------------------------------------------------------------- | | `even` | Allocate remaining budget evenly over the remaining flight (default). | | `asap` | Spend remaining budget as quickly as possible. | | `front_loaded` | Allocate more of the remaining budget earlier in the remaining flight. | Example package with an even-paced budget over a fixed flight: ```json theme={null} { "product_id": "prod_ctv_sports", "pricing_option_id": "po_cpm_fixed", "budget": 50000, "pacing": "even", "start_time": "2026-08-01T00:00:00Z", "end_time": "2026-08-31T23:59:59Z" } ``` With `even` pacing over the 31-day flight above, the seller spreads the $50,000 budget across the month — roughly $1,613/day — subject to the seller's own pacing engine and available inventory. #### Known gap: no explicit daily cap AdCP 3.1 has **no per-day (or per-hour) budget cap field.** Delivery rate is governed by the combination of `budget`, `pacing`, and the flight window (`start_time` / `end_time`) — not a hard daily ceiling the buyer can pin. To constrain daily spend today: * **Approximate a daily rate** by setting `pacing: "even"` and choosing `budget` and flight length so `budget ÷ flight_days` lands near the target daily spend. Enforcement is seller-side and best-effort, not a guaranteed hard cap. * **Tighten the window** by splitting a long flight into shorter packages (e.g. weekly packages), each with its own `budget`, when finer control is needed. A first-class daily-cap field is not yet specified. Tracking: [#5983](https://github.com/adcontextprotocol/adcp/issues/5983). ### Targeting Overlays **Use sparingly** - most targeting should be in your brief and handled through product selection. Use overlays only for: * Geographic restrictions (RCT testing, regulatory compliance) * Frequency capping * AXE segment inclusion/exclusion (legacy — new integrations use [TMP](/dist/docs/3.1.20/trusted-match)) See [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) for details. ## Format Workflow ### Why Format Specification Matters When creating a media buy, format specification enables: 1. **Placeholder Creation** - Publisher creates placeholders in ad server with correct specs 2. **Validation** - System validates products support requested formats 3. **Clear Expectations** - Both parties know exactly what's needed 4. **Progress Tracking** - Track which assets are missing vs. required 5. **Technical Setup** - Ad server configured before creatives arrive ### Complete Workflow ``` 1. list_creative_formats → Get available format specifications 2. get_products → Find products (returns format_ids[] and/or format_options[]) 3. Validate compatibility → Ensure products support desired formats 4. create_media_buy → Specify formats via format_ids[] (v1), format_option_refs[] (v2 reference), or format_kind/params (direct canonical); omit for all-formats default └── Publisher creates placeholders └── Clear creative requirements established 5. Creative supply → Upload matching files via `sync_creatives` for library-backed sellers, or inline `packages[].creatives` for inline-only sellers 6. Campaign activation → Replace placeholders with real creatives ``` ### Format Validation Publishers MUST validate: * All formats are supported by the product * Format specifications match `list_creative_formats` output * Creative requirements can be fulfilled within timeline Invalid legacy named-format example: ```json theme={null} { "errors": [{ "code": "UNSUPPORTED_FEATURE", "message": "Product 'ctv_sports_premium' does not support format 'audio_standard_30s'", "field": "packages[0].format_ids[0]", "supported_formats": [ { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_30s" }, { "agent_url": "https://creative.adcontextprotocol.org", "id": "video_standard_15s" } ] }] } ``` Invalid 3.1+ format-option example: ```json theme={null} { "errors": [{ "code": "UNSUPPORTED_FEATURE", "message": "Product 'ctv_sports_premium' has no format_options[] entry for format option 'audio_standard'", "field": "packages[0].format_option_refs[0]", "supported_format_option_refs": [ { "scope": "publisher", "publisher_domain": "streamhaus.example", "format_option_id": "ctv_video_30s_premium" }, { "scope": "publisher", "publisher_domain": "streamhaus.example", "format_option_id": "ctv_video_15s_premium" } ] }] } ``` ### Flight date validation For new media buys, the top-level `start_time` MUST be either `"asap"` or a date-time that is not in the past. A past concrete `start_time` MUST return an `INVALID_REQUEST` error. When a package specifies `start_time` or `end_time`, sellers SHOULD validate that: * Both dates fall within the media buy's date range * `start_time` is before `end_time` Out-of-range or inverted dates SHOULD return an `INVALID_REQUEST` error: ```json theme={null} { "errors": [{ "code": "INVALID_REQUEST", "message": "Package 'week_5' end_time 2026-04-05T23:59:59Z is after media buy end_time 2026-03-31T23:59:59Z", "field": "packages[3].end_time" }] } ``` ## Asynchronous Operations This task can complete instantly or take days depending on complexity and approval requirements. The response includes a `status` field that tells you what happened and what to do next. | Status | Meaning | Your Action | | ---------------- | ------------------------- | ----------------------------------- | | `completed` | Done immediately | Process the result | | `working` | Processing (\~2 min) | Poll frequently or wait for webhook | | `submitted` | Long-running (hours/days) | Use webhooks or poll infrequently | | `input-required` | Needs your input | Read message, respond with info | | `failed` | Error occurred | Handle the error | **Note:** For the complete status list see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle). ### Immediate Success (`completed`) The task completed synchronously. No async handling needed. **Request:** ```javascript test=false theme={null} const response = await session.call('create_media_buy', { brand: { domain: 'acmecorp.com' }, packages: [ { product_id: 'prod_ctv_sports', pricing_option_id: 'cpm_fixed', budget: 50000 } ] }); ``` **Response:** ```json theme={null} { "status": "completed", "media_buy_id": "mb_12345", "account": { "account_id": "acc_acme_direct", "name": "Acme", "status": "active", "brand": { "domain": "acmecorp.com" }, "operator": "acmecorp.com" }, "media_buy_status": "active", "confirmed_at": "2025-06-01T10:00:00Z", "creative_deadline": "2025-06-15T23:59:59Z", "revision": 1, "packages": [ { "package_id": "pkg_001", "product_id": "prod_ctv_sports" } ] } ``` The top-level `status` is the envelope task-status (TaskStatus) — `completed` on synchronous success. The body-level `media_buy_status` carries the buy's lifecycle state (`pending_creatives`, `pending_start`, `active`, or `paused`). A 3.0-style response would have collided these two enums at the same root key; in 3.1 they are distinct fields. Sellers MAY continue to emit a deprecated top-level `status: MediaBuyStatus` during the 3.1 deprecation window for back-compat with 3.0 buyers, but 3.1 buyers MUST prefer `media_buy_status`. See [Media-buy status field (3.1 migration)](#media-buy-status-field-3-1-migration) below. ### Long-Running (`submitted`) The task is queued for manual approval. Configure a webhook to receive updates. **Request with webhook:** ```javascript test=false theme={null} const response = await session.call('create_media_buy', { brand: { domain: 'acmecorp.com' }, packages: [ { product_id: 'prod_premium_ctv', pricing_option_id: 'cpm_fixed', budget: 500000 // Large budget triggers approval } ] }, { pushNotificationConfig: { url: 'https://your-app.com/webhooks/adcp', authentication: { schemes: ['bearer'], credentials: 'your_webhook_secret' } } } ); ``` **Initial response:** ```json theme={null} { "status": "submitted", "task_id": "task_abc123", "message": "Budget exceeds auto-approval limit. Sales review required (2-4 hours)." } ``` **Webhook POST when approved:** ```json theme={null} { "task_id": "task_abc123", "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-22T14:30:00Z", "message": "Media buy approved and created", "result": { "media_buy_id": "mb_67890", "account": { "account_id": "acc_acme_direct", "name": "Acme", "status": "active", "brand": { "domain": "acmecorp.com" }, "operator": "acmecorp.com" }, "confirmed_at": "2025-01-22T14:30:00Z", "creative_deadline": "2025-06-20T23:59:59Z", "revision": 1, "packages": [ { "package_id": "pkg_002", } ] } } ``` ### Error (`failed`) **Response:** ```json theme={null} { "status": "failed", "errors": [ { "code": "INSUFFICIENT_INVENTORY", "message": "Requested targeting yields no available impressions", "field": "packages[0].targeting", "suggestion": "Expand geographic targeting or increase CPM bid" } ] } ``` ### Immediate Success (`completed`) **Request:** ```javascript test=false theme={null} const response = await a2a.send({ message: { parts: [{ kind: 'data', data: { skill: 'create_media_buy', parameters: { brand: { domain: 'acmecorp.com' }, packages: [ { product_id: 'prod_ctv_sports', pricing_option_id: 'cpm_fixed', budget: 50000 } ] } } }] } }); ``` **Response:** ```json theme={null} { "status": "completed", "taskId": "task_123", "contextId": "ctx_456", "artifacts": [{ "parts": [ { "text": "Media buy created successfully" }, { "data": { "media_buy_id": "mb_12345", "confirmed_at": "2025-06-01T10:00:00Z", "creative_deadline": "2025-06-15T23:59:59Z", "revision": 1, "packages": [ { "package_id": "pkg_001", } ] } } ] }] } ``` ### Processing (`working`) Task is actively processing. Use SSE streaming or poll for updates. **Initial response:** ```json theme={null} { "status": "working", "taskId": "task_789", "contextId": "ctx_456" } ``` **SSE status update:** ```json theme={null} { "taskId": "task_789", "status": { "state": "working", "message": { "parts": [ { "text": "Validating inventory availability..." }, { "data": { "percentage": 50, "current_step": "inventory_check" } } ] } } } ``` ### Long-Running (`submitted`) **Request with push notification:** ```javascript test=false theme={null} const response = await a2a.send({ message: { parts: [{ kind: 'data', data: { skill: 'create_media_buy', parameters: { packages: [{ budget: 500000 }] // Triggers approval } } }] }, pushNotificationConfig: { url: 'https://your-app.com/webhooks/a2a', authentication: { schemes: ['bearer'], credentials: 'your_webhook_secret' } } }); ``` **Initial response:** ```json theme={null} { "status": "submitted", "taskId": "task_abc", "contextId": "ctx_456" } ``` **Webhook POST (Task) when completed:** ```json theme={null} { "id": "task_abc", "contextId": "ctx_456", "status": { "state": "completed", "message": { "parts": [ { "text": "Media buy approved and created" }, { "data": { "media_buy_id": "mb_67890", "packages": [{ "package_id": "pkg_002" }] } } ] }, "timestamp": "2025-01-22T14:30:00Z" } } ``` ### Input Required (`input-required`) Task is paused waiting for clarification or approval. **Response:** ```json theme={null} { "status": "input-required", "taskId": "task_def", "contextId": "ctx_456", "artifacts": [{ "parts": [ { "text": "The requested budget exceeds your pre-approved limit. Please confirm you want to proceed with $500K spend." }, { "data": { "reason": "APPROVAL_REQUIRED", "errors": [ { "code": "BUDGET_EXCEEDS_LIMIT", "message": "Requested budget exceeds pre-approved limit", "field": "total_budget" } ] } } ] }] } ``` **Follow-up to approve:** ```javascript test=false theme={null} await a2a.send({ contextId: 'ctx_456', // Continue the conversation message: { parts: [{ kind: 'text', text: 'Yes, I confirm the $500K budget' }] } }); ``` ### Error (`failed`) **Response:** ```json theme={null} { "status": "failed", "taskId": "task_xyz", "artifacts": [{ "parts": [ { "text": "Failed to create media buy" }, { "data": { "errors": [ { "code": "INSUFFICIENT_INVENTORY", "message": "Requested targeting yields no available impressions", "suggestion": "Expand geographic targeting" } ] } } ] }] } ``` For complete async handling patterns, see [Async Operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations). ## Usage Notes * Total budget is distributed across packages based on individual `budget` values * Creative assets must be uploaded before deadline for campaign activation * Impression-time targeting (audience, frequency, suitability) is handled by [TMP](/dist/docs/3.1.20/trusted-match) * Pending states (`working`, `submitted`) are normal, not errors * Orchestrators MUST handle pending states as part of normal workflow * **Inline creatives**: The `creatives` array creates or supplies package creatives inline. If the seller advertises `creative.has_creative_library: true`, inline creatives enter the library; use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) to update existing library creatives and `creative_assignments` to assign existing library creatives. If the seller advertises `inline_creative_management: true` without a creative library, use `packages[].creatives` on `create_media_buy` and `update_media_buy`; do not call `sync_creatives`. * **Inline creative lifecycle**: Library-backed inline creatives enter the library with the same lifecycle as `sync_creatives` uploads. Inline-only sellers may keep the creative package-scoped and do not advertise later reuse by `creative_id`. Creative review is independent of the buy outcome; sellers MUST NOT skip review solely because the buy did not activate. Retention of unassigned library creatives is seller-defined in 3.0. See [Inline creatives on the package](/dist/docs/3.1.20/creative/creative-libraries#path-2-inline-creatives-on-the-package). ## Content Standards When a media buy includes content standards (via the `governance.content_standards` field on `get_products` responses or the media buy request), the buyer is requesting brand suitability enforcement during delivery. Content standards are created by calling [`create_content_standards`](/dist/docs/3.1.20/governance/content-standards/tasks/create_content_standards) on a verification agent (e.g., IAS, DoubleVerify). Standards MUST be [calibrated](/dist/docs/3.1.20/governance/content-standards/tasks/calibrate_content) with each seller before use in production to ensure the seller's local evaluation model aligns with the verification agent's interpretation. See the [Content Standards overview](/dist/docs/3.1.20/governance/content-standards/index) for the full setup workflow: create → calibrate → activate → validate. ## Policy Compliance Brand and products are validated during creation. Policy violations return errors: ```json theme={null} { "errors": [{ "code": "POLICY_VIOLATION", "message": "Brand or product category not permitted on this publisher", "field": "brand", "suggestion": "Contact publisher for category approval process" }] } ``` Publishers should ensure: * Brand/products align with selected packages * Creatives match declared brand/products * Campaign complies with all advertising policies ## Next Steps After creating a media buy: 1. **Supply creatives**: Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for library-backed sellers, or `packages[].creatives` on [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) for inline-only sellers 2. **Monitor Status**: Use [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) 3. **Optimize**: Use [`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) 4. **Update**: Use [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) to modify campaign ## Media-buy status field (3.1 migration) 3.1 splits two enums that 3.0 collided at the same root key — envelope `status` (TaskStatus) at the top of every response, and body `media_buy_status` (MediaBuyStatus, **new in 3.1**) carrying the buy's lifecycle state alongside. The legacy top-level `status: MediaBuyStatus` form is `deprecated: true` in 3.1 and removed in 3.2 ([#4906](https://github.com/adcontextprotocol/adcp/issues/4906)); nested `status` on `get_media_buys`, `get_media_buy_delivery`, and `core/media-buy.json` follow in 4.0 ([#4905](https://github.com/adcontextprotocol/adcp/issues/4905)). 3.1 buyers MUST prefer `media_buy_status` when present. 3.1 compliance storyboards assert `path: "media_buy_status"` — a 3.1 seller emitting only the legacy `status` is schema-valid but fails certification; the storyboard is the binding conformance check. Full migration: [Migration › `media_buy_status`](/dist/docs/3.1.20/reference/migration/media-buy-status). ## Learn More * [Media Buy Lifecycle](/dist/docs/3.1.20/media-buy/media-buys/) - Complete campaign workflow * [get\_products](/dist/docs/3.1.20/media-buy/task-reference/get_products) - Discover inventory * [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) - Targeting strategies * [Pricing Models](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models) - Currency and pricing # get_media_buy_delivery Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery get_media_buy_delivery task — retrieve impressions, spend, pacing, and dimensional breakdowns for active AdCP campaigns. Supports custom date ranges and metric filtering. Retrieve comprehensive delivery metrics and performance data for media buy reporting. **Response Time**: \~60 seconds (reporting query) ## Scope `get_media_buy_delivery` works on any `media_buy_id` returned by [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys), regardless of how the underlying campaign was created. Sales agents MUST NOT refuse delivery reporting — or narrow its coverage — on the basis that the buy originated outside AdCP. If delivery data for a buy is genuinely unavailable (e.g., the ad server has not yet reported a flight), the seller returns the buy in `media_buy_deliveries` with zero or partial metrics; the seller does not omit it and does not return `MEDIA_BUY_NOT_FOUND` for an account-owned buy. **Request Schema**: [`/schemas/3.1.20/media-buy/get-media-buy-delivery-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buy-delivery-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/get-media-buy-delivery-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buy-delivery-response.json) ## Request Parameters | Parameter | Type | Required | Description | | -------------------------- | -------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | No | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Only returns media buys belonging to this account. When omitted, returns data across all accessible accounts. | | `media_buy_ids` | string\[] | No\* | Array of media buy IDs to retrieve | | `status_filter` | string \| string\[] | No | Status filter: `"pending_creatives"`, `"pending_start"`, `"active"`, `"paused"`, `"completed"`. Defaults to `["active"]` when omitted. | | `start_date` | string | No | Report start date (YYYY-MM-DD), inclusive. Omit for campaign lifetime data. Only accepted when product supports `date_range`. | | `end_date` | string | No | Report end date (YYYY-MM-DD), **exclusive**. Omit for campaign lifetime data. Only accepted when product supports `date_range`. | | `reporting_dimensions` | object | No | Request dimensional breakdowns within `by_package`. Include a key as an empty object (e.g., `"device_type": {}`) to activate with defaults. Keys: `geo`, `device_type`, `device_platform`, `audience`, `placement`. Each accepts optional `limit` (defaults to 25 for geo, audience, placement) and `sort_by` (sort-metric enum, default: `spend`). Geo requires `geo_level` (one per request); include `system` for metro/postal levels when requesting a specific system. Unsupported dimensions are silently omitted; malformed requests return a validation error. | | `time_granularity` | string | No | Per-window slice granularity for pull recovery, matching `reporting_webhook.reporting_frequency` vocabulary (`hourly`, `daily`, `monthly`). When set, the response includes `windows[]` slices shape-aligned with webhook fires at the same granularity. Capability-scoped — value MUST be in the product's `reporting_capabilities.windowed_pull_granularities`. See [Windowed pull recovery](#windowed-pull-recovery). | | `include_window_breakdown` | boolean | No | When `true` (and `time_granularity` is set), include the `windows[]` array on each media buy. Defaults to `false`. Ignored when `time_granularity` is omitted. | > **Date Range Behavior**: The date range is **start-inclusive, end-exclusive**. For example, `start_date: "2026-01-01"` and `end_date: "2026-01-02"` returns delivery data for January 1st only (from `2026-01-01 00:00:00` up to, but not including, `2026-01-02 00:00:00`). To get a full week of data (Jan 1-7), use `end_date: "2026-01-08"`. **Date Range Examples**: | start\_date | end\_date | Data Returned | | ------------ | ------------ | -------------------------------- | | `2026-01-01` | `2026-01-02` | January 1st only (1 day) | | `2026-01-01` | `2026-01-08` | January 1st through 7th (7 days) | | `2026-01-01` | `2026-02-01` | Full month of January (31 days) | | `2026-01-15` | `2026-01-16` | January 15th only (1 day) | \*`media_buy_ids` filters results to specific media buys. If neither provided, returns all media buys in current session context. ## Response Returns delivery report with aggregated totals and per-media-buy breakdowns: | Field | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `reporting_period` | Date range for report (start/end timestamps) | | `currency` | ISO 4217 currency code (USD, EUR, GBP, etc.) | | `attribution_window` | Attribution methodology: `post_click` and `post_view` (duration objects), and `model` (last\_touch, first\_touch, linear, time\_decay, data\_driven) | | `aggregated_totals` | Combined metrics across all media buys (impressions, spend, clicks, views, completed\_views, conversions, conversion\_value, roas, new\_to\_brand\_rate, cost\_per\_acquisition, completion\_rate, reach, reach\_unit, frequency, media\_buy\_count, metric\_aggregates) | | `media_buy_deliveries` | Array of delivery data per media buy | ### Media Buy Delivery Object **3.1 vocabulary note.** `get_media_buy_delivery` returns the lifecycle state on a nested `media_buy_deliveries[].status` field (no envelope collision — nested at depth 1). `create_media_buy` and `update_media_buy` success responses return the same lifecycle state on a top-level **`media_buy_status`** field (added in 3.1 to avoid colliding with the envelope task-status `status`). Same enum, two field names in 3.1 — the cascade unifies in 4.0 ([#4905](https://github.com/adcontextprotocol/adcp/issues/4905)). See [Migration › `media_buy_status`](/dist/docs/3.1.20/reference/migration/media-buy-status) for the full picture. | Field | Description | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy_id` | Media buy identifier | | `status` | Current status (`pending_creatives`, `pending_start`, `active`, `paused`, `completed`). In webhook context, may also be `reporting_delayed` or `failed`. Maps to `media_buy_status` on `create_media_buy` / `update_media_buy` success responses (3.1 vocabulary note above). | | `totals` | Aggregate metrics (impressions, spend, clicks, ctr, conversions, conversion\_value, roas, new\_to\_brand\_rate) | | `by_package` | Package-level breakdowns with delivery\_status, paused state, and pacing\_index | | `daily_breakdown` | Day-by-day delivery (date, impressions, spend, conversions, conversion\_value, roas, new\_to\_brand\_rate) | See [schema](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buy-delivery-response.json) for complete field list. ### Final vs provisional numbers A delivery row is either **final for its measurement window** or it isn't. Final means the seller considers these numbers closed for the period — no further revision — and is willing to invoice on them, subject to whatever `measurement_terms.billing_measurement` the buy was created with. Anything else is provisional: still settling as measurement matures (broadcast C3 → C7 DVR accumulation, post-IVT scrubbing, conversion dedup) and **not** an invoicing source of truth. Per-row signals: * `media_buy_deliveries[*].is_final` and `media_buy_deliveries[*].finalized_at` — row-level finality, true iff every package in the row is final for the same measurement window. * `media_buy_deliveries[*].by_package[*].is_final` and `.finalized_at` — package-level finality with the exact timestamp. * `media_buy_deliveries[*].by_package[*].measurement_window` — which maturation stage the numbers represent (`c3`, `c7`, `post_sivt`, `downloads_30d`, …). Callers using rows where `is_final` is false (or absent) for pacing and reporting are safe; callers using them for reconciliation, accruals, or finance close are not. ### Who is authoritative for billing Whose number invoices the buy is a **contract term**, declared in the buy's [`measurement_terms.billing_measurement`](/dist/docs/3.1.20/media-buy/advanced-topics/billing-authority): * **Seller-attested** (the default when `billing_measurement` is absent or names the seller's own ad server): invoice off final rows on `get_media_buy_delivery`. * **Vendor-attested** (third-party measurement vendor named in the buy — e.g., Nielsen, IAS, DV, MOAT): invoice off the named vendor's authoritative numbers. Operationally this is most often the seller pulling from the vendor and publishing on `get_media_buy_delivery` with `is_final: true`; when the buyer holds the vendor relationship instead, the buyer pushes via [`report_usage`](/dist/docs/3.1.20/accounts/tasks/report_usage) with `final: true` and `finalized_at` set. * **Buyer-attested** (buyer's 3PAS or MMP named in the buy — e.g., CM360, Flashtalking): invoice off the buyer's final records pushed via `report_usage`. When the authoritative party doesn't publish final numbers within `measurement_terms.billing_measurement.finalization_deadline_hours`, the counterparty MAY fall back to its own attestation; the breach is handled under `makegood_policy`. The deadline applies symmetrically to whichever party is named in `vendor`. When variance between parties exceeds `max_variance_percent`, parties resolve via the buy's [`makegood_policy.available_remedies`](/dist/docs/3.1.20/media-buy/advanced-topics/accountability) and out-of-band negotiation. See [Billing authority](/dist/docs/3.1.20/media-buy/advanced-topics/billing-authority) for the end-to-end flow. A structured dispute task — opening, transitioning, and resolving a delivery dispute on the wire — is targeted for AdCP 3.2. ### Aggregated metric partitions (`metric_aggregates`) Cross-buy delivery values that vary by **qualifier** (measurement standard, transparency disclosure) are reported as a partitioned array on `aggregated_totals.metric_aggregates` rather than as flat scalars. This solves the apples-to-oranges sum problem at the aggregate layer: MRC and GroupM viewability define materially different thresholds and must never be combined into a single rate. Each row carries the same atomic unit as `package.committed_metrics` and `by_package[].missing_metrics`: ``` committed_metrics row : { scope, metric_id, qualifier, committed_at } missing_metrics row : { scope, metric_id, qualifier } metric_aggregates row : { scope, metric_id, qualifier, value, ...components } ``` **Reconciliation is a row-level join on `(scope, metric_id, qualifier)`.** For each `committed_metrics` row, find the matching `metric_aggregates` row; absent matches surface as `missing_metrics`. No per-metric reconciliation logic, no traversal asymmetry between contract and delivery. **Granularity rule.** One row per `(metric_id, full-qualifier-set)`, reported at the finest available granularity. Buyers re-aggregate up if they want a coarser view. This eliminates rollup ambiguity and prevents accidental double-counting. **Unqualified metrics stay top-level.** `impressions`, `spend`, `media_buy_count`, and other metrics with no qualifier remain at the top of `aggregated_totals`. `metric_aggregates` is only used for metrics with non-empty qualifier sets. **Mutual exclusion (MUST).** For any `metric_id` appearing in `metric_aggregates`, the corresponding top-level scalar in `aggregated_totals` MUST be omitted — not zeroed. Sellers MUST NOT emit both. Avoids duplicate sources of truth. **Qualifier vocabulary** is closed today on both `committed_metrics` and `metric_aggregates` (`additionalProperties: false`). Five keys exist: `viewability_standard` (MRC vs GroupM viewability), `completion_source` (seller- vs vendor-attested completion), `attribution_methodology` (deterministic\_purchase / probabilistic / panel\_based / modeled — for outcome metrics), `attribution_window` (structured duration — for outcome metrics), and `lift_dimension` (awareness / consideration / favorability / purchase\_intent / ad\_recall — for `brand_lift`). The delivery vocabulary is **expected to diverge from contract** in future minors as transparency disclosures buyers don't commit to ship delivery-only (e.g., `tracker_firing` pending #3832). New qualifier keys ship explicitly in subsequent minors on either surface. **Heterogeneous value types**: qualifier values are mostly string enums but `attribution_window` is an object-valued duration; consumers MUST dispatch on key name to know value shape, and structured-value qualifiers join on canonical (key-sorted) deep equality. **Qualifier-set drift across reports.** When a campaign gains a new qualifier mid-flight (e.g., adds `tracker_firing` partitioning in week 2 after only client-side firing in week 1), prior periods' rows remain valid at their original granularity. Buyers SHOULD NOT retroactively repartition; report supersession via `supersedes_window` is the documented path for window-level revisions. **Per-buy `totals` shape stays flat.** Each individual buy is single-qualifier by definition; only the cross-buy aggregate spans qualifiers and needs the partitioned shape. Per-buy `totals.viewability` continues to be a flat object with its own `standard` field. Example: ```json theme={null} { "aggregated_totals": { "impressions": 1000000, "spend": 5000.00, "media_buy_count": 3, "metric_aggregates": [ { "scope": "standard", "metric_id": "viewable_rate", "qualifier": { "viewability_standard": "mrc" }, "value": 0.7286, "measurable_impressions": 700000, "viewable_impressions": 510000 }, { "scope": "standard", "metric_id": "viewable_rate", "qualifier": { "viewability_standard": "groupm" }, "value": 0.55, "measurable_impressions": 180000, "viewable_impressions": 99000 } ] } } ``` **Value typing dispatch.** Buyer agents MUST inspect `metric_id` before doing arithmetic. Rate metrics (`viewable_rate`, `completion_rate`) are 0.0–1.0; cost-per metrics are currency; count metrics are non-negative numbers; ROAS is a ratio. Same dispatch convention as `committed_metrics` — `metric_id` is the type tag. ## Common Scenarios ### Single Media Buy ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuyDeliveryResponseSchema } from '@adcp/sdk'; // Get single media buy delivery report const result = await testAgent.getMediaBuyDelivery({ media_buy_ids: ['mb_12345'], start_date: '2024-02-01', end_date: '2024-02-07' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuyDeliveryResponseSchema.parse(result.data); // Check for errors (discriminated union response) if ('errors' in validated && validated.errors) { throw new Error(`Query failed: ${JSON.stringify(validated.errors)}`); } console.log(`Delivered ${validated.aggregated_totals.impressions.toLocaleString()} impressions`); console.log(`Spend: $${validated.aggregated_totals.spend.toFixed(2)}`); if (validated.media_buy_deliveries.length > 0) { console.log(`CTR: ${(validated.media_buy_deliveries[0].totals.ctr * 100).toFixed(2)}%`); } ``` ```python Python integration=true theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuyDeliveryRequest async def main(): # Get single media buy delivery report result = await test_agent.get_media_buy_delivery( GetMediaBuyDeliveryRequest( media_buy_ids=['mb_12345'], start_date='2024-02-01', end_date='2024-02-07' ) ) # Check for errors (discriminated union response) if hasattr(result, 'errors') and result.errors: raise Exception(f"Query failed: {result.errors}") print(f"Delivered {result.aggregated_totals.impressions:,} impressions") print(f"Spend: ${result.aggregated_totals.spend:.2f}") if result.media_buy_deliveries: print(f"CTR: {result.media_buy_deliveries[0].totals.ctr * 100:.2f}%") asyncio.run(main()) ``` ### Multiple Media Buys ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuyDeliveryResponseSchema } from '@adcp/sdk'; // Get all active media buys from context const result = await testAgent.getMediaBuyDelivery({ status_filter: 'active', start_date: '2024-02-01', end_date: '2024-02-07' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuyDeliveryResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Query failed: ${JSON.stringify(validated.errors)}`); } console.log(`${validated.aggregated_totals.media_buy_count} active campaigns`); console.log(`Total impressions: ${validated.aggregated_totals.impressions.toLocaleString()}`); console.log(`Total spend: $${validated.aggregated_totals.spend.toFixed(2)}`); // Review each campaign validated.media_buy_deliveries.forEach(delivery => { console.log(`${delivery.media_buy_id}: ${delivery.totals.impressions.toLocaleString()} impressions, CTR ${(delivery.totals.ctr * 100).toFixed(2)}%`); }); ``` ```python Python test=false theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuyDeliveryRequest async def main(): # Get all active media buys from context result = await test_agent.get_media_buy_delivery( GetMediaBuyDeliveryRequest( status_filter='active', start_date='2024-02-01', end_date='2024-02-07' ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Query failed: {result.errors}") print(f"{result.aggregated_totals.media_buy_count} active campaigns") print(f"Total impressions: {result.aggregated_totals.impressions:,}") print(f"Total spend: ${result.aggregated_totals.spend:.2f}") # Review each campaign for delivery in result.media_buy_deliveries: print(f"{delivery.media_buy_id}: {delivery.totals.impressions:,} impressions, CTR {delivery.totals.ctr * 100:.2f}%") asyncio.run(main()) ``` ### Date Range Reporting ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuyDeliveryResponseSchema } from '@adcp/sdk'; // Get month-to-date performance const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const dateFormat = date => date.toISOString().split('T')[0]; const result = await testAgent.getMediaBuyDelivery({ media_buy_ids: ['mb_12345'], start_date: dateFormat(monthStart), end_date: dateFormat(now) }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuyDeliveryResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Query failed: ${JSON.stringify(validated.errors)}`); } if (validated.media_buy_deliveries.length > 0) { // Analyze daily breakdown const dailyBreakdown = validated.media_buy_deliveries[0].daily_breakdown; if (dailyBreakdown && dailyBreakdown.length > 0) { console.log(`Daily average: ${Math.round(validated.aggregated_totals.impressions / dailyBreakdown.length).toLocaleString()} impressions`); // Find peak day const peakDay = dailyBreakdown.reduce((max, day) => day.impressions > max.impressions ? day : max ); console.log(`Peak day: ${peakDay.date} with ${peakDay.impressions.toLocaleString()} impressions`); } } ``` ```python Python test=false theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuyDeliveryRequest from datetime import date async def main(): # Get month-to-date performance today = date.today() month_start = date(today.year, today.month, 1) result = await test_agent.get_media_buy_delivery( GetMediaBuyDeliveryRequest( media_buy_ids=['mb_12345'], start_date=str(month_start), end_date=str(today) ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Query failed: {result.errors}") if result.media_buy_deliveries: # Analyze daily breakdown daily_breakdown = result.media_buy_deliveries[0].daily_breakdown if daily_breakdown: daily_avg = result.aggregated_totals.impressions // len(daily_breakdown) print(f"Daily average: {daily_avg:,} impressions") # Find peak day peak_day = max(daily_breakdown, key=lambda d: d.impressions) print(f"Peak day: {peak_day.date} with {peak_day.impressions:,} impressions") asyncio.run(main()) ``` ### Multi-Status Query ```javascript JavaScript integration=true theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuyDeliveryResponseSchema } from '@adcp/sdk'; // Get both active and paused campaigns const result = await testAgent.getMediaBuyDelivery({ status_filter: ['active', 'paused'], start_date: '2024-02-01', end_date: '2024-02-07' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuyDeliveryResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Query failed: ${JSON.stringify(validated.errors)}`); } // Group by status const byStatus = validated.media_buy_deliveries.reduce((acc, delivery) => { if (!acc[delivery.status]) acc[delivery.status] = []; acc[delivery.status].push(delivery); return acc; }, {}); console.log(`Active campaigns: ${byStatus.active?.length || 0}`); console.log(`Paused campaigns: ${byStatus.paused?.length || 0}`); // Identify underperforming campaigns byStatus.paused?.forEach(delivery => { if (delivery.by_package && delivery.by_package.length > 0) { const avgPacing = delivery.by_package.reduce((sum, pkg) => sum + pkg.pacing_index, 0) / delivery.by_package.length; console.log(`${delivery.media_buy_id}: paused with ${(avgPacing * 100).toFixed(0)}% pacing`); } }); ``` ```python Python integration=true theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuyDeliveryRequest from collections import defaultdict async def main(): # Get both active and paused campaigns result = await test_agent.get_media_buy_delivery( GetMediaBuyDeliveryRequest( status_filter=['active', 'paused'], start_date='2024-02-01', end_date='2024-02-07' ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Query failed: {result.errors}") # Group by status by_status = defaultdict(list) for delivery in result.media_buy_deliveries: by_status[delivery.status].append(delivery) print(f"Active campaigns: {len(by_status['active'])}") print(f"Paused campaigns: {len(by_status['paused'])}") # Identify underperforming campaigns for delivery in by_status['paused']: if delivery.by_package: avg_pacing = sum(pkg.pacing_index for pkg in delivery.by_package) / len(delivery.by_package) print(f"{delivery.media_buy_id}: paused with {avg_pacing * 100:.0f}% pacing") asyncio.run(main()) ``` ### Correlating Your Own Reference **`media_buy_ids` are seller-assigned.** They are the `media_buy_id` values returned by [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) and [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) — not buyer-side identifiers. AdCP has no buyer-reference lookup key: the top-level `buyer_ref` field was removed in 3.0.0, making seller-assigned `media_buy_id`/`package_id` canonical. If you hold only your own identifier, include it in the opaque `context` object (commonly `context.buyer_ref`) when you call `create_media_buy`; the seller echoes `context` back on `create_media_buy` and [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys), so you can reconcile your reference to the seller's `media_buy_id` before requesting delivery. ```javascript JavaScript integration=true theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuyDeliveryResponseSchema } from '@adcp/sdk'; // Compare several media buys by their seller-assigned IDs (from create_media_buy / get_media_buys) const result = await testAgent.getMediaBuyDelivery({ media_buy_ids: ['mb_q1_campaign_2024', 'mb_q1_retargeting_2024'] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuyDeliveryResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Query failed: ${JSON.stringify(validated.errors)}`); } // Lifetime delivery data (no date range specified) console.log(`Total lifetime impressions: ${validated.aggregated_totals.impressions.toLocaleString()}`); console.log(`Total lifetime spend: $${validated.aggregated_totals.spend.toFixed(2)}`); // Compare campaigns validated.media_buy_deliveries.forEach(delivery => { if (delivery.totals.impressions > 0) { const cpm = (delivery.totals.spend / delivery.totals.impressions) * 1000; console.log(`${delivery.media_buy_id}: CPM $${cpm.toFixed(2)}, CTR ${(delivery.totals.ctr * 100).toFixed(2)}%`); } }); ``` ```python Python integration=true theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuyDeliveryRequest async def main(): # Compare several media buys by their seller-assigned IDs (from create_media_buy / get_media_buys) result = await test_agent.get_media_buy_delivery( GetMediaBuyDeliveryRequest( media_buy_ids=['mb_q1_campaign_2024', 'mb_q1_retargeting_2024'] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Query failed: {result.errors}") # Lifetime delivery data (no date range specified) print(f"Total lifetime impressions: {result.aggregated_totals.impressions:,}") print(f"Total lifetime spend: ${result.aggregated_totals.spend:.2f}") # Compare campaigns for delivery in result.media_buy_deliveries: if delivery.totals.impressions > 0: cpm = (delivery.totals.spend / delivery.totals.impressions) * 1000 print(f"{delivery.media_buy_id}: CPM ${cpm:.2f}, CTR {delivery.totals.ctr * 100:.2f}%") asyncio.run(main()) ``` ### Account-Scoped Query ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuyDeliveryResponseSchema } from '@adcp/sdk'; // Get delivery for a specific advertiser account const result = await testAgent.getMediaBuyDelivery({ account: { account_id: 'acc_acme_pinnacle' }, status_filter: 'active', start_date: '2024-02-01', end_date: '2024-02-07' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuyDeliveryResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Query failed: ${JSON.stringify(validated.errors)}`); } console.log(`${validated.aggregated_totals.media_buy_count} campaigns for account`); console.log(`Total spend: $${validated.aggregated_totals.spend.toFixed(2)}`); ``` ```python Python test=false theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuyDeliveryRequest async def main(): # Get delivery for a specific advertiser account result = await test_agent.get_media_buy_delivery( GetMediaBuyDeliveryRequest( account={'account_id': 'acc_acme_pinnacle'}, status_filter='active', start_date='2024-02-01', end_date='2024-02-07' ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Query failed: {result.errors}") print(f"{result.aggregated_totals.media_buy_count} campaigns for account") print(f"Total spend: ${result.aggregated_totals.spend:.2f}") asyncio.run(main()) ``` ## Metrics Definitions | Metric | Definition | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Impressions** | Number of times ads were displayed | | **Spend** | Amount spent in specified currency | | **Clicks** | Number of ad clicks (if available) | | **CTR** | Click-through rate (clicks/impressions) | | **Views** | Content engagements at the billable view threshold — video views, audio/podcast stream starts, or format-specific view events | | **Completed Views** | Audio/video completions (at threshold or 100%) | | **Completion Rate** | Completion rate (completed\_views/impressions) | | **Conversions** | Attributed conversions (purchases, new listeners, app installs, etc.) | | **Conversion Value** | Total monetary value of attributed conversions | | **ROAS** | Return on ad spend (conversion\_value / spend) | | **New-to-Brand Rate** | Fraction of conversions from first-time brand buyers (0-1) | | **Cost per Acquisition** | Cost per conversion (spend / conversions) | | **Reach** | Unique users reached (see `reach_unit` for measurement unit: individuals, households, devices, accounts, cookies). Measurement window declared via `reach_window`; without it, buyers must not sum reach across rows. | | **Reach Unit** | Unit of measurement for reach — required when reach is present | | **Reach Window** | Window semantics for reported reach/frequency: `cumulative` (uniques since campaign start), `period` (uniques within a single non-overlapping reporting period — e.g., daily snapshot), or `rolling` (uniques within a trailing window — e.g., trailing-7-day). Never sum across rows. Optional but strongly recommended when reach is present. | | **Frequency** | Average ad exposures per reach unit, measured over `reach_window` | | **Viewability** | Object with `vendor`, `measurable_impressions` (denominator), `viewable_impressions`, `viewable_rate`, `viewed_seconds` (average in-view duration per measurable impression — pairs with the `viewed_seconds` optimization goal), and `standard` | | **Follows** | New followers, page likes, or free channel/feed subscribes attributed to delivery | | **Pacing Index** | Actual vs. expected delivery rate (1.0 = on track, \<1.0 = behind, >1.0 = ahead) | | **CPM** | Cost per thousand impressions (spend/impressions \* 1000) | ## Query Behavior ### Context-Based Queries * If no `media_buy_ids` provided, returns all media buys from current session context * Context established by previous operations (e.g., `create_media_buy`) ### Status Filtering * Defaults to `["active"]` if not specified * Can be single string (`"active"`) or array (`["active", "paused"]`) * Valid filter values are media-buy lifecycle statuses: `pending_creatives`, `pending_start`, `active`, `paused`, `completed` * `reporting_delayed` and `failed` are delivery/reporting statuses returned in webhook contexts, not request filter values * Some legacy integrations may emit `pending`; treat it as equivalent to `pending_start` ### Date Ranges * If dates not specified, returns campaign lifetime delivery data * Both `start_date` and `end_date` must be provided together — partial date ranges are invalid * Date format: `YYYY-MM-DD` * **Start-inclusive, end-exclusive**: `start_date` is included, `end_date` is excluded. For example, `start_date: "2026-01-01"` and `end_date: "2026-01-02"` returns data for January 1st only. * Products declare date range support in `reporting_capabilities.date_range_support` * Products with `date_range_support: "lifetime_only"` reject requests that include `start_date`/`end_date` with a `DATE_RANGE_NOT_SUPPORTED` error * Products with `date_range_support: "date_range"` accept date parameters and filter delivery data accordingly * Daily breakdown may be truncated for long date ranges to reduce response size ### Metric Availability * **Universal**: Impressions, spend (available on all platforms) * **Format-dependent**: Clicks, completed\_views, completion\_rate (depends on inventory type and platform capabilities) * **Audience**: Reach, frequency (available on platforms with deduplicated measurement) * **Commerce attribution**: Conversions, conversion\_value, roas, new\_to\_brand\_rate (available on commerce media and streaming platforms) * **Engagement**: Follows, saves, engagements, profile\_visits (available on social and streaming platforms) * **Attribution window**: `attribution_window` describes the lookback windows and model used for conversion attribution (e.g., 14-day click, 1-day view, last\_touch) * **Package-level**: All metrics broken down by package with pacing\_index ## Data Freshness * Reporting data typically has 2-4 hour delay * Real-time impression counts not available * Use for periodic reporting and optimization decisions, not live monitoring **Phased-maturation channels**: Data freshness differs for channels where billing-grade data is produced in phases rather than arriving final on day one — broadcast TV (Live → C3 → C7 DVR accumulation, final C7 \~15–22 days after broadcast), DOOH (tentative plays → post-IVT/fraud-check final), digital with IVT filtering (raw → post-GIVT → post-SIVT), and podcast (7-day → 30-day downloads). Products with `reporting_capabilities.measurement_windows` declare these timelines. Buyers reconcile against the `measurement_window` specified in `billing_measurement` on the agreed terms. See [Accountability](/dist/docs/3.1.20/media-buy/advanced-topics/accountability) for measurement terms and [Optimization and reporting](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) for the full lifecycle. ## Error Handling | Error Code | Description | Resolution | | -------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AUTH_MISSING` | No credentials presented | Provide credentials via auth header | | `AUTH_INVALID` | Credentials rejected (expired / revoked) | Human credential rotation required; do not auto-retry | | `MEDIA_BUY_NOT_FOUND` | Media buy doesn't exist | Verify `media_buy_id`; for legacy correlation use `get_media_buys` + `context.internal_campaign_id` | | `INVALID_DATE_RANGE` | Invalid start/end dates | Use YYYY-MM-DD format, ensure start \< end | | `DATE_RANGE_NOT_SUPPORTED` | Product only supports lifetime reporting | Omit `start_date` and `end_date`. Check `reporting_capabilities.date_range_support` on the product. | | `UNSUPPORTED_GRANULARITY` | Requested `time_granularity` is not in the product's `windowed_pull_granularities` | Re-issue with a granularity from `error.details.supported_granularities`, or omit `time_granularity` to fall back to cumulative date-range pulls. See [Windowed pull recovery](#windowed-pull-recovery). | | `CONTEXT_REQUIRED` | No media buys in context | Provide media\_buy\_ids explicitly | | `INVALID_STATUS_FILTER` | Invalid status value | Use valid status: pending\_creatives, pending\_start, active, paused, completed | ## Package-Level Metrics The `by_package` array provides per-package delivery details with these key fields: **Buyer Control**: * **`paused`**: Whether the package is currently paused by the buyer (true/false) **System State**: * **`delivery_status`**: System-reported operational state: * `delivering` - Package is actively delivering impressions * `completed` - Package finished successfully * `budget_exhausted` - Package ran out of budget * `flight_ended` - Package reached its end date * `goal_met` - Package achieved its impression/conversion goal **Performance**: * **`pacing_index`**: Delivery pace (1.0 = on track, below 1.0 = behind, above 1.0 = ahead) * **`rate`**: Effective pricing rate (e.g., CPM) * **`pricing_model`**: How the package is billed (cpm, cpcv, cpp, etc.) **Accountability**: * **`missing_metrics`**: Metrics the binding reporting contract advertised but that are not populated in this report. Each entry uses an explicit `scope` discriminator: `{ "scope": "standard", "metric_id": "completed_views" }` for entries from the closed `available-metric.json` enum, `{ "scope": "vendor", "vendor": { "domain": "..." }, "metric_id": "attention_units" }` for vendor-defined metrics. Standard entries MAY carry a `qualifier` mirroring the `committed_metrics` qualifier (e.g., `{ "scope": "standard", "metric_id": "viewable_rate", "qualifier": { "viewability_standard": "mrc" } }` flags a missing MRC commitment even when GroupM viewability was reported, or `{ "scope": "standard", "metric_id": "completion_rate", "qualifier": { "completion_source": "vendor_attested" } }` flags a missing vendor-attested commitment even when seller-attested completion was reported — the paths are not interchangeable). Reconciled against `package.committed_metrics` (filtered to entries where `committed_at < reporting_period.end`) when present; falls back to the product's current `reporting_capabilities.available_metrics` and `vendor_metrics` when absent. Empty array (or absent) indicates clean delivery against the contract; non-empty signals an accountability breach. Sellers MUST exclude metrics that are not yet measurable for the current `measurement_window` (e.g., post-IVT counts during the live window) — those will appear (or not) when a wider window supersedes this report via `supersedes_window`. * **`vendor_metric_values`**: Reported values for vendor-defined metrics that the product's `reporting_capabilities.vendor_metrics` declared (proprietary attention, emissions, panel demographics, brand-lift surveys, etc.). Each entry carries `{ vendor, metric_id, value, unit?, measurable_impressions?, breakdown? }`. The `measurable_impressions` field is the coverage denominator — vendor measurement is rarely 100% of delivery, since vendors only score impressions where their SDK fires or their panel matches. Buyers compute coverage as `measurable_impressions / impressions`. When `measurable_impressions` is absent, coverage is unspecified — buyers MUST NOT compute a coverage rate or assume full coverage. When a declared vendor metric is omitted entirely from this array, infer no measurement happened (no integration). JIC or panel-based co-viewing adjustments, claim reconciliation, confidence intervals, and panel sizes belong here, usually in `breakdown`, rather than in buy-time signal targeting definitions. **Key Distinction**: `paused` reflects buyer control, while `delivery_status` reflects system reality. A package can be not paused but have `delivery_status: "budget_exhausted"`. ## Creative-Level Metrics When the seller supports creative-level reporting (`supports_creative_breakdown` in reporting capabilities), each package includes a `by_creative` array with per-creative delivery metrics. Each creative entry includes: * **`creative_id`**: Creative identifier matching the creative assignment * **`weight`**: Delivery weight for this creative during the reporting period (0-100) * All standard delivery metrics (impressions, spend, clicks, ctr, etc.) ```json theme={null} { "by_package": [ { "package_id": "pkg_001", "spend": 5000, "impressions": 100000, "pricing_model": "cpm", "rate": 50, "currency": "USD", "delivery_status": "delivering", "by_creative": [ { "creative_id": "hero_video_30s", "weight": 60, "impressions": 60000, "spend": 3000, "clicks": 3000, "ctr": 0.05, "completion_rate": 0.72 }, { "creative_id": "hero_video_15s", "weight": 40, "impressions": 40000, "spend": 2000, "clicks": 1200, "ctr": 0.03, "completion_rate": 0.85 } ] } ] } ``` For deeper creative analytics including variant-level delivery data (asset combination optimization, generative creative), use [`get_creative_delivery`](/dist/docs/3.1.20/creative/task-reference/get_creative_delivery). This is a Creative Protocol task — call it on any agent that implements the Creative Protocol, which may be the same sales agent if it declares `"creative"` in `supported_protocols`. See [Creative capabilities on sales agents](/dist/docs/3.1.20/creative/sales-agent-creative-capabilities). ## Catalog-item reporting For catalog-driven packages (packages with a `catalog` field), the seller can return per-catalog-item delivery in the `by_catalog_item` array within each package. Each entry identifies the catalog item and includes standard delivery metrics: | Field | Description | | ----------------- | ------------------------------------------------------------------------------------------ | | `content_id` | The item identifier (SKU, GTIN, job ID, etc.) | | `content_id_type` | Identifier type (`sku`, `gtin`, `job_id`, etc.) matching the catalog's `content_id_type` | | Standard metrics | `impressions`, `spend`, `clicks`, `ctr`, `conversions`, `roas`, and other delivery metrics | This is optional. Sellers that support item-level reporting populate `by_catalog_item`; sellers that do not simply omit it. ```json theme={null} { "by_package": [ { "package_id": "pkg_001", "spend": 5000, "impressions": 100000, "pricing_model": "cpc", "rate": 1.20, "currency": "USD", "delivery_status": "delivering", "by_catalog_item": [ { "content_id": "SKU-12345", "content_id_type": "sku", "impressions": 45000, "spend": 2250, "clicks": 1800, "ctr": 0.04, "conversions": 90, "roas": 4.2 }, { "content_id": "SKU-67890", "content_id_type": "sku", "impressions": 55000, "spend": 2750, "clicks": 2200, "ctr": 0.04, "conversions": 110, "roas": 3.8 } ] } ] } ``` ## Windowed pull recovery `reporting_webhook` fires at the buyer's chosen `reporting_frequency` (hourly, daily, monthly). When a receiver is offline long enough for transport retries to expire, the buyer loses per-window detail unless GET can reproduce the same slices. `time_granularity` + `include_window_breakdown` close that gap. ### Capability check Sellers declare which granularities they honor for pull recovery via `reporting_capabilities.windowed_pull_granularities`. Buyers MUST check the capability before requesting `time_granularity`: ```json test=false theme={null} { "reporting_capabilities": { "available_reporting_frequencies": ["hourly", "daily"], "windowed_pull_granularities": ["daily"] } } ``` The seller in this example emits hourly webhooks but only honors daily pulls — common in stream-tap architectures where the webhook is a Kafka tap and historical pulls go through a warehouse. Two-paths-parity holds at the **declared** set; for hourly recovery, the webhook is primary. Sellers that want full parity declare every frequency they fire. ### Requesting windowed slices ```json test=false theme={null} { "media_buy_ids": ["mb_12345"], "start_date": "2026-06-01", "end_date": "2026-06-02", "time_granularity": "hourly", "include_window_breakdown": true } ``` ### Response shape Each media buy gains a `windows[]` array on the response: ```json test=false theme={null} { "media_buy_deliveries": [ { "media_buy_id": "mb_12345", "status": "active", "totals": { "impressions": 12345678, "spend": 5432.10 }, "by_package": [ /* cumulative per-package — unchanged */ ], "windows": [ { "window_start": "2026-06-01T00:00:00Z", "window_end": "2026-06-01T01:00:00Z", "totals": { "impressions": 510234, "spend": 226.05 }, "by_package": [ { "package_id": "pkg_001", "impressions": 510234, "spend": 226.05 } ], "is_final": true }, { "window_start": "2026-06-01T01:00:00Z", "window_end": "2026-06-01T02:00:00Z", "totals": { "impressions": 488112, "spend": 215.83 }, "is_final": true } ] } ] } ``` Slices are ordered by `window_start` ascending; consecutive rows are contiguous (each row's `window_end` equals the next row's `window_start`). Each slice payload is shape-aligned with what `reporting_webhook` would have delivered for the same window — a buyer reconciling a missed webhook joins on `(media_buy_id, window_start)`. ### Spec contract * **Capability-scoped MUST** — sellers MUST honor `time_granularity` requests for any value in `windowed_pull_granularities`. Pulls outside the declared set return `UNSUPPORTED_GRANULARITY`. * **Asymmetric is honest** — sellers MAY emit higher-frequency webhooks than they expose for pull. Declaring `available_reporting_frequencies: ["hourly", "daily"]` with `windowed_pull_granularities: ["daily"]` is valid; the buyer treats the hourly webhook as primary at that frequency. * **Same-shape recovery** — slice payloads mirror webhook fire payloads at the same granularity so a buyer's reconciliation pipeline does not branch on transport path. This surface anchors [snapshot-and-log](/dist/docs/3.1.20/protocol/snapshot-and-log) Rule 4 (either path is complete) for data-bearing events. See that page for the broader contract. ## Dimension Breakdowns When you include `reporting_dimensions` in the request, the response includes dimensional breakdown arrays within each `by_package` entry. Each breakdown entry inherits all fields from `delivery-metrics` plus dimension-specific identifiers. ### Requesting breakdowns ```json test=false theme={null} { "media_buy_ids": ["mb_123"], "reporting_dimensions": { "geo": { "geo_level": "metro", "system": "nielsen_dma", "limit": 10 }, "device_type": {}, "placement": { "limit": 5, "sort_by": "roas" } } } ``` Each dimension accepts optional `limit` (max rows; defaults to 25 for geo, audience, and placement) and `sort_by` (any value from the `sort-metric` enum, e.g., `spend`, `impressions`, `clicks`, `roas` — defaults to `spend` descending; falls back to `spend` if the seller does not report the requested metric). Geo requires `geo_level` (`country`, `region`, `metro`, `postal_area`). Include `system` for metro/postal levels when requesting a specific system; native postal requests also include `country` (for example, `{ "geo_level": "postal_area", "country": "US", "system": "zip" }`). Each request uses a single geo\_level — for multiple granularities (e.g., country and region), make separate requests. Unsupported dimensions are silently omitted from the response, but malformed requests (e.g., geo without `geo_level`) return a validation error. Breakdowns are per-dimension only — cross-dimensional intersections (e.g., device\_type × geo) are not supported. ### Available dimensions | Dimension | Breakdown field | Required fields | Additional fields | Capability declaration | | --------------- | -------------------- | -------------------------------------------------------- | ------------------------------------ | ------------------------------------ | | Geography | `by_geo` | `geo_level`, `geo_code`, `impressions`, `spend` | `system`, `country`, `geo_name` | `supports_geo_breakdown` | | Device type | `by_device_type` | `device_type`, `impressions`, `spend` | — | `supports_device_type_breakdown` | | Device platform | `by_device_platform` | `device_platform`, `impressions`, `spend` | — | `supports_device_platform_breakdown` | | Audience | `by_audience` | `audience_id`, `audience_source`, `impressions`, `spend` | `audience_name` | `supports_audience_breakdown` | | Placement | `by_placement` | `placement_id`, `impressions`, `spend` | `publisher_domain`, `placement_name` | `supports_placement_breakdown` | Check `reporting_capabilities` on the product to discover which dimensions are available. Product-level capabilities are authoritative since different products from the same seller may support different breakdowns. `supports_geo_breakdown` is an object that declares available levels and systems; the other capability declarations in this table are boolean flags. Within `supports_geo_breakdown`, `country` and `region` are booleans, while `metro` is keyed by `metro-system` values and native `postal_area` is keyed by ISO 3166-1 alpha-2 country with arrays of country-local `postal-system` values. Geo rows use `system` for `geo_level: "metro"` and `"postal_area"`; native postal rows also include `country`. Deprecated country-fused postal systems remain accepted for compatibility. Placement identity is publisher-scoped. Placement rows MAY carry `publisher_domain` — the publisher namespace from the product's `placements[]` entry — so buyers can treat `{publisher_domain, placement_id}` as the stable placement identity for multi-publisher products when it is present. Sellers SHOULD emit `publisher_domain` whenever the product placement carries it (always true for `kind: "publisher_ref"`); sellers MAY omit it only for `kind: "seller_inline"` placements in a legacy single-publisher context where the seller agent's own domain is the namespace. When `publisher_domain` is omitted, buyers MAY interpret `placement_id` relative to the seller agent's own publisher domain only in that legacy single-publisher context; otherwise buyers should not infer a cross-publisher placement key. `publisher_domain` is single-valued because each placement belongs to exactly one publisher namespace. ### Truncation Each breakdown array has a sibling boolean flag (e.g., `by_geo_truncated`). When `true`, additional rows exist beyond the returned set. When `false`, the list is complete. Sellers MUST return the truncated flag whenever the corresponding breakdown array is present. Rows are sorted by the requested `sort_by` metric descending. ### Audience sources The `audience_source` field indicates where the audience segment originated: | Source | Description | Targetable? | | ------------- | ----------------------------------------------- | ----------------------------------------------- | | `synced` | Buyer's first-party data via `sync_audiences` | Yes — use `audience_include`/`audience_exclude` | | `platform` | Seller's native segments (interest, behavioral) | No — informational | | `third_party` | External data provider segments | No — informational | | `lookalike` | Platform-generated expansion from a seed | No — informational | | `retargeting` | Prior engagement via seller's pixel/tag | No — informational | | `unknown` | Unclassified or unrecognized audience source | No — informational | ## Best Practices **1. Check Date Range Support** Before requesting date-filtered delivery, check `reporting_capabilities.date_range_support` on the product. Products with `lifetime_only` support reject date range requests — omit `start_date` and `end_date` to get campaign lifetime data instead. **2. Use Date Ranges for Analysis** For products that support date ranges, specify dates for period-over-period comparisons and trend analysis. **3. Monitor Pacing Index** Aim for 0.95-1.05 pacing index. Values outside this range indicate delivery issues. **4. Check Daily Breakdown** Identify delivery patterns and weekend/weekday performance differences. **5. Compare Package Performance** Use `by_package` breakdowns to identify best-performing inventory. Check both `paused` state and `delivery_status` to understand why packages aren't delivering. **6. Track Status Changes** Use multi-status queries to understand why campaigns were paused or completed. ## Post-Delivery Governance Validation Delivery reporting is not the final step. When campaign governance is active, delivery data feeds into governance validation to detect unauthorized supply paths, geo drift, and pacing violations. The governance feedback loop: 1. Pull delivery data via `get_media_buy_delivery` 2. Report outcomes to the governance agent via [`report_plan_outcome`](/dist/docs/3.1.20/governance/campaign/tasks/report_plan_outcome) 3. The governance agent compares actual delivery against planned parameters (drift detection) 4. Validate property delivery via [`validate_property_delivery`](/dist/docs/3.1.20/governance/property/tasks/validate_property_delivery) to catch unauthorized supply paths | Governance task | Purpose | | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | [`report_plan_outcome`](/dist/docs/3.1.20/governance/campaign/tasks/report_plan_outcome) | Feed delivery data to the governance agent for budget tracking and drift detection | | [`validate_property_delivery`](/dist/docs/3.1.20/governance/property/tasks/validate_property_delivery) | Validate delivery records against property lists — catches ads running on unauthorized properties | | [`validate_content_delivery`](/dist/docs/3.1.20/governance/content-standards/tasks/validate_content_delivery) | Validate content artifacts against brand suitability standards | | [`get_plan_audit_logs`](/dist/docs/3.1.20/governance/campaign/tasks/get_plan_audit_logs) | View the full plan state and audit trail | Without this feedback loop, delivery data is reported but never validated. Budget overruns, pacing divergence, geo drift, and unauthorized supply paths go undetected. ## Next Steps After retrieving delivery data: 1. **Optimize Campaigns**: Use [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) to adjust budgets, pacing, or targeting 2. **Provide Feedback**: Use [`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) to share results with seller 3. **Update Creatives**: Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for library-backed sellers, or inline `packages[].creatives` on [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) for inline-only sellers 4. **Create Follow-Up Campaigns**: Use [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) based on insights ## Learn More * [Media Buy Lifecycle](/dist/docs/3.1.20/media-buy/media-buys/) - Complete campaign workflow * [Async Operations](/dist/docs/3.1.20/building/by-layer/L3/async-operations) - Async patterns and status handling * [Performance Optimization](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) - Using delivery data for optimization # get_media_buys Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/get_media_buys get_media_buys task — retrieve media buy status in AdCP including creative approvals, missing assets, configuration, and optional near-real-time delivery snapshots. Retrieve the current operational state of media buys: configuration, creative approval status, missing assets, and optional near-real-time delivery snapshots. **Response Time**: \~1 second ## Scope of Results Sales agents MUST return every media buy owned by the authenticated account, regardless of how the buy was created — via AdCP `create_media_buy`, via the seller's own APIs, via manual trafficking, via legacy or third-party systems. Scope is **account ownership**, not creation surface. A `media_buy_id` returned here identifies any order in the seller's ad server accessible to the authenticated caller. Any media buy returned by `get_media_buys` MUST be reachable by every task in its `valid_actions`. Sales agents MUST NOT mark a buy read-only, hide it, or refuse updates on the basis that it was not originally created via AdCP. When an action is unavailable for business reasons (contractual obligations, platform constraints, policy), the seller MUST omit only that action from `valid_actions` — never the whole set, and never merely because the buy was created outside AdCP. A seller that returns non-AdCP buys with a systematically empty `valid_actions` is non-conformant; that pattern is indistinguishable from hiding the buy. Sellers that need to partition inventory away from a caller MUST do so at the **account boundary**, not within-account. See [Account Ownership vs. Creation Surface](/dist/docs/3.1.20/media-buy/specification#account-ownership-vs-creation-surface). **Request Schema**: [`/schemas/3.1.20/media-buy/get-media-buys-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buys-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/get-media-buys-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-media-buys-response.json) ## Request Parameters | Parameter | Type | Required | Description | | -------------------------- | -------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | No | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. When omitted, returns data across all accessible accounts. | | `media_buy_ids` | string\[] | No\* | Array of media buy IDs to retrieve | | `status_filter` | string \| string\[] | No | Status filter: `"pending_creatives"`, `"pending_start"`, `"active"`, `"paused"`, `"completed"`, `"rejected"`, `"canceled"`. Defaults to `["active"]` only when `media_buy_ids` is omitted. | | `include_snapshot` | boolean | No | When true, include near-real-time delivery snapshots for each package. Defaults to `false`. | | `include_history` | integer | No | Include the last N revision history entries per media buy (returns min(N, available)). 0 or omit to exclude. Max 1000. | | `include_webhook_activity` | boolean | No | When true, each media buy includes a `webhook_activity` array with recent delivery-report webhook fires for the calling principal. Defaults to `false`. See [Webhook activity](#webhook-activity). | | `webhook_activity_limit` | integer | No | Per-buy cap on returned webhook records (most-recent first). Range 1–200, default 50. Ignored when `include_webhook_activity` is false. | | `pagination` | object | No | Cursor-based pagination controls (`max_results`, `cursor`) for broad queries. | \*`media_buy_ids` filters results to specific media buys. If neither is provided, the query is scope-based and uses `status_filter` + `pagination`. When `media_buy_ids` are provided, no implicit status filtering is applied. Pass `status_filter` explicitly if you want to filter identified buys by status. ## Response Returns an array of media buys with current status, creative approval state, and optionally delivery snapshots: | Field | Description | | ------------ | ------------------------------------------------------------------------- | | `media_buys` | Array of media buy objects | | `pagination` | Cursor pagination metadata (`has_more`, `cursor`, optional `total_count`) | | `errors` | Task-specific errors (e.g., media buy not found) | ### Media Buy Object **3.1 vocabulary note.** `get_media_buys` returns the lifecycle state on a nested `media_buys[].status` field (no envelope collision — nested at depth 1). `create_media_buy` and `update_media_buy` success responses return the same state on a top-level **`media_buy_status`** field (added in 3.1 to avoid colliding with the envelope task-status `status`). Same enum, two field names in 3.1 — the cascade unifies in 4.0 ([#4905](https://github.com/adcontextprotocol/adcp/issues/4905)). Buyers maintaining cross-call state should treat the two as the same logical value. See [Migration › `media_buy_status`](/dist/docs/3.1.20/reference/migration/media-buy-status) for the full picture. | Field | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy_id` | Seller's media buy identifier | | `invoice_recipient` | Per-buy invoice recipient when provided at creation. Confirms the seller accepted the billing override. Bank details are omitted (write-only). | | `status` | Current status (`pending_creatives`, `pending_start`, `active`, `paused`, `completed`, `rejected`, `canceled`). Maps to `media_buy_status` on `create_media_buy` / `update_media_buy` success responses (3.1 vocabulary note above). | | `status_as_of` | ISO 8601 timestamp when the seller last refreshed the returned media-buy-level `status` from its source of truth. For rolled-up statuses, this MUST NOT be later than the oldest upstream status observation that could affect the returned roll-up. Optional; omitted or `null` makes no freshness assertion, and buyers MUST NOT infer live status from absence. Use this to interpret cached or rolled-up statuses separately from `updated_at`, which means the media buy's last modification time. | | `currency` | ISO 4217 currency for media-buy-level monetary values | | `total_budget` | Total campaign budget (in `currency`) | | `creative_deadline` | Creative upload deadline (ISO 8601) | | `confirmed_at` | ISO 8601 timestamp when the seller committed to this media buy. May be `null` until seller commitment occurs in deferred/manual approval flows; stable after it is set. | | `cancellation` | Cancellation metadata (present only when `status` is `canceled`). Object with `canceled_at` (ISO 8601), `canceled_by` (`"buyer"` or `"seller"`), and optional `reason`. | | `revision` | Current revision number. Pass in `update_media_buy` for optimistic concurrency. | | `valid_actions` | Actions the buyer can perform in the current state (e.g., `["pause", "cancel", "update_budget"]`). See [valid actions mapping](#valid-actions-mapping). | | `history` | Revision history entries, most recent first. Only present when `include_history > 0`. Append-only — entries are never modified or deleted. | | `webhook_activity` | Recent reporting and health webhook fires for the calling principal, most-recent first. Only present when `include_webhook_activity` is true AND the seller surfaces fire history for this buy. See [Webhook activity](#webhook-activity) for three-state presence semantics. | | `context` | Opaque media-buy-level correlation data echoed unchanged from `create_media_buy`. Sellers MUST include persisted context when the media buy was created through AdCP with context, and MAY omit it for media buys created outside AdCP or without context. Use it to reconcile `media_buy_id` with buyer tracking state. | | `packages` | Array of packages with creative status and optional snapshots | ### Package Object | Field | Description | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `package_id` | Seller's package identifier | | `product_id` | Product identifier this package is purchased from. For packages created from an explicit `create_media_buy` package request, sellers MUST echo the request package's `product_id` on every response package object that represents that requested package. | | `currency` | Optional package-level currency override (defaults to media buy `currency`) | | `bid_price` | Current bid price for auction-based packages (in package `currency` if present, otherwise media buy `currency`) | | `format_ids` | Legacy named-format IDs supplied on `create_media_buy`, echoed whenever the original request included them, including dual-emission cases where another selector won precedence. | | `format_option_refs` | Structured 3.1+ format option references supplied on `create_media_buy`, echoed whenever the original request included them. | | `format_kind` | Direct canonical selector supplied on `create_media_buy`, echoed whenever the original request included it, including informational-echo cases where another selector won precedence. | | `params` | Parameters for the direct canonical selector in `format_kind`, echoed whenever the original request included them; requires `format_kind`. | | `start_time` | Flight start time (ISO 8601). Check this before interpreting delivery status. | | `end_time` | Flight end time (ISO 8601) | | `paused` | Whether buyer has paused this package | | `canceled` | Whether this package has been canceled (irreversible) | | `cancellation` | Cancellation metadata (present only when `canceled` is true). Object with `canceled_at` (ISO 8601), `canceled_by` (`"buyer"` or `"seller"`), and optional `reason`. | | `creative_deadline` | Per-package creative deadline (ISO 8601). When absent, the media buy's `creative_deadline` applies. | | `context` | Opaque package-level correlation data echoed unchanged from the `create_media_buy` package request. Sellers MUST include persisted context when the package was created through AdCP with context, and MAY omit it for packages created outside AdCP or without context. Buyers targeting mixed seller populations can use the per-package correlation value they included on create, commonly `context.buyer_ref`, to map `package_id` back to their line items when a legacy create response did not echo `product_id`. | | `creative_approvals` | Array of creative approval states (see below) | | `format_ids_pending` | Format IDs from `format_ids_to_provide` not yet uploaded | | `snapshot_unavailable_reason` | Reason code when `include_snapshot: true` but no snapshot is returned for this package | | `snapshot` | Near-real-time delivery snapshot (when `include_snapshot: true`) | ### Creative Approval Object | Field | Description | | ------------------ | --------------------------------------------------------------- | | `creative_id` | Creative identifier | | `approval_status` | `pending_review`, `approved`, or `rejected` | | `rejection_reason` | Explanation of rejection (when `approval_status` is `rejected`) | For sellers that advertise `inline_creative_management` without a creative library, `creative_approvals` is the only standardized approval-state readback surface for the package's currently assigned inline creatives. It reports `creative_id`, `approval_status`, and optional `rejection_reason`, but it does not include the full `CreativeAsset` payload, placement routing, weights, or prior revisions submitted on `create_media_buy` or `update_media_buy`. Buyers should retain their own submitted creative bodies when integrating with inline-only sellers. Creative revisions are represented as `approval_status: "rejected"` with a specific `rejection_reason`. There is no package-level `input-required` status for creative edits; upload corrected library assets via [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives), or corrected inline-only package assets via `packages[].creatives` on [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy). ### History Entry Object | Field | Required | Description | | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `revision` | Yes | Revision number after this change was applied | | `timestamp` | Yes | ISO 8601 timestamp when this change occurred | | `action` | Yes | What happened: `created`, `activated`, `paused`, `resumed`, `canceled`, `rejected`, `completed`, `updated_budget`, `updated_dates`, `updated_packages`, `package_canceled`, `package_paused`, `package_resumed` | | `actor` | No | Identity of who made the change (server-derived from auth context, not caller-provided) | | `summary` | No | Human-readable description (e.g., "Budget changed from $5,000 to $7,500 on pkg\_abc") | | `package_id` | No | Package affected, when the change targeted a specific package | History entries are **append-only** — sellers MUST NOT modify or delete previously emitted entries. Callers MAY cache entries by revision number. `revision` increments only when the seller applies a mutating state change or update. Reads, validation-only calls, and exact idempotency replays do not create history entries or bump revision. Buyers should treat the returned revision as the token for their next `update_media_buy` call intended to change state. `confirmed_at` is not a delivery status timestamp. It records seller commitment and remains stable through later pause/resume, activation, completion, cancellation, and reporting changes. ### Snapshot Object | Field | Description | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `as_of` | ISO 8601 timestamp when the platform captured this snapshot | | `staleness_seconds` | Maximum data age in seconds. Use this to interpret zero delivery: 900 (15 min) means zero is likely real; 14400 (4 hr) means reporting may still be catching up. | | `impressions` | Total impressions delivered since package start | | `spend` | Total spend since package start | | `currency` | Optional snapshot currency override for `spend` | | `clicks` | Total clicks since package start (when available) | | `pacing_index` | Delivery pace (1.0 = on track, \<1.0 = behind, >1.0 = ahead) | | `delivery_status` | `delivering`, `not_delivering`, `completed`, `budget_exhausted`, `flight_ended`, `goal_met` | | `ext` | Optional extension object for seller-specific operational fields | **`not_delivering`** means the package is within its scheduled flight but has delivered zero impressions for at least one full staleness cycle. Implementers must not return `not_delivering` until `staleness_seconds` have elapsed since package activation — a new package with no impressions in its first minutes is expected, not a problem. Check `start_time` to confirm the package is within its flight before acting on this status. Money fields use this currency precedence: `snapshot.currency` -> `package.currency` -> `media_buy.currency`. ### Webhook Activity When `include_webhook_activity: true`, each returned media buy MAY carry a `webhook_activity` array describing recent reporting and health webhook fires from the seller to the buyer's registered endpoint. This is the buyer-side debug surface for the [persistent-channel webhook contract](/dist/docs/3.1.20/building/by-layer/L3/webhooks#persistent-channel-contract) — buyers use it to verify that the publisher fired, what the buyer's gateway returned, and whether retries are still in flight, without needing an operator-level query against the seller's logs. The record shape, request-field names, scoping, retention floor, three-state presence, and cardinality rules are uniform across AdCP resources that adopt this surface. See [Webhook activity log pattern](/dist/docs/3.1.20/protocol/snapshot-and-log#webhook-activity-log-pattern) on the snapshot/log contract page for the cross-resource normative section — the rules below restate it for the media-buy call site and add the media-buy-specific capability gate. The surface covers both delivery-report notification types (`scheduled`, `final`, `delayed`, `adjusted`) and health notification types (`impairment`). All share the same webhook delivery contract and the same buyer-side debug need. | Field | Description | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `idempotency_key` | Equals the `idempotency_key` carried in the webhook payload itself ([§ Dedup by `idempotency_key`](/dist/docs/3.1.20/building/by-layer/L3/webhooks#dedup-by-idempotency_key)). Stable across retries of the same logical fire — buyers correlate this surface with their own endpoint logs via this exact field. Reference it when filing support tickets. | | `subscriber_id` | Identifies which registered webhook subscriber received this fire. Absent in single-subscriber configurations; populated under multi-subscriber buys (4.0+, see [#3009](https://github.com/adcontextprotocol/adcp/issues/3009)). | | `fired_at` | ISO 8601 timestamp when the seller initiated this attempt. | | `completed_at` | ISO 8601 timestamp when the response was observed (or terminal failure). Null while `status` is `pending`. | | `notification_type` | Verbatim from the webhook payload: `scheduled`, `final`, `delayed`, `adjusted`, `impairment`. | | `sequence_number` | Sequence number from the webhook payload — useful for spotting stale-sequence drops or gaps. Absent when the notification type does not carry one. | | `attempt` | 1-indexed retry counter for this logical fire. Initial fire is `attempt: 1`. | | `status` | `success`, `failed`, `timeout`, `connection_error`, or `pending`. See semantics below. | | `url` | Target URL with **query string and fragment stripped**, and high-entropy / token-shaped path segments redacted. Match this against your registered URL by origin + path, not full URL. | | `http_status_code` | HTTP status from the buyer's endpoint. Null when no HTTP response was received (`timeout`, `connection_error`, `pending`). | | `response_time_ms` | Wall-clock latency between request send and response receipt. Null for non-completed attempts. | | `payload_size_bytes` | Size of the request body the seller sent — useful for diagnosing oversized-payload rejections. | | `error_message` | Short human-readable server-side classification of failure. Null for `success`. Sellers MUST NOT include request / response bodies or headers here. | **Status semantics:** * `success` — response received with a 2xx status. `http_status_code` populated. * `failed` — response received with a non-2xx status. `http_status_code` populated; `error_message` describes the response. * `timeout` — no response within the seller's configured timeout. `http_status_code` null. Operationally: the buyer's endpoint is reachable but slow / overloaded. * `connection_error` — DNS, TLS, or socket failure before any HTTP response. `http_status_code` null. Operationally: the buyer's endpoint is unreachable or misconfigured. * `pending` — attempt is in flight or queued for retry. `completed_at` is null; subsequent attempts appear with the same `idempotency_key` and incremented `attempt`. **Record cardinality:** one record per attempt. A successful first-attempt fire appears as a single record with `attempt: 1`. A 3-attempt retry trail (e.g., two failures then a success) appears as three records sharing `idempotency_key`. **Scoping (normative):** * `webhook_activity` MUST be scoped to the **calling principal**. When multiple buyer principals share visibility into the same media buy via account-level access, each principal sees only fires targeting its own endpoint. * Sellers that surface this field **MUST** retain records for at least 30 days from each record's `completed_at` — uniformly across `success`, `failed`, `timeout`, and `connection_error` outcomes (all of which populate `completed_at`). For records still in `pending` status, the clock runs from `fired_at` until the attempt terminates and then transitions to 30 days from `completed_at` — retry trails do not age out mid-flight. Sellers that cannot honor this floor MUST omit the field entirely rather than return a shorter window; the three-state presence semantics give them a clean opt-out and buyers a single guarantee they can build against. * This surface is a debug aid, not a full audit log. There is no cursor for older fires beyond `webhook_activity_limit` — buyers needing full history must persist webhook records on their own side. **Three-state presence semantics:** | State | Meaning | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Field **omitted** | Seller does not surface webhook activity for this buy. Either the seller does not persist fire history, the seller's declared [`propagation_surfaces`](/dist/docs/3.1.20/media-buy/media-buys/lifecycle) excludes `webhook`, or the buy has no registered webhook endpoint for the calling principal. | | Empty array `[]` | Seller persists fire history but has fired nothing recent for this principal. | | Non-empty array | Actual fire records, most-recent first. | Sellers whose declared `propagation_surfaces` does not include `webhook` MUST omit the field; opting in via `include_webhook_activity: true` does not override that. **Diagnosing an unexpected omission.** When you expected fires but got an omitted field, two observables let you discriminate the cause without filing a ticket: (1) check your own `push_notification_config` registration state for this buy — if not registered, that's the cause; (2) check the seller's `capabilities.media_buy.propagation_surfaces` via `get_adcp_capabilities` — if `webhook` is absent, that's the cause. When both check out, "seller does not persist fire history" is the remaining cause; that's a seller-side gap and warrants an operator ticket. **Privacy:** * The `url` field has its query string and fragment **stripped**, and sellers SHOULD redact path segments resembling shared secrets (high-entropy random material, UUID / token shapes). * Request and response bodies are **not surfaced** by this field. A future `include_webhook_payloads` extension may add them under stricter authorization controls — out of scope here. * `error_message` is a server-side classification string only — never request headers, never response bodies, never buyer-endpoint stack traces. #### Diagnose a webhook delivery problem ```typescript TypeScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuysResponseSchema, type WebhookActivityRecord } from '@adcp/sdk'; // The WebhookActivityRecord type is regenerated by the SDK from // /schemas/core/webhook-activity-record.json — once the SDK rebuilds against this // branch's schemas the import resolves. The same type appears on every AdCP resource // that surfaces webhook_activity[], so debug helpers can be written once and reused. function latestAttempt(trail: WebhookActivityRecord[]): WebhookActivityRecord { return trail.reduce((a, b) => (a.attempt >= b.attempt ? a : b)); } const result = await testAgent.getMediaBuys({ media_buy_ids: ['mb_12345'], include_webhook_activity: true, webhook_activity_limit: 20, }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuysResponseSchema.parse(result.data); for (const mediaBuy of validated.media_buys) { // Three-state semantics — distinguish "seller does not surface" from "no recent fires". if (mediaBuy.webhook_activity === undefined) { console.log(`${mediaBuy.media_buy_id}: seller does not surface webhook activity for this buy`); continue; } const fires = mediaBuy.webhook_activity; if (fires.length === 0) { console.log(`${mediaBuy.media_buy_id}: no recent fires for this principal`); continue; } // Group attempts by idempotency_key so we can see the retry trail per logical fire. const trails = new Map(); for (const fire of fires) { const trail = trails.get(fire.idempotency_key) ?? []; trail.push(fire); trails.set(fire.idempotency_key, trail); } for (const [idempotencyKey, trail] of trails) { // Pick the latest attempt by `attempt` number — robust against any iteration order. const latest = latestAttempt(trail); if (latest.status === 'success') continue; const detail = latest.error_message ?? latest.http_status_code ?? '—'; console.log( `${mediaBuy.media_buy_id} ${idempotencyKey} ` + `(${latest.notification_type} seq=${latest.sequence_number}): ` + `${latest.status} after ${trail.length} attempt(s) — ${detail}` ); } } ``` ```python Python theme={null} import asyncio from collections import defaultdict from adcp.testing import test_agent from adcp.types import GetMediaBuysRequest, WebhookActivityRecord # WebhookActivityRecord is regenerated by the SDK from # /schemas/core/webhook-activity-record.json — once the SDK rebuilds against this # branch's schemas the import resolves. The same type appears on every AdCP resource # that surfaces webhook_activity[]. def latest_attempt(trail: list[WebhookActivityRecord]) -> WebhookActivityRecord: return max(trail, key=lambda f: f.attempt) async def main(): result = await test_agent.get_media_buys( GetMediaBuysRequest( media_buy_ids=['mb_12345'], include_webhook_activity=True, webhook_activity_limit=20, ) ) for media_buy in result.media_buys: # Three-state semantics: distinguish "seller does not surface" from "no recent fires". if media_buy.webhook_activity is None: print(f"{media_buy.media_buy_id}: seller does not surface webhook activity for this buy") continue fires = media_buy.webhook_activity if not fires: print(f"{media_buy.media_buy_id}: no recent fires for this principal") continue trails = defaultdict(list) for fire in fires: trails[fire.idempotency_key].append(fire) for idempotency_key, trail in trails.items(): latest = latest_attempt(trail) if latest.status == 'success': continue detail = latest.error_message or latest.http_status_code or '—' print(f"{media_buy.media_buy_id} {idempotency_key} " f"({latest.notification_type} seq={latest.sequence_number}): " f"{latest.status} after {len(trail)} attempt(s) — {detail}") asyncio.run(main()) ``` ## Valid Actions Mapping The `valid_actions` array tells agents what operations are permitted on a media buy in its current state. Sellers SHOULD include this field. Expected values by status: | Status | Expected `valid_actions` | | ------------------- | -------------------------------------------------------------------------------------------------------- | | `pending_creatives` | `pause`, `cancel`, `sync_creatives` | | `pending_start` | `pause`, `cancel`, `sync_creatives` | | `active` | `pause`, `cancel`, `update_budget`, `update_dates`, `update_packages`, `add_packages`, `sync_creatives` | | `paused` | `resume`, `cancel`, `update_budget`, `update_dates`, `update_packages`, `add_packages`, `sync_creatives` | | `completed` | *(empty array)* | | `rejected` | *(empty array)* | | `canceled` | *(empty array)* | Sellers MAY omit actions based on business rules (e.g., omit `cancel` when the media buy has contractual obligations that prevent cancellation). For creative changes, `sync_creatives` in `valid_actions` is a legacy creative-change action label, not proof that the `sync_creatives` task exists. Use the creative path the seller advertises: `sync_creatives` and `creative_assignments` for sellers with `creative.has_creative_library: true`, or `packages[].creatives` on `update_media_buy` for inline-only sellers. ## Common Scenarios ### Check creative approval status ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuysResponseSchema } from '@adcp/sdk'; const result = await testAgent.getMediaBuys({ media_buy_ids: ['mb_12345'] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuysResponseSchema.parse(result.data); if (validated.errors?.length > 0) { throw new Error(`Query failed: ${JSON.stringify(validated.errors)}`); } for (const mediaBuy of validated.media_buys) { for (const pkg of mediaBuy.packages) { // Check for missing creatives if (pkg.format_ids_pending?.length > 0) { console.log(`Package ${pkg.package_id}: missing formats ${pkg.format_ids_pending.map(f => f.id).join(', ')}`); } // Check approval states for (const approval of pkg.creative_approvals ?? []) { if (approval.approval_status === 'rejected') { console.log(`Creative ${approval.creative_id} rejected: ${approval.rejection_reason}`); } else if (approval.approval_status === 'pending_review') { console.log(`Creative ${approval.creative_id} pending review`); } } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuysRequest async def main(): result = await test_agent.get_media_buys( GetMediaBuysRequest(media_buy_ids=['mb_12345']) ) if result.errors: raise Exception(f"Query failed: {result.errors}") for media_buy in result.media_buys: for pkg in media_buy.packages: # Check for missing creatives if pkg.format_ids_pending: ids = [f.id for f in pkg.format_ids_pending] print(f"Package {pkg.package_id}: missing formats {', '.join(ids)}") # Check approval states for approval in pkg.creative_approvals or []: if approval.approval_status == 'rejected': print(f"Creative {approval.creative_id} rejected: {approval.rejection_reason}") elif approval.approval_status == 'pending_review': print(f"Creative {approval.creative_id} pending review") asyncio.run(main()) ``` ### Monitor delivery with snapshots ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuysResponseSchema } from '@adcp/sdk'; const result = await testAgent.getMediaBuys({ status_filter: 'active', include_snapshot: true }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuysResponseSchema.parse(result.data); for (const mediaBuy of validated.media_buys) { for (const pkg of mediaBuy.packages) { const snap = pkg.snapshot; if (!snap) continue; if (snap.delivery_status === 'not_delivering') { console.log(`Package ${pkg.package_id}: zero delivery (data up to ${snap.staleness_seconds}s old)`); } else if (snap.pacing_index !== undefined && snap.pacing_index < 0.8) { console.log(`Package ${pkg.package_id}: underpacing at ${(snap.pacing_index * 100).toFixed(0)}%`); } else { console.log(`Package ${pkg.package_id}: ${snap.impressions.toLocaleString()} impressions, pacing ${snap.pacing_index?.toFixed(2)}`); } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuysRequest async def main(): result = await test_agent.get_media_buys( GetMediaBuysRequest( status_filter='active', include_snapshot=True ) ) if result.errors: raise Exception(f"Query failed: {result.errors}") for media_buy in result.media_buys: for pkg in media_buy.packages: snap = pkg.snapshot if not snap: continue if snap.delivery_status == 'not_delivering': print(f"Package {pkg.package_id}: zero delivery (data up to {snap.staleness_seconds}s old)") elif snap.pacing_index is not None and snap.pacing_index < 0.8: print(f"Package {pkg.package_id}: underpacing at {snap.pacing_index * 100:.0f}%") else: print(f"Package {pkg.package_id}: {snap.impressions:,} impressions, pacing {snap.pacing_index:.2f}") asyncio.run(main()) ``` ### Campaign readiness check ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetMediaBuysResponseSchema } from '@adcp/sdk'; const result = await testAgent.getMediaBuys({ media_buy_ids: ['mb_12345'] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = GetMediaBuysResponseSchema.parse(result.data); const [mediaBuy] = validated.media_buys; const issues = []; for (const pkg of mediaBuy.packages) { if (pkg.format_ids_pending?.length > 0) { issues.push(`Package ${pkg.package_id}: ${pkg.format_ids_pending.length} format(s) not yet uploaded`); } const rejected = (pkg.creative_approvals ?? []).filter(a => a.approval_status === 'rejected'); if (rejected.length > 0) { issues.push(`Package ${pkg.package_id}: ${rejected.length} creative(s) rejected`); } } if (issues.length === 0) { console.log('Campaign ready to launch'); } else { console.log('Campaign has blocking issues:'); issues.forEach(issue => console.log(` - ${issue}`)); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import GetMediaBuysRequest async def main(): result = await test_agent.get_media_buys( GetMediaBuysRequest(media_buy_ids=['mb_12345']) ) media_buy = result.media_buys[0] issues = [] for pkg in media_buy.packages: if pkg.format_ids_pending: issues.append(f"Package {pkg.package_id}: {len(pkg.format_ids_pending)} format(s) not yet uploaded") rejected = [a for a in (pkg.creative_approvals or []) if a.approval_status == 'rejected'] if rejected: issues.append(f"Package {pkg.package_id}: {len(rejected)} creative(s) rejected") if not issues: print("Campaign ready to launch") else: print("Campaign has blocking issues:") for issue in issues: print(f" - {issue}") asyncio.run(main()) ``` ## Snapshot vs. `get_media_buy_delivery` | | `get_media_buys` (with snapshot) | `get_media_buy_delivery` | | ------------------- | -------------------------------- | ---------------------------- | | **Purpose** | Operational monitoring | Reporting and reconciliation | | **Freshness** | Minutes (entity-level stats) | Hours (batch report jobs) | | **Accuracy** | Best-effort | Authoritative, billing-grade | | **Date range** | Always "since campaign start" | Configurable period | | **Daily breakdown** | No | Yes | | **Creative status** | Yes | No | | **Missing assets** | Yes | No | Use `get_media_buys` to answer "what is the current state of my campaigns?" and `get_media_buy_delivery` for "how did my campaigns perform over a period?" Status taxonomy is shared for lifecycle filters across both tasks (`pending_creatives`, `pending_start`, `active`, `paused`, `completed`). `get_media_buy_delivery` may additionally return reporting-only statuses (`reporting_delayed`, `failed`) in webhook contexts. ## Data Freshness Snapshot `staleness_seconds` varies by platform: | Platform type | Typical `staleness_seconds` | | ---------------------------------------------- | --------------------------- | | Entity-level stats (e.g., GAM LineItemService) | 900 (15 min) | | Near-real-time insights API | 60–300 | | Batch-only reporting | 14400 (4 hr) | When the platform only has batch reporting, the seller agent should return the most recent cached data with the appropriate `staleness_seconds`. If `include_snapshot: true` and `snapshot` is omitted for a package, check `snapshot_unavailable_reason`: * `SNAPSHOT_UNSUPPORTED`: the seller does not support package snapshots for this integration * `SNAPSHOT_TEMPORARILY_UNAVAILABLE`: snapshot pipeline is delayed or degraded; retry later * `SNAPSHOT_PERMISSION_DENIED`: caller lacks permission to view snapshot metrics for that package ## Pagination Use cursor pagination for broad status queries to avoid large payloads: * Request: set `pagination.max_results` (1-100, default 50) and optional `pagination.cursor` * Response: read `pagination.has_more`; when true, pass `pagination.cursor` into the next request * ID-targeted queries (`media_buy_ids`) can omit pagination unless the ID set is very large ## Error Handling | Error Code | Description | Resolution | | --------------------- | ------------------------------------------- | ------------------------------------------------------------------ | | `MEDIA_BUY_NOT_FOUND` | Media buy ID does not exist | Verify `media_buy_id` | | `CONTEXT_REQUIRED` | No media buys found for the requested scope | Provide valid IDs/refs or broaden `status_filter`/pagination scope | | `AUTH_MISSING` | No credentials presented | Provide credentials via auth header | | `AUTH_INVALID` | Credentials rejected (expired / revoked) | Human credential rotation required; do not auto-retry | ## Next Steps * **Upload missing creatives**: Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for library-backed sellers, or `packages[].creatives` on [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) for inline-only sellers * **Investigate zero delivery**: Check `delivery_status: "not_delivering"` and `start_time` to confirm the flight is active, then use [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) to adjust pricing or targeting * **Detailed reporting**: Use [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) for date-range reporting and daily breakdowns * **Optimize campaigns**: Use [`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) to share results with the seller # get_products Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/get_products get_products task — discover advertising inventory in AdCP using natural language campaign briefs or structured filters. Returns matched products with pricing and formats. Discover available advertising products based on campaign requirements using natural language briefs or structured filters. **Why this shape.** Targeting, pricing, and curation are folded into one round-trip — the brief drives discovery, the publisher curates against it, and `pricing_options` carry firm prices the buyer commits against via `pricing_option_id`. We rejected a separate `get_price_quote` step between products and buy creation: it splits one expert decision into two underspecified ones and breaks the brief→curation contract. Iteration is `buying_mode: "refine"` with a typed change array — not a new task. → [Design principle: the brief drives discovery](/dist/docs/3.1.20/protocol/design-principles#3-the-brief-drives-discovery-targeting-is-an-input-not-a-step). **Authentication**: Optional (returns limited results without credentials) **Response Time**: \~60 seconds (AI inference with back-end systems) **Request Schema**: [`/schemas/3.1.20/media-buy/get-products-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/get-products-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-response.json) ## Quick Start Discover products with a natural language brief: ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { GetProductsResponseSchema } from '@adcp/sdk'; const result = await testAgent.getProducts({ buying_mode: 'brief', brief: 'Premium athletic footwear with innovative cushioning', brand: { domain: 'acmecorp.com' } }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } // Validate response against schema const validated = GetProductsResponseSchema.parse(result.data); console.log(`Found ${validated.products.length} products`); // Access validated product fields for (const product of validated.products) { console.log(`- ${product.name} (${product.delivery_type})`); console.log(` Formats: ${product.format_ids.map(f => f.id).join(', ')}`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_products(): result = await test_agent.simple.get_products( buying_mode='brief', brief='Premium athletic footwear with innovative cushioning', brand={ 'domain': 'acmecorp.com' } ) print(f"Found {len(result.products)} products") asyncio.run(discover_products()) ``` ```bash CLI requires-env=ADCP_AUTH_TOKEN theme={null} uvx adcp \ https://test-agent.adcontextprotocol.org/sales/mcp \ get_products \ '{"buying_mode":"brief","brief":"Premium athletic footwear with innovative cushioning","brand":{"domain":"acmecorp.com"}}' \ --auth $ADCP_AUTH_TOKEN ``` ### Using Structured Filters You can also use structured filters instead of (or in addition to) a brief. In `brief` mode, filters act as hard constraints on top of the publisher's curation — the brief describes intent, filters enforce requirements: ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; const result = await testAgent.getProducts({ buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' }, filters: { channels: ['ctv'], delivery_type: 'guaranteed', standard_formats_only: true } }); if (result.success && result.data) { console.log(`Found ${result.data.products.length} guaranteed CTV products`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_with_filters(): result = await test_agent.simple.get_products( buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' }, filters={ 'channels': ['ctv'], 'delivery_type': 'guaranteed', 'standard_formats_only': True } ) print(f"Found {len(result.products)} guaranteed CTV products") asyncio.run(discover_with_filters()) ``` ## Request Parameters | Parameter | Type | Required | Description | | --------------------------- | ---------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `buying_mode` | string | Yes | `"brief"`, `"wholesale"`, or `"refine"`. `"brief"`: publisher curates products from the brief. `"wholesale"`: raw product feed access for buyer-directed targeting, `brief` must not be provided. `"refine"`: iterate on products and proposals from a previous response using the `refine` array of change requests. v3 clients MUST include `buying_mode`. Sellers receiving requests from pre-v3 clients without `buying_mode` SHOULD default to `"brief"`. **Timing semantics:** `"wholesale"` is a wholesale product feed read — sellers SHOULD return a synchronous response and MUST NOT route a `"wholesale"` request through the async/Submitted arm. Partial completion is signalled via [`incomplete[]`](#incomplete-array), not a task handoff. `"brief"` and `"refine"` MAY complete synchronously OR MAY return a `Submitted` envelope when curation requires upstream-system queries or HITL review the seller cannot complete inside `time_budget`. Buyers needing predictable fast wholesale product feed access MUST use `"wholesale"`. | | `brief` | string | Conditional | Natural language description of campaign requirements. Required when `buying_mode` is `"brief"`. Must not be provided when `buying_mode` is `"wholesale"` or `"refine"`. | | `refine` | [Refine\[\]](#refine-array) | Conditional | Array of change requests for iterating on products and proposals. Required when `buying_mode` is `"refine"`. Must not be provided when `buying_mode` is `"brief"` or `"wholesale"`. See [Refine array](#refine-array) below. | | `brand` | BrandRef | No | Brand reference (domain + optional brand\_id). Resolved to full identity at execution time. | | `account` | AccountRef | No | Account reference for account-specific pricing. Returns products with pricing from this account's rate card. | | `catalog` | [Catalog](/dist/docs/3.1.20/creative/catalogs) | No | Catalog of items the buyer wants to promote. The seller matches catalog items against its inventory and returns products where matches exist. Requires `brand`. See [Catalog discovery](#catalog-discovery) below. | | `filters` | Filters | No | Structured filters (see below) | | `fields` | string\[] | No | Specific product fields to include in the response for lightweight discovery. When requesting signal metadata, buyers SHOULD request `included_signals` for non-selectable bundled/planned signals, and `signal_targeting_allowed`, `signal_targeting_options`, and `signal_targeting_rules` for package-level signal selection. | | `property_list` | PropertyListRef | No | \[AdCP 3.0] Reference to a property list for filtering. See [Property Lists](/dist/docs/3.1.20/governance/property/tasks/property_lists) | | `pagination` | PaginationRequest | No | Cursor-based pagination to cap returned `products[]` in curated/refined responses or walk wholesale product feeds (see below) | | `if_wholesale_feed_version` | string | No | Opaque `wholesale_feed_version` token from a prior wholesale-mode `get_products` response from this agent. Only valid with `buying_mode: "wholesale"`. When provided, the seller compares against its current wholesale product feed version for the buyer's `cache_scope` and MAY return `unchanged: true` (with `products` omitted) if nothing has changed. Version scope excludes `pagination.cursor`: `public` is keyed by (agent, `buying_mode`, `filters`, `property_list`, `catalog`); `account` adds account identity. See [Wholesale feed versioning](#wholesale-feed-versioning). | | `if_pricing_version` | string | No | Opaque `pricing_version` token from a prior response. MUST only be sent together with `if_wholesale_feed_version`. Evaluation order: `if_wholesale_feed_version` mismatch → full payload; `if_wholesale_feed_version` matches but `if_pricing_version` mismatches → full payload (so the buyer sees updated `pricing_options`); both match → seller MAY return `unchanged: true`. Sellers that don't track pricing separately ignore this. | | `time_budget` | Duration | No | Maximum time the buyer will commit to this request. The seller returns the best results achievable within this budget and does not start processes (human approvals, expensive external queries) that cannot complete in time. When omitted, the seller decides timing. Example: `{"interval": 30, "unit": "seconds"}`. | | `push_notification_config` | PushNotificationConfig | No | Optional webhook channel for async terminal completion/failure notifications on `brief` / `refine` curated discovery. `submitted` responses with a `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If the request includes this field and the seller returns `submitted`, the seller MUST deliver at least the terminal completion/failure notification to the configured webhook; intermediate progress notifications are MAY. If the seller cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. Ignored for `wholesale`; sellers MUST NOT route wholesale reads through the Submitted arm because this field is present. | **Property Governance** The `property_list` filter references a property list created via [`create_property_list`](/dist/docs/3.1.20/governance/property/tasks/property_lists#create_property_list) on a property governance agent. Property lists define which publisher properties meet compliance requirements — COPPA-certified sites, sustainability-scored inventory, brand-safe publishers, etc. To use property list filtering: 1. Call `get_adcp_capabilities` on a property governance agent to discover available `property_features` 2. Create a property list via `create_property_list` with your feature requirements 3. Pass the resulting `property_list_id` to `get_products` to filter inventory The seller must declare `features.property_list_filtering: true` in [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) to support this filter. In AdCP 3.1 this legacy flag also declares support for `targeting_overlay.property_list` on media-buy create and update requests. See the [Property Governance overview](/dist/docs/3.1.20/governance/property/index) for the full workflow. ### Filters Object | Parameter | Type | Description | | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `delivery_type` | string | Filter by `"guaranteed"` or `"non_guaranteed"` | | `is_fixed_price` | boolean | Filter for fixed-price vs auction products. Products with both pricing types can match either value, but the returned `pricing_options` array must include only options matching the requested pricing type so buyers can select deterministically from discovery. | | `pricing_currencies` | string\[] | Filter by ISO 4217 currencies the buyer can use for the media product transaction (e.g., `["USD"]`). Products match when they offer at least one product-level `pricing_options` entry in one of the requested currencies and any seller-applied or otherwise mandatory product-scoped signal charges are satisfiable in one of those currencies or have no incremental price. Sellers MUST return only matching product `pricing_options` so buyers can select deterministically from discovery. Optional signal or vendor add-on pricing is not pruned by this filter. | | `format_ids` | FormatID\[] | Filter by specific format IDs | | `standard_formats_only` | boolean | Only return products accepting IAB standard formats | | `min_exposures` | integer | Minimum exposures needed for measurement validity | | `start_date` | string | Campaign start date in ISO 8601 format (YYYY-MM-DD) for availability checks | | `end_date` | string | Campaign end date in ISO 8601 format (YYYY-MM-DD) for availability checks | | `budget_range` | object | Budget range to filter appropriate products (see Budget Range Object below) | | `countries` | string\[] | Filter by target countries using ISO 3166-1 alpha-2 codes (e.g., `["US", "CA", "GB"]`) | | `regions` | string\[] | Filter by region coverage using ISO 3166-2 codes (e.g., `["US-NY", "GB-SCT"]`). Best for locally-bound inventory | | `metros` | object\[] | Filter by metro coverage. Each entry: `{ system, code }` (e.g., `[{ "system": "nielsen_dma", "code": "501" }]`) | | `channels` | string\[] | Filter by advertising channels (e.g., `["display", "ctv", "social", "streaming_audio"]`). See [Media Channel Taxonomy](/dist/docs/3.1.20/reference/media-channel-taxonomy) | | `video_placement_types` | string\[] | Filter video products by acceptable declared video placement types: `instream`, `accompanying_content`, `interstitial`, or `standalone`. Sellers should return only products they can satisfy with at least one requested type, and should exclude mixed, non-targetable bundles unless delivery can be constrained to the requested type. Uses IAB Tech Lab/OpenRTB 2.6 `video.plcmt` definitions with AdCP-native names. | | `audio_distribution_types` | string\[] | Filter audio products by acceptable declared audio distribution types: `music_streaming_service`, `fm_am_broadcast`, `podcast`, `catch_up_radio`, `web_radio`, `video_game`, or `text_to_speech`. Sellers should return only products they can satisfy with at least one requested type, and should exclude mixed, non-targetable bundles unless delivery can be constrained to the requested type. Uses IAB Tech Lab/OpenRTB 2.6 `audio.feed` definitions with AdCP-native names. | | `sponsored_placement_types` | string\[] | Filter catalog-driven retail-media products by acceptable declared sponsored-placement types: `sponsored_search`, `sponsored_display`, or `sponsored_native`. Sellers should return only products they can satisfy with at least one requested type, and should exclude mixed, non-targetable bundles unless delivery can be constrained to the requested type. | | `social_placement_surfaces` | string\[] | Filter social products by acceptable declared social-placement surfaces: `feed`, `stories`, `short_video`, `explore`, or `search`. Sellers should return only products they can satisfy with at least one requested surface, and should exclude mixed, non-targetable bundles unless delivery can be constrained to the requested surface. | | `postal_areas` | object\[] | Filter by postal area coverage. Each entry: `{ country, system, values }` (e.g., `[{ "country": "US", "system": "zip", "values": ["10001"] }]`) | | `geo_proximity` | object\[] | Filter by proximity to geographic points. Each entry uses exactly one boundary method: `radius`, `travel_time` + `transport_mode`, or `geometry` | | `keywords` | object\[] | Filter by keyword relevance for search/retail media. Each entry: `{ keyword, match_type? }`. `match_type` defaults to `broad` if omitted | | `signal_targeting` | SignalTargeting\[] | Discovery filter for products where the requested signals are buyer-selectable and jointly composable: available through inline `signal_targeting_options` and/or the seller's `get_signals` feed for wholesale products that allow signal targeting but omit inline options, with `signal_targeting_allowed: true`, and compatible under the product's `signal_targeting_rules`. Each entry uses `signal_ref` (`signal_id` is accepted only as a deprecated migration bridge) and may include `targeting_mode: "include"` or `"exclude"` to require support for `any` or `none` groups; omitted means `"include"`. `scope: "product"` is seller-local exact option matching only, not a portable semantic identifier across products or sellers; buyers wanting portable discovery should use `scope: "data_provider"` or `get_signals`. `included_signals` and deprecated `data_provider_signals` metadata do not satisfy this filter because they cannot be selected on `create_media_buy`. This filter is not the buy-time request shape; package selection always uses `packages[].targeting_overlay.signal_targeting_groups`. | | `required_performance_standards` | [PerformanceStandard\[\]](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#measurement-terms-and-performance-standards) | Filter to products that can meet the buyer's performance standard requirements. Each entry specifies a metric, threshold, and vendor (e.g., "DoubleVerify for viewability at 70% MRC"). Products that cannot meet these thresholds or do not support the specified vendors are excluded. | | `required_metrics` | string\[] ([metric vocabulary](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting)) | Filter to products whose `reporting_capabilities.available_metrics` is a superset of these metrics — i.e., products that commit to reporting all listed metrics in delivery. Use for capability discovery (e.g., `["completed_views"]` for a CTV CPCV buy). Sellers MUST silently exclude products that cannot meet the list — filter-not-fail; do not return an error. The product's declared `available_metrics` becomes the binding reporting contract carried into the resulting media buy. | | `required_vendor_metrics` | object\[] | Filter to products whose `reporting_capabilities.vendor_metrics` covers vendor-defined metrics (proprietary attention, emissions, panel demographics, brand-lift surveys, etc.). Each entry pins `vendor` (BrandRef) and/or `metric_id` — at least one. Cross-vendor discovery (e.g., "any attention measurement") is the buyer agent's responsibility: resolve which vendors offer a category via the vendors' `brand.json` records, then enumerate them as filter entries. Same filter-not-fail semantics as `required_metrics`. | ### Placement fields `get_products` returns product placement data when the seller includes `placements` or the buyer asks for it through `fields`. Placement IDs are publisher-scoped. Product placements should reference the publisher's public `adagents.json` placement declarations with `{publisher_domain, placement_id}` when a publisher declaration exists. Seller-private placement IDs, source/origin details, and delivery-system mappings must stay out of the response. Each returned placement may carry: | Field | Meaning | | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `placement_id` | Placement identifier in the publisher namespace. Buyers reference this with `publisher_domain` in `creative_assignments[].placement_refs`; legacy `placement_ids` strings are only unambiguous in single-publisher contexts. | | `publisher_domain` | Domain whose `adagents.json` defines the publisher-referenced placement. New multi-publisher products SHOULD include it. When omitted on legacy products, buyers may interpret `placement_id` relative to the seller agent's own publisher domain. | | `mode` | `targetable` means the buyer may reference the publisher-scoped placement, for example in `creative_assignments[].placement_refs`. `included` means the placement is part of the product composition but not buyer-selectable. | | `video_placement_types` | Declared video placement types for OLV and other video inventory, using the IAB Tech Lab/OpenRTB 2.6 `video.plcmt` definitions with AdCP-native names. Concrete placements usually declare one value; aggregate placements may declare multiple. | | `audio_distribution_types` | Declared audio distribution types for radio, streaming-audio, podcast, gaming, and other audio inventory, using the IAB Tech Lab/OpenRTB 2.6 `audio.feed` definitions with AdCP-native names. Concrete placements usually declare one value; aggregate placements may declare multiple. | | `sponsored_placement_types` | Declared sponsored-placement types for catalog-driven retail-media inventory. Concrete placements usually declare one value; aggregate placements may declare multiple. | | `social_placement_surfaces` | Declared social-placement surfaces for social inventory. Concrete placements usually declare one value; aggregate placements may declare multiple. | | `format_ids` / `format_options` | Placement-specific creative support. Product-level formats are the upper bound; placement-level formats narrow the effective accepted set for that placement and must not add formats the product does not accept. | Publishers can authorize sales agents for specific publisher placements using `authorized_agents[].placement_ids` or `authorized_agents[].placement_tags` in `adagents.json`. Sellers should only return publisher-referenced placements they are authorized to sell. Signal-targeting filter example: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/get-products-request.json", "buying_mode": "wholesale", "filters": { "signal_targeting": [ { "signal_ref": { "scope": "data_provider", "data_provider_domain": "pinnacle-data.example", "signal_id": "auto_intenders" }, "value_type": "binary", "value": true, "targeting_mode": "include" } ] }, "fields": [ "product_id", "name", "included_signals", "signal_targeting_allowed", "signal_targeting_options", "signal_targeting_rules", "pricing_options" ] } ``` ### Currency filtering Use `filters.pricing_currencies` when the buyer's constraint is "only show products whose media price I can transact in." Use `budget_range.currency` when the buyer is also providing a budget amount or range. Buyers MAY send both. Sellers apply them conjunctively: `budget_range.currency` denominates the budget amounts, while `pricing_currencies` narrows which returned product `pricing_options` are eligible. If the two fields conflict, sellers SHOULD return zero matching products rather than reject the request solely because of the conflict. Because product-scoped signal pricing is a separate add-on surface, this filter only gates mandatory seller-applied signal charges; optional signal or vendor add-ons may still advertise other currencies, and buyers should not select unsupported add-on prices. When combined with `is_fixed_price`, returned product `pricing_options` MUST satisfy both filters: the option must be fixed-price when requested and its `currency` must be in `pricing_currencies`. Currency-only filter example: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/get-products-request.json", "buying_mode": "wholesale", "filters": { "pricing_currencies": ["USD"] }, "fields": ["product_id", "name", "pricing_options"] } ``` If a product has both USD and EUR media pricing and the buyer sends `pricing_currencies: ["USD"]`, the seller returns the product with only its USD product-level `pricing_options`. If the product also has a fixed or otherwise mandatory product-scoped signal charge, that mandatory charge must either be priced in USD or have no incremental price; otherwise the product does not match the filter. A mandatory `custom` signal price without `currency` is not satisfiable for this filter unless the seller can truthfully treat it as having no incremental price. Optional signal add-ons do not affect product matching. ### Budget Range Object | Parameter | Type | Required | Description | | ---------- | ------ | -------- | -------------------------------------------------------- | | `currency` | string | Yes | ISO 4217 currency code (e.g., `"USD"`, `"EUR"`, `"GBP"`) | | `min` | number | No\* | Minimum budget amount | | `max` | number | No\* | Maximum budget amount | \*At least one of `min` or `max` must be specified. ### Refine array The `refine` array is a list of change requests. Each entry declares a `scope` and what the buyer is asking for. At least one entry is required. The seller considers all entries together when composing the response, and replies to each via `refinement_applied`. Each entry is a discriminated union on `scope`: #### scope: "request" | Field | Type | Required | Description | | ------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------- | | `scope` | string | Yes | `"request"` | | `ask` | string | Yes | Direction for the selection as a whole (e.g., `"more video options"`, `"suggest how to combine these products"`). | #### scope: "product" | Field | Type | Required | Description | | ------------ | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scope` | string | Yes | `"product"` | | `product_id` | string | Yes | Product ID from a previous `get_products` response | | `action` | string | No | `"include"` (default): return this product with updated pricing and data. `"omit"`: exclude from the response. `"more_like_this"`: find similar products (the original is also returned). When omitted, the seller treats the entry as `"include"`. | | `ask` | string | No | What the buyer is asking for. For `"include"`: specific changes (e.g., `"add 16:9 format"`). For `"more_like_this"`: what "similar" means (e.g., `"same audience but video format"`). Ignored when `action` is `"omit"`. | #### scope: "proposal" | Field | Type | Required | Description | | ------------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scope` | string | Yes | `"proposal"` | | `proposal_id` | string | Yes | Proposal ID from a previous `get_products` response | | `action` | string | No | `"include"` (default): return with updated allocations and pricing. `"omit"`: exclude from the response. `"finalize"`: request firm pricing and inventory hold (transitions a draft proposal to committed). When omitted, the seller treats the entry as `"include"`. | | `ask` | string | No | What the buyer is asking for (e.g., `"shift more budget toward video"`, `"reduce total by 10%"`). Ignored when `action` is `"omit"`. | ### refinement\_applied (response) When the seller receives a `refine` array, the response includes `refinement_applied` — an array matched by position. Each entry reports whether the ask was fulfilled: | Field | Type | Required | Description | | ------------- | ------ | -------------------------------- | -------------------------------------------------------------------------------------------------- | | `scope` | string | Yes | Echoes the scope (`"request"` / `"product"` / `"proposal"`) from the corresponding `refine` entry. | | `product_id` | string | Yes when `scope` is `"product"` | Echoes `product_id` from the corresponding refine entry. | | `proposal_id` | string | Yes when `scope` is `"proposal"` | Echoes `proposal_id` from the corresponding refine entry. | | `status` | string | Yes | `"applied"`: ask fulfilled. `"partial"`: partially fulfilled. `"unable"`: could not fulfill. | | `notes` | string | No | Seller explanation. Recommended when status is `"partial"` or `"unable"`. | ### Catalog discovery Pass a `catalog` to find advertising products that can promote your catalog items. The seller matches your catalog items against its inventory and returns products where matches exist. Supports all catalog types — a product catalog finds sponsored product slots, a job catalog finds job ad products, a flight catalog finds dynamic travel ads. The `catalog` field uses the same [Catalog](/dist/docs/3.1.20/creative/catalogs) object used throughout AdCP. You can reference a synced catalog by `catalog_id`, provide inline items, or use selectors to filter: | Field | Type | Description | | ------------ | ----------- | ------------------------------------------------------------------------------- | | `type` | CatalogType | Catalog type (required) — `product`, `job`, `hotel`, `flight`, `offering`, etc. | | `catalog_id` | string | Reference a synced catalog by ID | | `ids` | string\[] | Filter to specific item IDs | | `gtins` | string\[] | Filter by GTIN for cross-retailer matching (product type only) | | `tags` | string\[] | Filter by tags (OR logic) | | `category` | string | Filter by category | | `query` | string | Natural language filter | Products in the response include `catalog_types` (what catalog types they support) and `catalog_match` (which items matched). ## Response Returns an array of `products` and optionally `proposals`. ### Products Array | Field | Type | Description | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `product_id` | string | Unique product identifier | | `name` | string | Human-readable product name | | `description` | string | Detailed product description | | `publisher_properties` | PublisherProperty\[] | Array of publisher entries, each with `publisher_domain` and either `property_ids` or `property_tags` | | `format_ids` | FormatID\[] | Supported creative format IDs | | `delivery_type` | string | `"guaranteed"` or `"non_guaranteed"` | | `delivery_measurement` | DeliveryMeasurement | (Optional) How delivery is measured (impressions, views, etc.) | | `pricing_options` | PricingOption\[] | Available pricing models (CPM, CPCV, etc.). Auction options may include `floor_price` and optional `price_guidance`. Bid-based auction models (CPM, vCPM, CPC, CPCV, CPV) may also include optional `max_bid` (boolean). | | `shows` | CollectionSelector\[] | (Optional) Collections available in this product. Each entry has `publisher_domain` and `collection_ids`. Buyers resolve full collection objects from the referenced `adagents.json`. See [Collections and installments](/dist/docs/3.1.20/media-buy/product-discovery/collections-and-installments). | | `collection_targeting_allowed` | boolean | (Optional, default: false) Whether buyers can target a subset of this product's shows. When false, the product is a bundle. | | `data_provider_signals` | DataProviderSignalSelector\[] | (Optional, deprecated) Legacy/non-selectable metadata for data-provider signals already bundled into or associated with this product. New implementations should use `included_signals`. | | `included_signals` | SignalListing\[] | (Optional) Non-selectable signal metadata for signals already included in, bundled with, or planned into this product. These describe what the product is; buyers do not select them in package `signal_targeting_groups`. Data-provider and signal-source refs may be reference-only; product-local refs include inline `name` and `value_type`. | | `signal_targeting_allowed` | boolean | (Optional, default: false) Whether this product has a package-level signal targeting surface. Editability is controlled by `signal_targeting_rules`; fixed/default-only products still set this to true when applied signal groups are echoed. | | `signal_targeting_options` | ProductSignalTargetingOption\[] | (Optional) Inline product-scoped signal options the buyer may select, or the seller may apply when fixed/default, through `packages[].targeting_overlay.signal_targeting_groups`. May include per-signal `pricing_options`; product-scoped prices are authoritative for this product. Data-provider and signal-source refs may be reference-only; product-local refs include inline `name` and `value_type`. | | `signal_targeting_rules` | SignalTargetingRules | (Optional) Product-scoped composition rules for selectable signals, such as direct vs seller-planned resolution, optional, required, maximum, mutually exclusive, fixed selections, and group size limits. These limits belong on the product, not seller-wide `get_adcp_capabilities`, because products may be backed by different ad servers or seller planning layers. Fixed/default selections are applied by the seller and echoed on the resulting package state. | | `brief_relevance` | string | Why this product matches the brief (when brief provided) | | `measurement_readiness` | [MeasurementReadiness](/dist/docs/3.1.20/media-buy/conversion-tracking/#measurement-readiness) | (Optional) Whether the buyer's event setup is sufficient for this product's optimization. Only present when the seller can evaluate the buyer's account context. | | `measurement_terms` | [MeasurementTerms](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#measurement-terms-and-performance-standards) | (Optional) Seller's default billing measurement and makegood terms. Buyers may propose different terms at `create_media_buy`. | | `performance_standards` | [PerformanceStandard\[\]](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#measurement-terms-and-performance-standards) | (Optional) Seller's default performance standards (viewability, IVT, completion rate, brand safety, attention score). Buyers may propose different standards at `create_media_buy`. | | `cancellation_policy` | [CancellationPolicy](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models#cancellation-policy) | (Optional) Cancellation notice period and penalties for guaranteed products. Buyers accept these terms by creating a media buy against the product. | ### Publisher properties for non-URL inventory `publisher_properties[].publisher_domain` is the domain that anchors the publisher's `adagents.json` namespace. It is not required to be the URL where an ad is displayed, and it is not a placeholder for a physical venue, publication, station, screen network, or print title. Use the same selector shape for digital and non-digital products: * **Digital properties**: `publisher_domain` is usually the publisher domain whose `adagents.json` declares the website, app, channel, or CTV property. * **Print, static OOH, radio, cinema, and local TV**: `publisher_domain` is the operating publisher or network domain that publishes the authoritative property catalog. The actual inventory is identified by `property_ids`, `property_tags`, placements, collections, product metadata, and channel fields. * **Aggregated networks**: use `property_tags` when the product spans many properties, such as a tagged set of venues, publications, screens, stations, or local markets. Do not invent values like `"print"` or `"ooh"` for `publisher_domain`. Put channel meaning in `channels`, property meaning in the referenced property declarations, and sellable-package meaning in the product itself. For example, a product that spans tagged metro properties can use this selector inside its `publisher_properties` array: ```json theme={null} [ { "selection_type": "by_tag", "property_tags": ["metro", "station"] } ] ``` ### Proposals Array (Optional) Publishers may return proposals alongside products - structured media plans with budget allocations. See [Proposals](/dist/docs/3.1.20/media-buy/product-discovery/media-products#proposals) for details. | Field | Type | Description | | ----------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `proposal_id` | string | Unique identifier for finalizing this proposal and, once committed, executing it via `create_media_buy` | | `proposal_status` | string | Lifecycle state. `draft` means the proposal must be finalized via `get_products` refine action `finalize` before create. `committed` means the proposal can be executed via `create_media_buy` before `expires_at`. When absent, treat the proposal as ready to buy for backward compatibility. | | `name` | string | Human-readable name for the media plan | | `allocations` | ProductAllocation\[] | Budget allocations across products (percentages must sum to 100). Each allocation may include optional `start_time` and `end_time` for per-flight scheduling. | | `forecast` | DeliveryForecast | Aggregate delivery forecast for the proposal. Contains forecast points with metric ranges. See [Delivery Forecasts](/dist/docs/3.1.20/media-buy/product-discovery/media-products#delivery-forecasts) | | `total_budget_guidance` | object | Optional min/recommended/max budget guidance | | `brief_alignment` | string | How this proposal addresses the campaign brief | | `expires_at` | string | ISO 8601 timestamp when this proposal expires. For committed proposals, this is the inventory-hold deadline for `create_media_buy`. | Each `ForecastPoint` is one forecast row. Composite slices are encoded by multiple `dimensions[]` items on the same point, such as placement x country. Sibling points are parallel rows, not nested children. Dimension order has no meaning; buyers normalize row identity from `(forecast_range_unit, budget if present, product_id if present, dimensions sorted by kind)`. Buyers may compare rows at the same grain, but MUST NOT sum them unless the seller documents that the returned rows form a complete, non-overlapping partition. Standard delivery reporting verifies one-dimensional marginals, not exact cross-dimensional intersections. ### Pagination `pagination` is valid in all `get_products` modes, but its meaning follows the buying mode: * In `brief` mode, pagination bounds the seller's curated answer to the brief. A page is not a promise that every product matching the words in the brief has been enumerated. * In `refine` mode, pagination bounds the refined `products[]` result implied by the `refine` array and current filters. Proposals may accompany the page as plan metadata, but `pagination.max_results`, `has_more`, `cursor`, and `total_count` are scoped to the product result set, not to a separate proposal list or a combined product/proposal count. * In `wholesale` mode, pagination walks the wholesale product feed. This is the exhaustive/feed-style read and is the mode that pairs with wholesale feed versioning. Use cursor-based pagination to cap returned products in curated/refined responses or walk wholesale product feeds: | Request Parameter | Type | Description | | ------------------------ | ------- | ---------------------------------------------- | | `pagination.max_results` | integer | Maximum products per page (1-100, default: 50) | | `pagination.cursor` | string | Cursor from previous response for next page | | Response Field | Type | Description | | ------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pagination.has_more` | boolean | Whether more products are available | | `pagination.cursor` | string | Cursor to pass for the next page | | `pagination.total_count` | integer | Total products in this paginated result set (optional, not all backends support this). In `brief` / `refine`, this is the curated/refined product set, not the full seller catalog. | Pagination is optional. When omitted, the server returns the complete result set or a server-chosen default page. When the response includes `pagination.has_more: true`, pass `pagination.cursor` in the next request to get the next page using the same result-defining request context, except for the updated `pagination.cursor`. ### Response Metadata | Field | Type | Description | | ------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `property_list_applied` | boolean | \[AdCP 3.0] `true` if the agent filtered products based on the provided `property_list`. Absent or `false` if not provided or not supported. | | `catalog_applied` | boolean | `true` if the seller filtered results based on the provided `catalog`. Absent or `false` if no catalog was provided or the seller does not support catalog matching. | | `refinement_applied` | [RefinementResult\[\]](#refinement_applied-response) | Seller acknowledgment of each `refine` entry, matched by position. Only present when `buying_mode` is `"refine"`. See [refinement\_applied](#refinement_applied-response) above. | | `incomplete` | [IncompleteEntry\[\]](#incomplete-array) | Declares what the seller could not finish within the `time_budget` or due to internal limits. Each entry identifies a scope with a human-readable explanation. Absent when the response is fully complete. See [incomplete array](#incomplete-array) below. | | `filter_diagnostics` | object | Optional non-fatal observability block describing how `filters` narrowed the candidate set — `total_candidates` plus per-filter `excluded_by` counts (keyed by filter name). Disambiguates "no inventory" from "your filter excluded everything" when the result list is empty or unexpectedly small. Counts only — never product names — to avoid leaking competitive intelligence. See [filter\_diagnostics](#filter_diagnostics) below. | | `wholesale_feed_version` | string | Opaque token representing the version of the wholesale product feed state used to compose this response. Sellers implementing conditional-fetch (`if_wholesale_feed_version`) MUST return this on every wholesale-mode response so buyers can cache and probe later. Treat as opaque — no format, no ordering, no inspection. See [Wholesale feed versioning](#wholesale-feed-versioning). | | `pricing_version` | string | Optional opaque token representing the version of the pricing layer, including product `pricing_options` and nested `signal_targeting_options[].pricing_options`. When the seller supports independent pricing versioning, `pricing_version` changes when prices move but `wholesale_feed_version` changes only when structure/metadata moves. Sellers not separating these MAY omit `pricing_version` and use `wholesale_feed_version` for both. | | `cache_scope` | string | `"public"` or `"account"`. **REQUIRED on every response** (schema-enforced — the safety property of the two-layer cache depends on it). When the request had no `account`, MUST be `"public"`. When the request had `account`, the seller declares either `"public"` (account prices off the rate card — buyer dedupes) or `"account"` (account-specific overrides). See [Cache layering](#cache-layering). | | `unchanged` | boolean | Present and `true` ONLY when the request carried `if_wholesale_feed_version` (and/or `if_pricing_version`) matching the seller's current version for the buyer's `cache_scope`, in which case `products[]` MUST be omitted; `wholesale_feed_version`, `cache_scope`, and `pricing_version` (when used) MUST still be echoed. Sellers MUST NOT emit `unchanged: false` — absence of the field IS the "response carries products" signal (one shape per state). Buyers receiving `unchanged: true` MUST NOT mutate their local wholesale product mirror. | ### filter\_diagnostics When the seller can attribute exclusions to specific filters, the response MAY include a `filter_diagnostics` block. This is observability — not error reporting; sellers still silently exclude unmatched products per the filter-not-fail convention. Buyers use this to triage empty/small results without depending on its presence. `total_candidates` and `excluded_by` are independently optional — sellers whose baseline candidate set size is sensitive MAY emit `excluded_by` without `total_candidates`. | Field | Type | Description | | ----------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `semantics` | string | `"only"` (deterministic; counts products that would have been included if not for *this* filter alone — recommended for triage), `"any"` (counts products excluded by any filter; counts may overlap), or `"approximate"` (seller can't cleanly attribute exclusions to a single filter). Buyers SHOULD inspect `semantics` before doing arithmetic on counts. | | `total_candidates` | integer | Number of products considered before filters were applied. May be sampled or capped when the candidate pool is large. Optional. | | `excluded_by` | object | Keys are filter property names from the request (`pricing_currencies`, `required_metrics`, `required_geo_targeting`, `budget_range`, etc.). Each value is `{ count, values?, notes? }`. Only filters that meaningfully narrowed the set need appear. | | `excluded_by..count` | integer | Count of products excluded by this filter, interpreted per the parent `semantics` field. | | `excluded_by..values` | array | Optional list of the specific filter values that contributed to exclusions (e.g., `["completed_views"]` for `required_metrics`). Items are strings or objects depending on filter shape; opaque without filter-specific knowledge. | | `excluded_by..notes` | string | Optional human-readable note about the narrowing. | ```json theme={null} { "products": [], "filter_diagnostics": { "semantics": "only", "total_candidates": 47, "excluded_by": { "required_metrics": { "count": 31, "values": ["completed_views"] }, "required_geo_targeting": { "count": 9 }, "pricing_currencies": { "count": 3, "values": ["USD"] }, "budget_range": { "count": 7 } } } } ``` ### incomplete array When the seller cannot complete all work within the `time_budget` (or due to its own internal limits), the response includes `incomplete` — an array declaring what is missing. Buyers can use `estimated_wait` to decide whether to retry with a larger budget. | Field | Type | Required | Description | | ---------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scope` | string | Yes | `"products"`: not all inventory sources were searched. `"pricing"`: products returned but pricing is absent or unconfirmed. `"forecast"`: products returned but forecast data is absent. `"proposals"`: proposals were not generated or are incomplete. `"wholesale_feed"`: in wholesale mode, full feed enumeration could not complete. | | `description` | string | Yes | Human-readable explanation of what is missing and why. | | `estimated_wait` | Duration | No | How much additional time would resolve this scope. | ### Wholesale feed versioning A buyer that just synced a seller's wholesale product feed can ask "has anything changed since version X?" in one cheap call, regardless of feed size. Sellers return an opaque `wholesale_feed_version` on every wholesale-mode response; buyers pass it back via `if_wholesale_feed_version` on the next call and the seller MAY short-circuit with `unchanged: true` — no products payload, no per-page diff. Patterned on HTTP `ETag` / `If-None-Match`. This is the seller-side wholesale product feed returned by `get_products`. It is not a `sync_catalogs` feed; `sync_catalogs` manages buyer-provided campaign input feeds on the seller account. **Unchanged response example:** Request: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/get-products-request.json", "buying_mode": "wholesale", "if_wholesale_feed_version": "v2026-05-18T08:00:00Z-acme-rev412" } ``` Response (wholesale product feed unchanged): ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/get-products-response.json", "status": "completed", "message": "Wholesale product feed unchanged since v2026-05-18T08:00:00Z-acme-rev412.", "context_id": "ctx-abc-789", "unchanged": true, "wholesale_feed_version": "v2026-05-18T08:00:00Z-acme-rev412", "pricing_version": "v2026-05-18T08:00:00Z-acme-rev412", "cache_scope": "public" } ``` Response (wholesale product feed changed — full payload returned, abbreviated): ```json test=false theme={null} { "message": "Returning 50 of 312 products (wholesale feed version advanced).", "context_id": "ctx-abc-790", "wholesale_feed_version": "v2026-05-18T10:15:00Z-acme-rev415", "pricing_version": "v2026-05-18T10:15:00Z-acme-rev415", "cache_scope": "public", "products": [ { "product_id": "prod_premium_ctv_us", "name": "Premium CTV — US", "description": "Run-of-network CTV inventory across premium publishers.", "publisher_properties": [{ "publisher_domain": "streamhaus.example.com", "property_ids": ["primetime_ctv"] }], "format_ids": [{ "id": "video_ctv_1080p_30s" }], "delivery_type": "guaranteed", "pricing_options": [ { "pricing_option_id": "po_cpm_v2", "pricing_model": "cpm", "currency": "USD", "fixed_price": 18.50 } ] } ], "pagination": { "has_more": true, "cursor": "eyJvIjo1MH0=", "total_count": 312 } } ``` **Rules** * Tokens are **opaque**. No format, no ordering, no inspection. * A returned `wholesale_feed_version` is scoped to the request parameters that produced it. Buyers MUST cache the version alongside the `(account, filters, buying_mode, property_list, catalog)` tuple used. * `pricing_version` is an optional finer-grained token: when present, it changes when prices move but `wholesale_feed_version` changes only when structure/metadata moves. Common for rate-card sweeps that don't change product metadata. * **`if_pricing_version` requires `if_wholesale_feed_version`.** Pricing has no structural baseline of its own. Sending `if_pricing_version` without `if_wholesale_feed_version` is a schema-level error. The seller's evaluation is two-stage: wholesale feed mismatch returns the full payload (pricing is implicitly stale); wholesale feed match with pricing mismatch also returns the full payload (so the buyer sees updated `pricing_options`); both match → `unchanged: true`. * **`filters` canonicalization.** Sellers MUST treat the `filters` object as canonicalized before hashing into the `wholesale_feed_version` keyspace: keys MUST be sorted lexicographically, omitted-and-default values MUST be treated identically (a missing `delivery_type` key is the same scope as `delivery_type: null`), array values MUST be sorted where the filter has set semantics (e.g., `channels`, `format_ids`, `required_metrics`) and preserved-order where the filter has sequence semantics (e.g., `preferred_delivery_types`). Buyers that pass equivalent-but-differently-shaped filter objects MUST receive the same `wholesale_feed_version` from the seller. This rule prevents silent stale-mirror bugs from key-order or default-elision differences between buyer SDKs. **Forward-compat default:** new filter fields added in 3.x minor versions MUST declare set-vs-sequence semantics in their schema (via `x-canonicalization: set | sequence` or equivalent prose); absent an explicit declaration, the rule defaults to **set-semantics** (sort before hashing). Sellers and SDKs that drift on this default produce cache misses that consumers can't explain. * **Pagination interaction.** `wholesale_feed_version` describes the wholesale product feed as a whole, not individual pages. Sellers MUST return `wholesale_feed_version` on every paginated page (not only the first) when they declare `wholesale_feed_versioning.supported: true`; sellers that do not declare versioning SHOULD do the same. When the wholesale feed mutates between pages, the new version surfaces on the next page and the buyer MUST restart pagination from `cursor: null` — the partial pages they've already received describe a stale version. Sellers MAY alternatively snapshot the feed at the start of pagination and serve all pages from that snapshot under the original version; either implementation is conformant as long as `wholesale_feed_version` on a given page is the version that page belongs to. * **`unchanged: true` and in-progress pagination.** A buyer that is mid-pagination on `cursor: X` MAY send `if_wholesale_feed_version` matching the version their pages so far were drawn from. If the seller confirms `unchanged: true`, the response omits `products[]` and pagination envelope entirely; the buyer abandons their in-progress walk under that version with confidence that no further pages would have produced new data. Sellers MAY NOT use the conditional-fetch short-circuit to skip individual pages within an active pagination — `unchanged` is feed-versus-cached-version, not per-page. * Pre-v3.1 sellers that ignore `if_wholesale_feed_version` simply return the full payload — semantically correct, just inefficient (same as the unchanged-server path in HTTP). For pushed change tracking beyond conditional fetch, see `specs/wholesale-feed-webhooks.md`. Wholesale feed webhooks carry the changed product payload, pricing payload, removal tombstone, or bulk-change summary; `get_products` remains the repair and reconciliation read. ### Cache layering Sellers publish two notional layers: a **public layer** (the rate-card / structural view) and **per-account overlays** (custom deals, account-specific rate cards). The conditional-fetch path is layer-aware via `cache_scope`. **Why this matters.** A buyer mirroring wholesale products across N accounts at one seller doesn't want to hold N copies of inventory that's actually identical for every buyer. The public layer is the seller's published rate card; most accounts at most sellers price off it directly. Premium custom deals are the exception. **Two-layer cache.** | Layer | Cache key | What's stored | | --------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Public | `(agent, buying_mode, filters, property_list, catalog)` | `wholesale_feed_version_public`, the wholesale product feed payload as seen without an account ref | | Account overlay | `(agent, buying_mode, filters, property_list, catalog, account_id)` | `wholesale_feed_version_account`, the wholesale product feed payload as seen WITH this account ref, when `cache_scope: "account"` was returned | **Behavior.** * Requests without `account` always return `cache_scope: "public"`. Buyers cache under the public key. * Requests with `account` return `cache_scope: "public"` OR `"account"` (seller MUST declare; no default). * `"public"`: this account prices off the rate card. Buyer MAY dedupe — the version and payload are the same as the unauthenticated view. The buyer can serve subsequent requests for any account in `"public"` cache\_scope from a single public-layer entry. * `"account"`: this response carries account-specific overrides. Buyer caches under the account overlay key. * Sellers MAY downgrade an account from `"account"` back to `"public"` by returning `cache_scope: "public"` on a request that previously got `"account"` — buyers SHOULD interpret this as "this account no longer has overrides" and drop their account overlay. **Conditional fetch with `if_wholesale_feed_version`.** Send the token paired with whichever scope it was returned in. The seller compares against the current version for that scope. If the buyer's token belongs to an `"account"` scope but the seller responds with `cache_scope: "public"`, that's the downgrade signal — buyer drops the overlay. **Webhook invalidation.** Wholesale feed webhook events declare `applies_to.scope` on `*.priced` and `*.updated` payloads. Sellers MUST apply the same account/caller authorization predicate used by `get_products buying_mode: "wholesale"` when deciding which subscribers receive product webhooks: * `applies_to: { scope: "public" }` → invalidate the public-layer cache for the entity. All account overlays referencing that public version are also stale and SHOULD be refetched. * `applies_to: { scope: "account", account_ids: [...] }` → invalidate only the named accounts' overlays. The public layer is unaffected. * `applies_to: { scope: "account" }` without `account_ids` → the seller is withholding the affected set; the per-subscriber scope filter routes the event only to subscribers whose principal is in the affected set. Receiving the event means "your overlay is stale." See `specs/wholesale-feed-webhooks.md` §"Cache layering and event scoping" for the full webhook-side spec. **See schema for complete field list**: [`get-products-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/get-products-response.json) ## Common Scenarios ### Time-budgeted discovery Declare a time budget when you need fast results and can accept partial data. The seller returns what it can within the budget and declares what is incomplete: ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; const result = await testAgent.getProducts({ buying_mode: 'brief', brief: 'CTV and display for brand awareness', brand: { domain: 'acmecorp.com' }, time_budget: { interval: 10, unit: 'seconds' } }); if (result.success && result.data) { console.log(`Found ${result.data.products.length} products`); if (result.data.incomplete) { for (const entry of result.data.incomplete) { console.log(`Incomplete: ${entry.scope} — ${entry.description}`); if (entry.estimated_wait) { console.log(` Would resolve in ${entry.estimated_wait.interval} ${entry.estimated_wait.unit}`); } } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_with_time_budget(): result = await test_agent.simple.get_products( buying_mode='brief', brief='CTV and display for brand awareness', brand={ 'domain': 'acmecorp.com' }, time_budget={ 'interval': 10, 'unit': 'seconds' } ) print(f"Found {len(result.products)} products") for entry in result.get('incomplete', []): print(f"Incomplete: {entry['scope']} — {entry['description']}") if 'estimated_wait' in entry: wait = entry['estimated_wait'] print(f" Would resolve in {wait['interval']} {wait['unit']}") asyncio.run(discover_with_time_budget()) ``` A response with incomplete data — products are returned but some scopes are missing: ```json test=false theme={null} { "products": [ { "product_id": "prog-display-ros", "name": "Programmatic Display — Run of Site", "delivery_type": "non_guaranteed", "pricing_options": [{ "pricing_option_id": "cpm-ros", "pricing_model": "cpm", "currency": "USD", "fixed_price": 12.00 }] } ], "incomplete": [ { "scope": "products", "description": "Premium inventory not searched — requires publisher approval", "estimated_wait": { "interval": 60, "unit": "minutes" } }, { "scope": "forecast", "description": "Forecast model did not complete within budget", "estimated_wait": { "interval": 45, "unit": "seconds" } } ] } ``` ### Wholesale Product Discovery ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // wholesale mode: buyer applies their own audiences, no publisher curation const result = await testAgent.getProducts({ buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' }, filters: { delivery_type: 'non_guaranteed' } }); if (result.success && result.data) { console.log(`Found ${result.data.products.length} standard wholesale products`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_standard_wholesale_products(): # wholesale mode: buyer applies their own audiences, no publisher curation result = await test_agent.simple.get_products( buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' }, filters={ 'delivery_type': 'non_guaranteed' } ) print(f"Found {len(result.products)} standard wholesale products") asyncio.run(discover_standard_wholesale_products()) ``` ### Multi-Format Discovery ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // Find products supporting both video and display const result = await testAgent.getProducts({ buying_mode: 'brief', brief: 'Brand awareness campaign with video and display', brand: { domain: 'acmecorp.com' }, filters: { channels: ['display', 'ctv'] } }); if (result.success && result.data) { console.log(`Found ${result.data.products.length} products supporting video and display`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_multi_format(): # Find products supporting both video and display result = await test_agent.simple.get_products( buying_mode='brief', brief='Brand awareness campaign with video and display', brand={ 'domain': 'acmecorp.com' }, filters={ 'channels': ['display', 'ctv'] } ) print(f"Found {len(result.products)} products supporting video and display") asyncio.run(discover_multi_format()) ``` ### Budget and Date Filtering ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // Find products within budget and date range for specific countries and channels const result = await testAgent.getProducts({ buying_mode: 'brief', brief: 'Q2 campaign for athletic footwear in North America', brand: { domain: 'acmecorp.com' }, filters: { start_date: '2025-04-01', end_date: '2025-06-30', budget_range: { min: 50000, max: 100000, currency: 'USD' }, countries: ['US', 'CA'], channels: ['display', 'ctv', 'podcast'], delivery_type: 'guaranteed' } }); if (result.success && result.data) { console.log(`Found ${result.data.products.length} products for Q2 within budget`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_with_budget_and_dates(): # Find products within budget and date range for specific countries and channels result = await test_agent.simple.get_products( buying_mode='brief', brief='Q2 campaign for athletic footwear in North America', brand={ 'domain': 'acmecorp.com' }, filters={ 'start_date': '2025-04-01', 'end_date': '2025-06-30', 'budget_range': { 'min': 50000, 'max': 100000, 'currency': 'USD' }, 'countries': ['US', 'CA'], 'channels': ['display', 'ctv', 'podcast'], 'delivery_type': 'guaranteed' } ) print(f"Found {len(result.products)} products for Q2 within budget") asyncio.run(discover_with_budget_and_dates()) ``` ### Property Tag Resolution ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // Get products with property tags const result = await testAgent.getProducts({ buying_mode: 'brief', brief: 'Sports content', brand: { domain: 'acmecorp.com' } }); if (result.success && result.data) { // Products with property_tags in publisher_properties represent large networks // Use get_adcp_capabilities to discover the agent's portfolio const productsWithTags = result.data.products.filter(p => p.publisher_properties?.some(pub => pub.property_tags && pub.property_tags.length > 0) ); console.log(`${productsWithTags.length} products use property tags (large networks)`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_property_tags(): # Get products with property tags result = await test_agent.simple.get_products( buying_mode='brief', brief='Sports content', brand={ 'domain': 'acmecorp.com' } ) # Products with property_tags in publisher_properties represent large networks # Use get_adcp_capabilities to discover the agent's portfolio products_with_tags = [p for p in result.products if any(pub.get('property_tags') for pub in p.get('publisher_properties', []))] print(f"{len(products_with_tags)} products use property tags (large networks)") asyncio.run(discover_property_tags()) ``` ### Guaranteed Delivery Products ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // Find guaranteed delivery products for measurement const result = await testAgent.getProducts({ buying_mode: 'brief', brief: 'Guaranteed delivery for lift study', brand: { domain: 'acmecorp.com' }, filters: { delivery_type: 'guaranteed', min_exposures: 100000 } }); if (result.success && result.data) { console.log(`Found ${result.data.products.length} guaranteed products with 100k+ exposures`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_guaranteed(): # Find guaranteed delivery products for measurement result = await test_agent.simple.get_products( buying_mode='brief', brief='Guaranteed delivery for lift study', brand={ 'domain': 'acmecorp.com' }, filters={ 'delivery_type': 'guaranteed', 'min_exposures': 100000 } ) print(f"Found {len(result.products)} guaranteed products with 100k+ exposures") asyncio.run(discover_guaranteed()) ``` ### Standard Formats Only ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // Find products that only accept IAB standard formats const result = await testAgent.getProducts({ buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' }, filters: { standard_formats_only: true } }); if (result.success && result.data) { console.log(`Found ${result.data.products.length} products with standard formats only`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_standard_formats(): # Find products that only accept IAB standard formats result = await test_agent.simple.get_products( buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' }, filters={ 'standard_formats_only': True } ) print(f"Found {len(result.products)} products with standard formats only") asyncio.run(discover_standard_formats()) ``` ### Catalog-driven discovery Use `catalog` with a brand to discover advertising products that can promote your catalog items. The seller matches your items against its inventory and returns products where matches exist: ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // Discover retail media products for specific catalog items const result = await testAgent.getProducts({ buying_mode: 'wholesale', brand: { domain: 'acmecorp.com' }, catalog: { type: 'product', tags: ['ketchup', 'organic'], category: 'food/condiments' }, filters: { channels: ['retail_media'] } }); if (result.success && result.data) { if (result.data.catalog_applied) { console.log(`Found ${result.data.products.length} products with catalog matches`); } else { console.log('Seller does not support catalog matching'); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_commerce_products(): # Discover retail media products for specific catalog items result = await test_agent.simple.get_products( buying_mode='wholesale', brand={ 'domain': 'acmecorp.com' }, catalog={ 'type': 'product', 'tags': ['ketchup', 'organic'], 'category': 'food/condiments' }, filters={ 'channels': ['retail_media'] } ) if result.get('catalog_applied'): print(f"Found {len(result.products)} products with catalog matches") else: print("Seller does not support catalog matching") asyncio.run(discover_commerce_products()) ``` ```bash CLI requires-env=ADCP_AUTH_TOKEN theme={null} uvx adcp \ https://test-agent.adcontextprotocol.org/sales/mcp \ get_products \ '{"buying_mode":"wholesale","brand":{"domain":"acmecorp.com"},"catalog":{"type":"product","tags":["ketchup","organic"],"category":"food/condiments"},"filters":{"channels":["retail_media"]}}' \ --auth $ADCP_AUTH_TOKEN ``` You can also use GTIN matching, reference a synced catalog, or discover products for other catalog types: ```json theme={null} { "catalog": { "type": "product", "gtins": ["00013000006040", "00013000006057"] } } ``` ```json theme={null} { "catalog": { "catalog_id": "gmc-primary", "type": "product" } } ``` ```json theme={null} { "catalog": { "type": "job", "catalog_id": "chef-vacancies" } } ``` ### Property List Filtering **AdCP 3.0** - Property list filtering requires governance agent support. Filter products to only those available on properties in your approved list: ```javascript JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; // Filter products by property list from governance agent const result = await testAgent.getProducts({ buying_mode: 'brief', brief: 'Brand-safe inventory for family brand', brand: { domain: 'acmecorp.com' }, property_list: { agent_url: 'https://governance.example.com', list_id: 'pl_brand_safe_2024' } }); if (result.success && result.data) { // Check if filtering was actually applied if (result.data.property_list_applied) { console.log(`Found ${result.data.products.length} products on approved properties`); } else { console.log('Agent does not support property list filtering'); console.log(`Found ${result.data.products.length} products (unfiltered)`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def discover_with_property_list(): # Filter products by property list from governance agent result = await test_agent.simple.get_products( buying_mode='brief', brief='Brand-safe inventory for family brand', brand={ 'domain': 'acmecorp.com' }, property_list={ 'agent_url': 'https://governance.example.com', 'list_id': 'pl_brand_safe_2024' } ) # Check if filtering was actually applied if result.get('property_list_applied'): print(f"Found {len(result['products'])} products on approved properties") else: print("Agent does not support property list filtering") print(f"Found {len(result['products'])} products (unfiltered)") asyncio.run(discover_with_property_list()) ``` **Note**: If `property_list_applied` is absent or `false`, the sales agent did not filter products. This can happen if: * The agent doesn't support property governance features * The agent couldn't access the property list * The property list had no effect on the available inventory #### Property Targeting Behavior Products have a `property_targeting_allowed` flag that affects filtering: * **`property_targeting_allowed: false` (default)**: Product is "all or nothing" - excluded unless your list contains all of its properties * **`property_targeting_allowed: true`**: Product is included if there's any intersection between its properties and your list This allows publishers to offer run-of-network products that can't be cherry-picked alongside flexible inventory that buyers can filter. See [Property Targeting](/dist/docs/3.1.20/media-buy/product-discovery/media-products#property-targeting) for more details and [Property Governance](/dist/docs/3.1.20/governance/property/specification) for more on property lists. ## Refinement After initial discovery, use `buying_mode: "refine"` to iterate on specific products and proposals. The `refine` array is a list of change requests — each entry declares a scope and what the buyer is asking for. The seller returns updated products with revised pricing and configurations, plus `refinement_applied` acknowledging each ask. See the [Refinement guide](/dist/docs/3.1.20/media-buy/product-discovery/refinement) for the full walkthrough: scope types, action semantics, seller responses, and common patterns. The parameter shape is defined in the [Refine array](#refine-array) section above. Minimal example: ```json test=false theme={null} { "buying_mode": "refine", "refine": [ { "scope": "request", "ask": "more video, less display" }, { "scope": "product", "product_id": "prod_premium_video", "ask": "add 16:9 format option" }, { "scope": "product", "product_id": "prod_display_run_of_site", "action": "omit" }, { "scope": "proposal", "proposal_id": "prop_awareness_q2", "ask": "reallocate display budget to video" } ], "filters": { "start_date": "2026-04-01", "end_date": "2026-04-30", "budget_range": { "min": 200000, "max": 200000, "currency": "USD" } } } ``` Key rules to know before sending: * **`refine` is only valid in `refine` mode.** Requests that include this field in `brief` or `wholesale` mode are rejected with `INVALID_REQUEST`. * **Filters are absolute**, not deltas. Always send the full filter set you want applied. * **Proposals are actionable through status.** `proposal_status: "draft"` requires finalization before create; `proposal_status: "committed"` can be executed with `create_media_buy(proposal_id)` before `expires_at`; absent status is legacy ready-to-buy. * **Proposals are ephemeral.** Proposals typically include an `expires_at` timestamp. After expiration, the seller returns `PROPOSAL_EXPIRED`. * **Product IDs are stable catalog identifiers.** Custom products (`is_custom: true`) may have an `expires_at` timestamp, after which refinement returns `PRODUCT_NOT_FOUND`. ## Error Handling | Error Code | Description | Resolution | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `AUTH_MISSING` | No credentials presented | Provide credentials via auth header | | `AUTH_INVALID` | Credentials rejected (expired / revoked) | Human credential rotation required; do not auto-retry | | `INVALID_REQUEST` | Brief too long or malformed filters | Check request parameters | | `PRODUCT_NOT_FOUND` | One or more referenced product IDs are unknown or expired | Remove invalid IDs and retry, or re-discover with a `brief` request | | `PROPOSAL_EXPIRED` | A referenced proposal ID has passed its `expires_at` timestamp | Re-discover with a new `brief` or `wholesale` request | | `PROPOSAL_NOT_FOUND` | The referenced `proposal_id` is unknown to the seller (never finalized, wrong tenant, or evicted from cache) | Re-issue `get_products` in `refine` mode with `action: 'finalize'` to obtain a current proposal\_id | | `MULTI_FINALIZE_UNSUPPORTED` | `refine[]` carried multiple `action: 'finalize'` entries but the seller cannot guarantee atomic multi-proposal commit | Sequence single-proposal finalize calls — one finalize entry per `get_products` call | | `POLICY_VIOLATION` | Category blocked for advertiser | See policy response message for details | ### Authentication Comparison See the difference between authenticated and unauthenticated access: ```javascript JavaScript theme={null} import { testAgent, testAgentNoAuth } from '@adcp/sdk/testing'; // WITH authentication - full product results with pricing const fullProducts = await testAgent.getProducts({ buying_mode: 'brief', brief: 'Premium CTV inventory for brand awareness', brand: { domain: 'acmecorp.com' } }); if (!fullProducts.success) { throw new Error(`Failed to get products: ${fullProducts.error}`); } console.log(`With auth: ${fullProducts.data.products.length} products`); console.log(`First product pricing: ${fullProducts.data.products[0].pricing_options.length} options`); // WITHOUT authentication - limited public product results const publicProducts = await testAgentNoAuth.getProducts({ buying_mode: 'brief', brief: 'Premium CTV inventory for brand awareness', brand: { domain: 'acmecorp.com' } }); if (!publicProducts.success) { throw new Error(`Failed to get products: ${publicProducts.error}`); } console.log(`Without auth: ${publicProducts.data.products.length} products`); console.log(`First product pricing: ${publicProducts.data.products[0].pricing_options?.length || 0} options`); ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent, test_agent_no_auth async def compare_auth(): # WITH authentication - full product results with pricing full_products = await test_agent.simple.get_products( buying_mode='brief', brief='Premium CTV inventory for brand awareness', brand={ 'domain': 'acmecorp.com' } ) print(f"With auth: {len(full_products['products'])} products") print(f"First product pricing: {len(full_products['products'][0]['pricing_options'])} options") # WITHOUT authentication - limited public product results public_products = await test_agent_no_auth.simple.get_products( buying_mode='brief', brief='Premium CTV inventory for brand awareness', brand={ 'domain': 'acmecorp.com' } ) print(f"Without auth: {len(public_products['products'])} products") print(f"First product pricing: {len(public_products['products'][0].get('pricing_options', []))} options") asyncio.run(compare_auth()) ``` **Key Differences:** * **Product Count**: Authenticated access returns more products, including private/custom offerings * **Pricing Information**: Only authenticated requests receive detailed pricing options (CPM, CPCV, etc.) * **Targeting Details**: Custom targeting capabilities may be restricted to authenticated users * **Rate Limits**: Unauthenticated requests have lower rate limits ## Authentication Behavior * **Without credentials**: Returns limited public product results, no pricing, no custom offerings * **With credentials**: Returns complete product results with pricing and custom products See [Authentication Guide](/dist/docs/3.1.20/building/by-layer/L2/authentication) for details. ## Asynchronous Operations Most product searches complete immediately, but some scenarios require asynchronous processing. When this happens, you'll receive a status other than `completed`. A `submitted` response with `task_id` is always pollable through `get_task_status` (legacy `tasks/get`); `push_notification_config` adds webhook notification for background workflows. #### SDK Status Handling ```typescript theme={null} const initial = await agent.getProducts(params); const final = initial.status === 'submitted' ? await initial.submitted!.waitForCompletion(30000) : initial; if (final.status === 'failed') { throw new Error(final.error?.message ?? 'get_products failed'); } if (final.status !== 'completed') { throw new Error(`Unhandled get_products status: ${final.status}`); } for (const product of final.products) { console.log(product.name); } ``` ### When Search Runs Asynchronously Product search may require async processing in these situations: * **Complex searches**: Searching across multiple inventory sources or custom curation * **Needs clarification**: Your brief is vague and the system needs more information * **Custom products**: Bespoke product packages that require human review ### Async Status Flow #### Immediate Completion (Most Common) ```json theme={null} POST /api/mcp/call_tool { "name": "get_products", "arguments": { "buying_mode": "brief", "brief": "CTV inventory for sports audience", "brand": { "domain": "acmecorp.com" } } } Response (200 OK): { "status": "completed", "message": "Found 3 products matching your requirements", "products": [...] } ``` #### Needs Clarification When the brief is unclear, the system asks for more details: ```json theme={null} Response (200 OK): { "status": "input-required", "message": "I need a bit more information. What's your budget range and campaign duration?", "task_id": "task_789", "context_id": "ctx_123", "reason": "CLARIFICATION_NEEDED", "partial_results": [], "suggestions": ["$50K-$100K", "1 month", "Q1 2024"] } ``` Continue the conversation with the same `context_id`: ```json theme={null} POST /api/mcp/continue { "context_id": "ctx_123", "message": "Budget is $75K for a 3-week campaign in March" } Response (200 OK): { "status": "completed", "message": "Perfect! Found 5 products within your budget", "products": [...] } ``` #### Complex Search (With Webhook and Polling) For searches requiring deep inventory analysis, configure a webhook for terminal completion/failure notification. The returned `task_id` remains valid for polling via `get_task_status` (legacy `tasks/get`). ```json theme={null} POST /api/mcp/call_tool { "name": "get_products", "arguments": { "buying_mode": "brief", "brief": "Premium inventory across all formats for luxury automotive brand", "brand": { "domain": "acmecorp.com" }, "push_notification_config": { "url": "https://buyer.com/webhooks/adcp/get_products", "authentication": { "schemes": ["Bearer"], "credentials": "secret_token_32_chars" } } } } Response (200 OK): { "status": "submitted", "message": "Custom curation queued; typical turnaround 10-30 minutes", "task_id": "task_456", "context_id": "ctx_123", "estimated_completion": "2025-01-22T10:30:00Z" } // Later, poll get_task_status/tasks/get with task_id, or receive webhook POST to https://buyer.com/webhooks/adcp/get_products { "task_id": "task_456", "task_type": "get_products", "status": "completed", "timestamp": "2025-01-22T10:30:00Z", "message": "Found 12 premium products across all formats", "result": { "products": [...] } } ``` #### Immediate Completion (Most Common) ```json theme={null} POST /api/a2a { "message": { "role": "user", "parts": [{ "kind": "data", "data": { "skill": "get_products", "parameters": { "buying_mode": "brief", "brief": "CTV inventory for sports audience", "brand": { "domain": "acmecorp.com" } } } }] } } Response (200 OK): { "id": "task_123", "contextId": "ctx_456", "artifact": { "kind": "data", "data": { "products": [...] } }, "status": { "state": "completed", "message": { "role": "agent", "parts": [{ "text": "Found 3 products matching your requirements" }] } } } ``` #### Needs Clarification Real-time updates via SSE when clarification is needed: ```json theme={null} // Initial response { "id": "task_789", "contextId": "ctx_123", "status": { "state": "input-required", "message": { "role": "agent", "parts": [ { "text": "I need a bit more information. What's your budget range and campaign duration?" }, { "data": { "reason": "CLARIFICATION_NEEDED", "suggestions": ["$50K-$100K", "1 month", "Q1 2024"] } } ] } } } // Send follow-up POST /api/a2a { "contextId": "ctx_123", "message": { "role": "user", "parts": [{ "text": "Budget is $75K for a 3-week campaign in March" }] } } // SSE update: task completed { "id": "task_789", "contextId": "ctx_123", "artifact": { "kind": "data", "data": { "products": [...] } }, "status": { "state": "completed", "message": { "role": "agent", "parts": [{ "text": "Perfect! Found 5 products within your budget" }] } } } ``` #### Complex Search (With Webhook and Polling) For A2A, push notifications use the transport-level `configuration.pushNotificationConfig` field. Do not put snake\_case `push_notification_config` inside the skill parameters. The A2A task id remains valid for task polling. ```json theme={null} POST /api/a2a { "message": { "role": "user", "parts": [{ "kind": "data", "data": { "skill": "get_products", "parameters": { "buying_mode": "brief", "brief": "Premium inventory across all formats for luxury automotive brand", "brand": { "domain": "acmecorp.com" } } } }] }, "configuration": { "pushNotificationConfig": { "url": "https://buyer.com/webhooks/a2a/get_products", "authentication": { "schemes": ["bearer"], "credentials": "secret_token_32_chars" } } } } Response (200 OK): { "id": "task_456", "contextId": "ctx_789", "status": { "state": "submitted", "message": { "role": "agent", "parts": [ { "text": "Custom curation queued; typical turnaround 10-30 minutes" }, { "data": { "estimated_completion": "2025-01-22T10:30:00Z" } } ] } } } // Later, poll the A2A task, or receive webhook POST to https://buyer.com/webhooks/a2a/get_products { "id": "task_456", "contextId": "ctx_789", "artifact": { "kind": "data", "data": { "products": [...] } }, "status": { "state": "completed", "message": { "role": "agent", "parts": [ { "text": "Found 12 premium products across all formats" }, { "data": { "products": [...] } } ] }, "timestamp": "2025-01-22T10:30:00Z" } } ``` ### Status Overview | Status | When It Happens | What You Do | | ---------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `completed` | Search finished successfully | Process the product results | | `input-required` | Need clarification on the brief | Answer the question and continue | | `working` | Searching across multiple sources | Wait on the open connection / transport progress stream | | `submitted` | Custom curation queued | Poll `get_task_status` (legacy `tasks/get`) with `task_id`; also wait for webhook notification if `push_notification_config` / A2A `configuration.pushNotificationConfig` was accepted | | `failed` | Search couldn't complete | Check error message, adjust brief | **Note:** For the complete status list see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle). **Most searches complete immediately.** Async processing is only needed for complex scenarios or when the system needs your input. ## Next Steps After discovering products: 1. **Review Options**: Compare products, pricing, and targeting capabilities 2. **Create Media Buy**: Use [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) to execute campaign 3. **Prepare Creatives**: Use [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) to see format requirements 4. **Supply Assets**: Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for library-backed sellers, or inline `packages[].creatives` for inline-only sellers ## Learn More * [Product Discovery Guide](/dist/docs/3.1.20/media-buy/product-discovery/) - Understanding briefs and products * [Pricing Models](/dist/docs/3.1.20/media-buy/advanced-topics/pricing-models) - CPM, CPCV, CPP explained * [Brief Expectations](/dist/docs/3.1.20/media-buy/product-discovery/brief-expectations) - How to write effective briefs * [Media Products](/dist/docs/3.1.20/media-buy/product-discovery/media-products) - Product structure and fields # Task Reference Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/index AdCP media buy task reference — all tasks for product discovery, campaign creation, delivery reporting, creatives, audiences, and conversion tracking with schemas and examples. Complete reference for all AdCP Media Buy tasks. Each task is designed for AI agents to automate specific parts of the advertising workflow. ## All Tasks Overview | Task | Purpose | Response Time | Phase | | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ------------- | ------------------- | | [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) | Discover inventory and refine products | \~60s | Discovery | | [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) | Create campaigns from selected products | Minutes-Days | Media Buys | | [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) | Modify campaign settings and budgets | Minutes-Days | Media Buys | | [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) | View supported creative specifications | \~1s | Capability | | [`sync_catalogs`](/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs) | Sync catalog feeds (products, stores, inventory) | Minutes-Days | Catalogs | | [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) | Upload and manage creative assets | Minutes-Days | Creatives | | [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) | Query creative library with filtering | \~1s | Creatives | | [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) | Retrieve media buy status, creative approvals, and delivery snapshots | \~1s | Monitoring | | [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) | Retrieve performance and delivery data | \~60s | Reporting | | [`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) | Submit performance signals for optimization | \~1s | Optimization | | [`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) | Configure event sources for conversion tracking | \~1s | Conversion Tracking | | [`log_event`](/dist/docs/3.1.20/media-buy/task-reference/log_event) | Send marketing events for attribution | \~1s | Conversion Tracking | | [`sync_audiences`](/dist/docs/3.1.20/media-buy/task-reference/sync_audiences) | Upload and manage first-party CRM audiences | Minutes-Days | Audiences | ## Response Time Categories AdCP tasks fall into four response time categories: ### 🟢 Instant (\~1 second) **Simple lookups and event ingestion** * [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) - Format specifications * [`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives) - Creative library queries * [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) - Media buy status and creative approvals * [`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback) - Performance signal submission * [`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) - Event source configuration * [`log_event`](/dist/docs/3.1.20/media-buy/task-reference/log_event) - Event ingestion ### 🟡 Processing (\~60 seconds) **AI/LLM inference with backend systems** * [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) - Natural language product discovery and refinement * [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) - Performance data aggregation ### 🟠 Asynchronous (Minutes to Days) **Complex operations with potential human approval** * [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) - Campaign creation and validation * [`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy) - Campaign modifications * [`sync_catalogs`](/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs) - Catalog feed processing and review * [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) - Creative asset processing * [`sync_audiences`](/dist/docs/3.1.20/media-buy/task-reference/sync_audiences) - Audience matching and processing ## Task Categories by Workflow ### Account Management Before placing media buys, establish a commercial relationship with the seller. These tasks are shared across all vendor protocols and live in the Commerce Protocol section: * **[`sync_accounts`](/dist/docs/3.1.20/accounts/tasks/sync_accounts)** - Declare brand/operator pairs and billing; seller provisions accounts * **[`list_accounts`](/dist/docs/3.1.20/accounts/tasks/list_accounts)** - Check account status and retrieve active `account_id` values ### Discovery & Planning Start here to understand what's available and plan your campaign. * **[`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities)** - Discover agent capabilities, portfolio, and supported features (protocol-level task) * **[`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products)** - The core discovery task using natural language briefs * **[`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats)** - Understand creative requirements ### Media Buy Management Create and manage your advertising campaigns. * **[`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy)** - Create campaigns from discovered products * **[`update_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/update_media_buy)** - Modify budgets, targeting, and settings ### Catalog Management Sync product feeds, inventory, and store data to seller accounts. * **[`sync_catalogs`](/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs)** - Push catalog feeds with platform review and approval ### Creative Management Handle creative assets throughout their lifecycle. * **[`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives)** - Upload assets to an agent-hosted creative library * **[`list_creatives`](/dist/docs/3.1.20/creative/task-reference/list_creatives)** - Search and manage your creative library (creative protocol) * **Inline `packages[].creatives`** - Attach or replace package-scoped creative bodies when the seller advertises `inline_creative_management` without a creative library ### Performance & Optimization Monitor and optimize campaign performance. * **[`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys)** - Check campaign status, creative approvals, and near-real-time delivery snapshots * **[`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery)** - Track delivery and performance metrics for reporting * **[`provide_performance_feedback`](/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback)** - Submit performance signals for publisher optimization ### Conversion Tracking Configure event sources and send marketing events for attribution. * **[`sync_event_sources`](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources)** - Configure event sources on seller accounts * **[`log_event`](/dist/docs/3.1.20/media-buy/task-reference/log_event)** - Send marketing events for attribution ### Audience Management Upload and manage first-party CRM audiences for targeting. * **[`sync_audiences`](/dist/docs/3.1.20/media-buy/task-reference/sync_audiences)** - Upload hashed customer lists and check matching status ## Schema Reference All tasks include JSON schema definitions for requests and responses: * **Request Schemas**: `/schemas/3.1.20/media-buy/[task-name]-request.json` * **Response Schemas**: `/schemas/3.1.20/media-buy/[task-name]-response.json` **Task Management**: For tracking async operations across all AdCP domains, see [Task Lifecycle](/dist/docs/3.1.20/building/by-layer/L3/task-lifecycle). Schemas are accessible at runtime via the documentation server for validation and tooling. ## Common Patterns ### Task Naming Conventions Task names use snake\_case and follow verb-first semantics consistently across Media Buy: * `get_*`: Retrieve current state or scoped datasets (for example `get_products`, `get_media_buys`, `get_media_buy_delivery`) * `list_*`: Enumerate collections with optional filtering (for example `list_creative_formats`, `list_creatives`) * `create_*`: Create new resources (`create_media_buy`) * `update_*`: Apply partial updates to existing resources (`update_media_buy`) * `sync_*`: Reconcile external state into seller systems with upsert-like behavior (`sync_catalogs`, `sync_creatives`, `sync_event_sources`) * `log_*`: Ingest append-only event records (`log_event`) * `provide_*`: Submit optimization or feedback signals (`provide_performance_feedback`) ### Error Handling All tasks follow consistent error patterns with: * HTTP status codes for different error types * Structured error messages with context * Retry guidance for transient failures ### Authentication Tasks require proper authentication via: * API keys for service-to-service calls * Authenticated agent and account scope for multi-tenant operations * Permission validation for resource access ### Asynchronous Operations Long-running tasks provide: * Immediate response with operation ID * Status polling endpoints for progress * Webhook notifications for completion ## Getting Started 1. **Discover Capabilities**: Use [`get_adcp_capabilities`](/dist/docs/3.1.20/protocol/get_adcp_capabilities) to understand what the agent supports 2. **Find Inventory**: Use [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) to find relevant inventory 3. **Refine Products**: Re-call [`get_products`](/dist/docs/3.1.20/media-buy/task-reference/get_products) with `buying_mode: "refine"` to iterate on budgets, pricing, and targeting 4. **Understand Formats**: Check [`list_creative_formats`](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) for requirements 5. **Sync Catalogs**: Use [`sync_catalogs`](/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs) to push product feeds to the account 6. **Supply Creatives**: Use [`sync_creatives`](/dist/docs/3.1.20/creative/task-reference/sync_creatives) for library-backed sellers, or inline `packages[].creatives` for inline-only sellers 7. **Create Campaign**: Use [`create_media_buy`](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) with selected products 8. **Check Operational State**: Use [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) for status, approvals, and missing formats 9. **Monitor Performance**: Track reporting metrics with [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) For reporting and reconciliation, treat [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) as the authoritative, billing-grade source. Use [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys) snapshots for operational monitoring only. ## Related Documentation * **[Product Discovery](/dist/docs/3.1.20/media-buy/product-discovery/)** - Natural language inventory discovery * **[Media Buys](/dist/docs/3.1.20/media-buy/media-buys/)** - Campaign lifecycle management * **[Creatives](/dist/docs/3.1.20/media-buy/creatives/)** - Creative asset management * **[Advanced Topics](/dist/docs/3.1.20/media-buy/advanced-topics/)** - Targeting, security, and architecture # log_event Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/log_event log_event task — send conversion and marketing events to AdCP sellers in batches. Supports attribution, campaign optimization, ROAS measurement, and test events. Send conversion or marketing events for attribution and optimization. Supports batch submissions, test events, and partial failure reporting. **Response Time**: \~1s (events are queued for processing) **Request Schema**: [`/schemas/3.1.20/media-buy/log-event-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/log-event-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/log-event-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/log-event-response.json) ## Quick Start Log a purchase event: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { LogEventResponseSchema } from "@adcp/sdk"; const result = await testAgent.logEvent({ event_source_id: "website_pixel", events: [ { event_id: "evt_purchase_12345", event_type: "purchase", event_time: "2026-01-15T14:30:00Z", action_source: "website", event_source_url: "https://www.example.com/checkout/confirm", user_match: { click_id: "abc123def456", click_id_type: "gclid", }, custom_data: { value: 149.99, currency: "USD", order_id: "order_98765", num_items: 3, }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } // Validate response against schema const validated = LogEventResponseSchema.parse(result.data); // Check for operation-level errors first (discriminated union) if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("events_received" in validated) { console.log(`Received: ${validated.events_received}, Processed: ${validated.events_processed}`); if (validated.match_quality !== undefined) { console.log(`Match quality: ${(validated.match_quality * 100).toFixed(0)}%`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.log_event( event_source_id='website_pixel', events=[{ 'event_id': 'evt_purchase_12345', 'event_type': 'purchase', 'event_time': '2026-01-15T14:30:00Z', 'action_source': 'website', 'event_source_url': 'https://www.example.com/checkout/confirm', 'user_match': { 'click_id': 'abc123def456', 'click_id_type': 'gclid' }, 'custom_data': { 'value': 149.99, 'currency': 'USD', 'order_id': 'order_98765', 'num_items': 3 } }] ) # Check for operation-level errors first if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") print(f"Received: {result.events_received}, Processed: {result.events_processed}") if hasattr(result, 'match_quality') and result.match_quality is not None: print(f"Match quality: {result.match_quality * 100:.0f}%") asyncio.run(main()) ``` ## Request Parameters | Parameter | Type | Required | Description | | ----------------- | ------------------------------------------------------------------ | -------- | ---------------------------------------------------------------- | | `event_source_id` | string | Yes | Event source configured on the account via `sync_event_sources` | | `events` | [Event](/dist/docs/3.1.20/media-buy/conversion-tracking/#event)\[] | Yes | Events to log (min 1, max 10,000) | | `test_event_code` | string | No | Test event code for validation without affecting production data | ### Event Object | Field | Type | Required | Description | | ------------------- | ------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `event_id` | string | Yes | Unique identifier for deduplication (scoped to event\_type + event\_source\_id). Max 256 chars. | | `event_type` | [EventType](/dist/docs/3.1.20/media-buy/conversion-tracking/#event-types) | Yes | Standard event type (e.g. `purchase`, `lead`, `add_to_cart`) | | `event_time` | date-time | Yes | ISO 8601 timestamp when the event occurred | | `user_match` | [UserMatch](/dist/docs/3.1.20/media-buy/conversion-tracking/#user-match) | No | User identifiers for attribution matching | | `custom_data` | [CustomData](/dist/docs/3.1.20/media-buy/conversion-tracking/#custom-data) | No | Event-specific data (value, currency, items) | | `action_source` | [ActionSource](/dist/docs/3.1.20/media-buy/conversion-tracking/#action-sources) | No | Where the event occurred (`website`, `app`, `in_store`, etc.) | | `surface` | [EventSurface](/dist/docs/3.1.20/media-buy/conversion-tracking/#event-surfaces) | No | Structured surface context for the event, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store | | `event_source_url` | uri | No | URL where the event occurred (required when action\_source is `website`) | | `custom_event_name` | string | No | Name for custom events (used when event\_type is `custom`) | ### User Match Object At least one of `uids`, `hashed_email`, `hashed_phone`, `click_id`, or `client_ip` + `client_user_agent` is required: | Field | Type | Description | | ------------------- | ------ | ----------------------------------------------------------------------- | | `uids` | UID\[] | Universal ID values (`rampid`, `id5`, `uid2`, `euid`, `pairid`, `maid`) | | `hashed_email` | string | SHA-256 hash of lowercase, trimmed email address (64-char hex) | | `hashed_phone` | string | SHA-256 hash of E.164-formatted phone number (64-char hex) | | `click_id` | string | Platform click identifier (fbclid, gclid, ttclid, etc.) | | `click_id_type` | string | Type of click identifier | | `client_ip` | string | Client IP address for probabilistic matching | | `client_user_agent` | string | Client user agent string for probabilistic matching | **Hashing:** Hashed identifiers must be SHA-256 hex strings (64 characters, lowercase). Normalize before hashing: emails to lowercase with whitespace trimmed, phone numbers to E.164 format (e.g. `+12065551234`). ### Custom Data Object | Field | Type | Description | | ------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `value` | number | Monetary value of the event | | `currency` | string | ISO 4217 currency code (e.g. `USD`, `EUR`, `GBP`) | | `order_id` | string | Unique order or transaction identifier | | `content_ids` | string\[] | Product or content identifiers. For catalog-driven campaigns, these match the catalog's `content_id_type` (e.g., SKUs, GTINs, job IDs). See [catalog-item attribution](/dist/docs/3.1.20/media-buy/conversion-tracking#catalog-item-attribution). | | `content_type` | string | Category of content (product, service, etc.) | | `num_items` | integer | Number of items in the event | | `progress_percent` | number | Content progress percentage reached, primarily for `watch_milestone` events | | `progress_seconds` | number | Content progress duration reached in seconds, primarily for time-based `watch_milestone` events | | `contents` | Content\[] | Per-item details (id, quantity, price, brand) | ## Response **Success Response:** * `events_received` - Number of events received * `events_processed` - Number of events successfully queued * `partial_failures` - Events that failed validation (with event\_id, code, message) * `warnings` - Non-fatal issues (low match quality, missing fields) * `match_quality` - Overall match quality score (0.0 to 1.0) **Error Response:** * `errors` - Array of operation-level errors (invalid event source, auth failure) **Note:** Responses use discriminated unions - you get either success fields OR errors, never both. Partial failures are reported per-event within the success response. ## Common Scenarios ### Creator Engagement Events Log creator or content engagement events against the configured owned-property source. Use `surface` to identify the property and put watch thresholds in `custom_data.progress_percent` or `custom_data.progress_seconds`. ```json test=false theme={null} { "event_source_id": "creator_channel", "events": [ { "event_id": "evt_watch_001", "event_type": "watch_milestone", "event_time": "2026-01-16T11:05:00Z", "action_source": "system_generated", "surface": { "category": "owned_property", "property_type": "channel", "namespace": "video_platform", "property_id": "channel_123" }, "user_match": { "uids": [{ "type": "uid2", "value": "CreatorViewer456" }] }, "custom_data": { "content_ids": ["episode_001"], "progress_percent": 75 } } ] } ``` For free durable opt-ins, use `event_type: "follow"`. Reserve `event_type: "subscribe"` for paid subscriptions or paid memberships. ### Batch Events Send multiple events in a single request: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { LogEventResponseSchema } from "@adcp/sdk"; const result = await testAgent.logEvent({ event_source_id: "website_pixel", events: [ { event_id: "evt_purchase_001", event_type: "purchase", event_time: "2026-01-15T10:00:00Z", action_source: "website", user_match: { hashed_email: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", uids: [{ type: "uid2", value: "AbC123XyZ..." }], }, custom_data: { value: 89.99, currency: "USD", order_id: "order_001", }, }, { event_id: "evt_lead_002", event_type: "lead", event_time: "2026-01-15T11:30:00Z", action_source: "website", user_match: { hashed_email: "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5", click_id: "abc123def456", click_id_type: "fbclid", }, }, { event_id: "evt_cart_003", event_type: "add_to_cart", event_time: "2026-01-15T12:15:00Z", action_source: "app", user_match: { uids: [{ type: "rampid", value: "Def456Ghi..." }], }, custom_data: { content_ids: ["SKU-1234", "SKU-5678"], num_items: 2, value: 45.00, currency: "USD", }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = LogEventResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("events_received" in validated) { console.log(`${validated.events_processed}/${validated.events_received} events processed`); if (validated.partial_failures?.length) { for (const failure of validated.partial_failures) { console.log(` Failed: ${failure.event_id} - ${failure.message}`); } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.log_event( event_source_id='website_pixel', events=[ { 'event_id': 'evt_purchase_001', 'event_type': 'purchase', 'event_time': '2026-01-15T10:00:00Z', 'action_source': 'website', 'user_match': { 'uids': [{'type': 'uid2', 'value': 'AbC123XyZ...'}] }, 'custom_data': { 'value': 89.99, 'currency': 'USD', 'order_id': 'order_001' } }, { 'event_id': 'evt_lead_002', 'event_type': 'lead', 'event_time': '2026-01-15T11:30:00Z', 'action_source': 'website', 'user_match': { 'click_id': 'abc123def456', 'click_id_type': 'fbclid' } }, { 'event_id': 'evt_cart_003', 'event_type': 'add_to_cart', 'event_time': '2026-01-15T12:15:00Z', 'action_source': 'app', 'user_match': { 'uids': [{'type': 'rampid', 'value': 'Def456Ghi...'}] }, 'custom_data': { 'content_ids': ['SKU-1234', 'SKU-5678'], 'num_items': 2, 'value': 45.00, 'currency': 'USD' } } ] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") print(f"{result.events_processed}/{result.events_received} events processed") if hasattr(result, 'partial_failures') and result.partial_failures: for failure in result.partial_failures: print(f" Failed: {failure.event_id} - {failure.message}") asyncio.run(main()) ``` ### Test Events Validate event integration without affecting production data: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { LogEventResponseSchema } from "@adcp/sdk"; const result = await testAgent.logEvent({ event_source_id: "website_pixel", test_event_code: "TEST_12345", events: [ { event_id: "test_evt_001", event_type: "purchase", event_time: new Date().toISOString(), action_source: "website", event_source_url: "https://www.example.com/checkout", user_match: { click_id: "test_click_abc", click_id_type: "gclid", }, custom_data: { value: 99.99, currency: "USD", }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = LogEventResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("events_received" in validated) { console.log("Test event sent successfully"); if (validated.warnings?.length) { console.log("Warnings:", validated.warnings); } } ``` ```python Python theme={null} import asyncio from datetime import datetime, timezone from adcp.testing import test_agent async def main(): result = await test_agent.simple.log_event( event_source_id='website_pixel', test_event_code='TEST_12345', events=[{ 'event_id': 'test_evt_001', 'event_type': 'purchase', 'event_time': datetime.now(timezone.utc).isoformat(), 'action_source': 'website', 'event_source_url': 'https://www.example.com/checkout', 'user_match': { 'click_id': 'test_click_abc', 'click_id_type': 'gclid' }, 'custom_data': { 'value': 99.99, 'currency': 'USD' } }] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") print('Test event sent successfully') if hasattr(result, 'warnings') and result.warnings: print(f"Warnings: {result.warnings}") asyncio.run(main()) ``` Test events appear in the seller's test events UI but do not affect production attribution or reporting. ### In-Store Conversions Report offline conversions using CRM data: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { LogEventResponseSchema } from "@adcp/sdk"; const result = await testAgent.logEvent({ event_source_id: "crm_import", events: [ { event_id: "store_txn_20260115_001", event_type: "purchase", event_time: "2026-01-15T16:45:00Z", action_source: "in_store", user_match: { uids: [{ type: "rampid", value: "XyZ789AbC..." }], }, custom_data: { value: 250.0, currency: "USD", order_id: "POS-2026-0115-001", contents: [ { id: "SKU-JACKET-L", quantity: 1, price: 189.0 }, { id: "SKU-SCARF-01", quantity: 1, price: 61.0 }, ], }, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = LogEventResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("events_received" in validated) { console.log(`In-store events processed: ${validated.events_processed}`); } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.log_event( event_source_id='crm_import', events=[{ 'event_id': 'store_txn_20260115_001', 'event_type': 'purchase', 'event_time': '2026-01-15T16:45:00Z', 'action_source': 'in_store', 'user_match': { 'uids': [{'type': 'rampid', 'value': 'XyZ789AbC...'}] }, 'custom_data': { 'value': 250.00, 'currency': 'USD', 'order_id': 'POS-2026-0115-001', 'contents': [ {'id': 'SKU-JACKET-L', 'quantity': 1, 'price': 189.00}, {'id': 'SKU-SCARF-01', 'quantity': 1, 'price': 61.00} ] } }] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") print(f"In-store events processed: {result.events_processed}") asyncio.run(main()) ``` ## Event Deduplication Events are deduplicated by the combination of `event_id` + `event_type` + `event_source_id`. Sending the same event multiple times is safe - duplicates are silently ignored. Choose `event_id` values that are stable across retries: * Transaction IDs: `"order_98765"` * Composite keys: `"purchase_user123_20260115"` * UUIDs: `"550e8400-e29b-41d4-a716-446655440000"` ## Error Handling | Error Code | Description | Resolution | | --------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `REFERENCE_NOT_FOUND` | Event source not configured or not accessible (`error.field` = `event_source_id`) | Run `sync_event_sources` first | | `INVALID_EVENT_TYPE` | Unrecognized or disallowed event type | Check event source's `event_types` configuration | | `INVALID_EVENT_TIME` | Event time too far in the past/future | Use timestamps within the seller's attribution window | | `MISSING_USER_MATCH` | No user identifiers provided | Include at least one of: uids, hashed\_email, hashed\_phone, click\_id, or client\_ip + client\_user\_agent | | `BATCH_TOO_LARGE` | More than 10,000 events | Split into smaller batches | | `RATE_LIMITED` | Too many requests | Wait and retry with exponential backoff | ## Best Practices 1. **Configure sources first** - Always run `sync_event_sources` before sending events. Events sent to unconfigured sources are rejected. 2. **Include user\_match** - Events without user identifiers cannot be attributed. Provide the strongest identifiers available: hashed email/phone > UIDs > click IDs > IP/UA. Send multiple identifier types when available to maximize match rates. 3. **Use test events first** - Set `test_event_code` during integration to validate events appear correctly without affecting production data. 4. **Batch when possible** - Send up to 10,000 events per request to reduce API calls. Events within a batch are processed independently. 5. **Include value and currency** - For purchase events, always include `custom_data.value` and `custom_data.currency` to enable ROAS reporting and optimization. 6. **Stable event IDs** - Use deterministic event IDs (order numbers, transaction IDs) rather than random UUIDs. This ensures safe retries without duplicate counting. 7. **Send events promptly** - Log events as close to real-time as possible. Events outside the seller's attribution window may not be matched. ## Next Steps * [Conversion Tracking](/dist/docs/3.1.20/media-buy/conversion-tracking/) - Data model, optimization goals, and the end-to-end flow * [sync\_event\_sources](/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources) - Configure event sources before logging events * [create\_media\_buy](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy#campaign-with-conversion-optimization) - Set optimization goals on packages * [get\_media\_buy\_delivery](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) - Monitor conversion metrics in delivery reports # provide_performance_feedback Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/provide_performance_feedback provide_performance_feedback task — share normalized performance scores with AdCP publishers so sellers can optimize delivery based on buyer-observed outcomes. Share performance outcomes with publishers to enable data-driven optimization and improved campaign delivery. **Response Time**: \~5 seconds (data ingestion) **Request Schema**: [`https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-request.json) **Response Schema**: [`https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/provide-performance-feedback-response.json) ## Request Parameters | Parameter | Type | Required | Description | | -------------------- | -------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy_id` | string | Yes | Seller's media buy identifier | | `measurement_period` | object | Yes | Time period for performance measurement | | `performance_index` | number | Yes | Normalized performance score (0.0 = no value, 1.0 = expected, >1.0 = above expected) | | `package_id` | string | No | Specific package within the media buy (if feedback is package-specific) | | `creative_id` | string | No | Specific creative asset (if feedback is creative-specific) | | `metric_type` | string | **Deprecated** | Legacy free-form metric enum (mixes metrics, verification, attribution into one list). New implementations SHOULD use `metric` instead. No longer required at the schema level; retained for one-minor backwards compatibility; removed at the next major. See the [metric-type migration table](https://adcontextprotocol.org/schemas/3.1.20/enums/metric-type.json) for the mapping. | | `metric` | object | No | The metric this feedback row pertains to, using the `(scope, metric_id, qualifier)` row shape symmetric with `committed_metrics`. Preferred over the deprecated `metric_type`. Standard metrics: `{ scope: "standard", metric_id: "viewable_rate", qualifier: { viewability_standard: "mrc" } }`. Vendor metrics: `{ scope: "vendor", vendor: { domain: "nielsen.com" }, metric_id: "brand_lift" }`. **Omit entirely for holistic feedback** — a trader flagging a campaign as underperforming without a specific metric carries the signal via `performance_index` plus the response narrative, no `metric` row needed. Senders SHOULD populate `metric` when the feedback is metric-specific so consumers can route it. | | `feedback_source` | string | No | Source of the performance data (defaults to "buyer\_attribution") | | `vendor` | [BrandRef](/dist/docs/3.1.20/reference/glossary#b) | No | Vendor that produced this feedback. SHOULD be populated when `feedback_source` is `third_party_measurement` or `verification_partner` AND a single attesting vendor exists. OMIT for blended outputs (MMM mixes, multi-touch attribution that joins across vendors, clean-room outputs where the clean room is not the measurement source). Optional for `buyer_attribution` and `platform_analytics`. **Distinct from the nested `metric.vendor` field** — this top-level `vendor` identifies the *source* of the feedback (the party producing it); `metric.vendor` (when present in vendor-scope `metric` entries) identifies the *vendor that defines the metric*. Often the same; can differ. | ## Response (Message) The response includes a human-readable message that: * Confirms receipt of the performance feedback * Summarizes the performance level provided * Explains how the feedback will be used for optimization * Provides next steps or recommendations The message is returned differently in each protocol: * **MCP**: Returned as a `message` field in the JSON response * **A2A**: Returned as a text part in the artifact ## Response (Payload) ```json theme={null} { "success": "boolean", "message": "string" } ``` ### Field Descriptions * **success**: Whether the performance feedback was successfully received * **message**: Optional human-readable message about the feedback processing ## Protocol-Specific Examples The AdCP payload is identical across protocols. Only the request/response wrapper differs. ### MCP Request ```json theme={null} { "tool": "provide_performance_feedback", "arguments": { "media_buy_id": "gam_1234567890", "measurement_period": { "start": "2024-01-15T00:00:00Z", "end": "2024-01-21T23:59:59Z" }, "performance_index": 1.35, "metric_type": "conversion_rate" } } ``` ### MCP Response ```json theme={null} { "message": "Performance feedback received for campaign gam_1234567890. The 35% above-expected conversion rate will be used to optimize future delivery. Next optimization cycle runs tonight at midnight UTC.", "success": true } ``` ### A2A Request #### Natural Language Invocation ```javascript theme={null} await a2a.send({ message: { parts: [{ kind: "text", text: "The campaign gam_1234567890 had a conversion rate 35% above expectations for the week of January 15-21. Please use this to optimize future delivery." }] } }); ``` #### Explicit Skill Invocation ```javascript theme={null} await a2a.send({ message: { parts: [{ kind: "data", data: { skill: "provide_performance_feedback", parameters: { media_buy_id: "gam_1234567890", measurement_period: { start: "2024-01-15T00:00:00Z", end: "2024-01-21T23:59:59Z" }, performance_index: 1.35, metric_type: "conversion_rate" } } }] } }); ``` ### A2A Response A2A returns results as artifacts: ```json theme={null} { "artifacts": [{ "artifactId": "artifact-perf-feedback-abc789", "name": "performance_feedback_confirmation", "parts": [ { "kind": "text", "text": "Performance feedback received for campaign gam_1234567890. The 35% above-expected conversion rate will be used to optimize future delivery. Next optimization cycle runs tonight at midnight UTC." }, { "kind": "data", "data": { "success": true } } ] }] } ``` ### Key Differences * **MCP**: Direct tool call with arguments, returns flat JSON response * **A2A**: Skill invocation with input, returns artifacts with text and data parts * **Payload**: The `input` field in A2A contains the exact same structure as MCP's `arguments` ## Scenarios ### Example 1: Campaign-Level Performance Feedback #### Request ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-request.json", "idempotency_key": "c7d8e9f0-a1b2-4345-c678-345678901234", "media_buy_id": "gam_1234567890", "measurement_period": { "start": "2024-01-01T00:00:00Z", "end": "2024-01-31T23:59:59Z" }, "performance_index": 0.85, "metric_type": "brand_lift", "feedback_source": "third_party_measurement", "vendor": { "domain": "doubleverify.com" } } ``` #### Response - Below Expected Performance **Message**: "Performance feedback received for campaign gam\_1234567890. The 15% below-expected brand lift suggests targeting refinement is needed. Our optimization algorithms will reduce spend on underperforming segments starting with the next cycle." **Payload**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-response.json", "status": "completed", "success": true, "message": "Performance feedback processed successfully. Optimization algorithms updated." } ``` ### Example 2: Package-Specific Performance Feedback #### Request ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-request.json", "idempotency_key": "d8e9f0a1-b2c3-4456-d789-456789012345", "media_buy_id": "meta_9876543210", "package_id": "pkg_social_feed", "measurement_period": { "start": "2024-02-01T00:00:00Z", "end": "2024-02-07T23:59:59Z" }, "performance_index": 2.1, "metric_type": "click_through_rate", "feedback_source": "buyer_attribution" } ``` #### Response - Exceptional Performance **Message**: "Outstanding performance feedback for package pkg\_social\_feed! The 110% above-expected click-through rate indicates this audience segment is highly engaged. We'll increase allocation to similar inventory and audiences." **Payload**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-response.json", "status": "completed", "success": true, "message": "Exceptional performance noted. Increasing allocation to similar segments." } ``` ### Example 3: Creative-Specific Performance Feedback #### Request ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-request.json", "idempotency_key": "e9f0a1b2-c3d4-4567-e890-567890123456", "media_buy_id": "ttd_5555555555", "creative_id": "creative_video_123", "measurement_period": { "start": "2024-02-01T00:00:00Z", "end": "2024-02-07T23:59:59Z" }, "performance_index": 0.65, "metric_type": "completion_rate", "feedback_source": "verification_partner" } ``` #### Response - Poor Creative Performance **Message**: "Creative creative\_video\_123 shows 35% below-expected completion rate. Consider creative refresh or A/B testing alternative versions." **Payload**: ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-response.json", "status": "completed", "success": true, "message": "Creative performance feedback recorded. Consider creative optimization." } ``` ### Example 4: Multiple Performance Metrics To report multiple metrics for the same media buy, send one request per metric type: #### Request - Viewability Feedback ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-request.json", "idempotency_key": "f0a1b2c3-d4e5-4678-f901-678901234567", "media_buy_id": "ttd_5555555555", "measurement_period": { "start": "2024-02-01T00:00:00Z", "end": "2024-02-14T23:59:59Z" }, "metric_type": "viewability", "performance_index": 1.15, "feedback_source": "verification_partner" } ``` #### Request - Completion Rate Feedback ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-request.json", "idempotency_key": "a1b2c3d4-e5f6-4789-a012-789012345678", "media_buy_id": "ttd_5555555555", "measurement_period": { "start": "2024-02-01T00:00:00Z", "end": "2024-02-14T23:59:59Z" }, "metric_type": "completion_rate", "performance_index": 0.92, "feedback_source": "verification_partner" } ``` #### Request - Brand Safety Feedback ```json theme={null} { "$schema": "/schemas/3.1.20/media-buy/provide-performance-feedback-request.json", "idempotency_key": "b2c3d4e5-f6a7-4890-b123-890123456789", "media_buy_id": "ttd_5555555555", "measurement_period": { "start": "2024-02-01T00:00:00Z", "end": "2024-02-14T23:59:59Z" }, "metric_type": "brand_safety", "performance_index": 1.05, "feedback_source": "verification_partner" } ``` ## Performance Index Scale The performance index provides a normalized way to communicate business outcomes: * **0.0**: No measurable value or impact * **0.5**: Significantly below expectations (-50%) * **1.0**: Meets baseline expectations (0% variance) * **1.5**: Exceeds expectations by 50% * **2.0+**: Exceptional performance (100%+ above expected) ### Common Metric Types * **overall\_performance**: General campaign success (default) * **conversion\_rate**: Post-click or post-view conversions * **brand\_lift**: Brand awareness or consideration lift * **click\_through\_rate**: Engagement with creative * **completion\_rate**: Video or audio completion rates * **viewability**: Viewable impression rate * **brand\_safety**: Brand safety compliance * **cost\_efficiency**: Cost per desired outcome ### Feedback Sources * **buyer\_attribution**: Buyer's own measurement and attribution * **third\_party\_measurement**: Independent measurement partner * **platform\_analytics**: Publisher platform's analytics * **verification\_partner**: Third-party verification service ## How Publishers Use Performance Feedback Publishers leverage performance indices to: 1. **Optimize Targeting**: Shift impressions to high-performing segments and audiences 2. **Improve Inventory**: Identify and prioritize high-value placements 3. **Adjust Pricing**: Update CPMs based on proven value delivery 4. **Enhance Algorithms**: Train machine learning models on actual business outcomes 5. **Product Development**: Refine product definitions based on performance patterns ## Usage Notes * Performance feedback is optional but highly valuable for optimization * Feedback can be provided at campaign or package level * Multiple performance indices can be shared for the same period (batch submission planned for future releases) * Optimization impact depends on the publisher's algorithm sophistication * Feedback is processed asynchronously; status can be checked via the response * Historical feedback helps improve future campaign performance across the publisher's inventory ## Privacy and Data Sharing * Performance feedback sharing is voluntary and controlled by the buyer * Aggregate performance patterns may be used to improve overall platform performance * Individual campaign details remain confidential to the buyer-publisher relationship * Publishers should provide clear data usage policies in their AdCP documentation ## Implementation Guide ### Calculating Performance Index ```python theme={null} def calculate_performance_index(actual_metric, expected_metric): """ Calculate normalized performance index Args: actual_metric: Measured performance value expected_metric: Baseline or expected performance value Returns: Performance index (0.0 = no value, 1.0 = expected, >1.0 = above expected) """ if expected_metric == 0: return 0.0 return actual_metric / expected_metric # Examples: # CTR: 0.15% actual vs 0.12% expected = 1.25 performance index (25% above) # Conversions: 45 actual vs 60 expected = 0.75 performance index (25% below) # Brand lift: 8% actual vs 5% expected = 1.6 performance index (60% above) ``` ### Determining Metric Types Choose metric types based on campaign objectives: ```python theme={null} METRIC_TYPE_MAPPING = { 'awareness': 'brand_lift', 'consideration': 'brand_lift', 'traffic': 'click_through_rate', 'conversions': 'conversion_rate', 'sales': 'conversion_rate', 'engagement': 'completion_rate', 'reach': 'overall_performance' } def get_metric_type(campaign_objective): return METRIC_TYPE_MAPPING.get(campaign_objective, 'overall_performance') ``` ## Related Documentation * [`get_media_buy_delivery`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) - Retrieve delivery metrics * [Optimization & Reporting](/dist/docs/3.1.20/media-buy/media-buys/optimization-reporting) - Performance feedback concepts * [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) - Understanding targeting for optimization # sync_audiences Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/sync_audiences sync_audiences task — upload hashed first-party CRM audiences to AdCP seller accounts for retargeting, suppression, and lookalike expansion. Supports match status tracking. Manage first-party CRM audiences on a seller account. Upload hashed customer lists, check matching status, and reference the resulting audiences in `create_media_buy` targeting overlays for explicit retargeting or suppression. Audiences are distinct from [signals](/dist/docs/3.1.20/signals/): signals are named targeting dimensions discovered through product signal options, provider-published signal definitions, or `get_signals`; audiences are data you own and upload. Use `audience_include` to target only members of an uploaded list. `audience_include` is a hard constraint — only users on the list are eligible. To find new users *similar to* an audience (lookalike expansion), describe that intent in your campaign brief — the seller handles expansion strategy. Note: lookalike intent expressed in the brief cannot be verified through the protocol; confirm via seller-side reporting. **Response Time**: Upload accepted in \~1–2s. Per-audience matching is asynchronous — the task remains active until matching completes (1–48 hours depending on the seller). Configure `push_notification_config` to receive a webhook when the audience is ready. Sellers whose ingestion pipeline cannot return per-audience results in the synchronous response (batch ingestion, governance-gated upload, clean-room flows) MAY respond with an operation-level submitted task envelope — see [Response shapes](#response-shapes). **Request Schema**: [`/schemas/3.1.20/media-buy/sync-audiences-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-audiences-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/sync-audiences-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-audiences-response.json) ## Quick Start Upload a customer list and check its status: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAudiencesResponseSchema } from "@adcp/sdk"; import { createHash } from "crypto"; const hashEmail = (email) => createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); const hashPhone = (e164Phone) => createHash("sha256").update(e164Phone).digest("hex"); const result = await testAgent.syncAudiences({ account: { account_id: "acct_12345" }, audiences: [ { audience_id: "existing_customers", name: "Existing customers", add: [ { external_id: "crm_1001", hashed_email: hashEmail("alice@example.com") }, { external_id: "crm_1002", hashed_email: hashEmail("bob@example.com"), hashed_phone: hashPhone("+12065551234") }, ], }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAudiencesResponseSchema.parse(result.data); // Three-shape discriminated union: errors | submitted | audiences if ("status" in validated && validated.status === "submitted") { // Whole sync queued asynchronously — poll tasks/get with task_id or await webhook console.log(`Sync queued as task ${validated.task_id}: ${validated.message ?? ""}`); } else if ("errors" in validated && validated.errors && !("audiences" in validated)) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } else if ("audiences" in validated) { for (const audience of validated.audiences) { console.log(`${audience.audience_id}: ${audience.action} (${audience.status ?? "n/a"})`); if (audience.status === "ready") { console.log(` Matched ${audience.matched_count} of ${audience.uploaded_count} members (this sync)`); } } } ``` ```python Python theme={null} import asyncio import hashlib from adcp.testing import test_agent def hash_email(email: str) -> str: return hashlib.sha256(email.lower().strip().encode()).hexdigest() def hash_phone(e164_phone: str) -> str: return hashlib.sha256(e164_phone.encode()).hexdigest() async def main(): result = await test_agent.simple.sync_audiences( account={'account_id': 'acct_12345'}, audiences=[{ 'audience_id': 'existing_customers', 'name': 'Existing customers', 'add': [ {'external_id': 'crm_1001', 'hashed_email': hash_email('alice@example.com')}, {'external_id': 'crm_1002', 'hashed_email': hash_email('bob@example.com'), 'hashed_phone': hash_phone('+12065551234')}, ] }] ) # Three-shape discriminated union: errors | submitted | audiences if getattr(result, 'status', None) == 'submitted': # Whole sync queued asynchronously — poll tasks/get with task_id or await webhook print(f"Sync queued as task {result.task_id}: {getattr(result, 'message', '') or ''}") return if getattr(result, 'errors', None) and not getattr(result, 'audiences', None): raise Exception(f"Operation failed: {result.errors}") for audience in result.audiences: status = getattr(audience, 'status', 'n/a') print(f"{audience.audience_id}: {audience.action} ({status})") if status == 'ready': print(f" Matched {audience.matched_count} of {audience.uploaded_count} members (this sync)") asyncio.run(main()) ``` ## Request Parameters | Parameter | Type | Required | Description | | ---------------- | -------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. | | `audiences` | [Audience](#audience-object)\[] | No | Audiences to sync. When omitted, the call is discovery-only and returns all existing audiences without modification. | | `delete_missing` | boolean | No | When true, buyer-managed audiences on the account not in this request are removed (default: false). Does not affect seller-managed audiences. Do not combine with an omitted `audiences` array or all buyer-managed audiences will be deleted. | ### Audience Object | Field | Type | Required | Description | | --------------- | ------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `audience_id` | string | Yes | Buyer's identifier for this audience. Used to reference the audience in targeting overlays. | | `name` | string | No | Human-readable name | | `delete` | boolean | No | When true, delete this audience from the account entirely. All other fields are ignored. | | `add` | [AudienceMember](#audience-member)\[] | No | Members to add to this audience | | `remove` | [AudienceMember](#audience-member)\[] | No | Members to remove from this audience. If the same identifier appears in both `add` and `remove`, remove takes precedence. | | `consent_basis` | string | No | GDPR lawful basis: `consent`, `legitimate_interest`, `contract`, or `legal_obligation`. Required by some sellers in regulated markets. | ### Audience Member Every member requires an `external_id` (buyer-assigned stable identifier) plus at least one matchable identifier. Hash all values with SHA-256 before sending — normalize emails to lowercase+trim, phone numbers to E.164 format (e.g. `+12065551234`). | Field | Type | Description | | -------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `external_id` | string | **Required.** Buyer-assigned stable identifier for this member (e.g. CRM record ID, loyalty ID). Used for deduplication, removal, and cross-referencing with buyer systems. | | `hashed_email` | string | SHA-256 hash of lowercase, trimmed email (64-char hex) | | `hashed_phone` | string | SHA-256 hash of E.164-formatted phone number (64-char hex) | | `uids` | UID\[] | Universal IDs: `type` (rampid, uid2, maid, etc.) + `value` | Providing multiple identifiers for the same person improves match rates. Composite identifiers (e.g. hashed first name + last name + zip) are not yet standardized — use `ext` for platform-specific extensions. **Identifier support varies by seller**: Check `get_adcp_capabilities` → `media_buy.audience_targeting.supported_identifier_types` and `media_buy.audience_targeting.supported_uid_types` before sending. MAID support is not universal (LinkedIn does not accept MAIDs; iOS IDFA requires App Tracking Transparency consent). The `media_buy.audience_targeting.matching_latency_hours` range and `media_buy.audience_targeting.minimum_audience_size` in capabilities are also seller-specific. **Size limit**: Payloads are limited to 100,000 members per call across all audiences. For larger lists, chunk into sequential calls using `add` deltas. **Concurrency**: Ensure that calls made to `sync_audience` are independent of eachother. They may be processed out-of-order. If you need sequential execution, wait for the callback to your configured webhook before making another call. ## Response shapes Responses use discriminated unions — a response has exactly one of three shapes, never mixed: **1. Synchronous success** — per-audience results: * `audiences` — Results for each audience on the account, including audiences not in this request * `sandbox` — Boolean indicating if this response is from sandbox mode (optional) **2. Terminal failure** — no audiences processed: * `errors` — Array of operation-level errors (auth failure, account not found, invalid request format) **3. Submitted task envelope** — whole operation queued asynchronously (batch ingestion, governance-gated upload, clean-room flows where the seller cannot return per-audience results before the response is emitted): * `status` — Always `"submitted"` * `task_id` — Handle for polling via `tasks/get` or receiving a webhook on completion * `message` — Optional human-readable explanation of the queue state The final per-audience `audiences` array lands on the task completion artifact, not on the submitted envelope. Per-audience asynchronous matching (one audience in `processing` while the rest of the sync resolves synchronously) belongs on the synchronous success branch with `status: "processing"` on that item, not on the submitted envelope. Matching latency on the per-audience [audience-status](#audience-status) enum is the common case; the submitted envelope is for the less-common operation-level async case. **Each audience in success response includes:** | Field | Description | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `audience_id` | Echoed from request (buyer's identifier) | | `seller_id` | Seller-assigned ID in their ad platform | | `action` | `created`, `updated`, `unchanged`, `deleted`, or `failed` | | `status` | `processing`, `ready`, or `too_small`. Present when action is `created`, `updated`, or `unchanged`; absent when action is `deleted` or `failed`. | | `uploaded_count` | Members submitted in this sync operation (delta, not cumulative). 0 for discovery-only calls. | | `total_uploaded_count` | Cumulative members uploaded across all syncs. Compare with `matched_count` to calculate match rate. | | `matched_count` | Total members matched to platform users across all syncs (cumulative). Populated when `status: "ready"`. | | `effective_match_rate` | Deduplicated match rate across all identifier types (0–1). A single number for reach estimation. Populated when `status: "ready"`. | | `match_breakdown` | Per-identifier-type match results. Shows which ID types resolve and at what rate. See [match breakdown](#match-breakdown). | | `last_synced_at` | ISO 8601 timestamp of the most recent sync. Omitted if the seller does not track this. | | `minimum_size` | Minimum matched audience size for targeting on this platform. Populated when `status: "too_small"`. | | `errors` | Per-audience errors (only when `action: "failed"`) | ## Match breakdown When a seller supports per-identifier-type reporting, the response includes `match_breakdown` — an array showing which identity types are resolving and at what rate. This helps buyers decide which identifiers to prioritize in future uploads. ```json theme={null} { "audience_id": "existing_customers", "action": "updated", "status": "ready", "uploaded_count": 5000, "total_uploaded_count": 25000, "matched_count": 18750, "effective_match_rate": 0.75, "match_breakdown": [ { "id_type": "hashed_email", "submitted": 25000, "matched": 17500, "match_rate": 0.70 }, { "id_type": "hashed_phone", "submitted": 15000, "matched": 12000, "match_rate": 0.80 }, { "id_type": "rampid", "submitted": 8000, "matched": 7200, "match_rate": 0.90 } ] } ``` Key semantics: * **`submitted` and `matched` are cumulative** across all syncs, matching `total_uploaded_count` semantics (not `uploaded_count`). * **`effective_match_rate` is deduplicated** — a member matched via both email and phone counts once. It will be less than or equal to the sum of per-type match rates. * **`match_rate` is server-authoritative** — consumers should prefer this value over computing their own from submitted/matched. * **`id_type` values** combine hashed PII types (`hashed_email`, `hashed_phone`) with universal ID types (`rampid`, `uid2`, `id5`, `euid`, `pairid`, `maid`). Sellers that only support aggregate match counts omit `match_breakdown` entirely. ## Common Scenarios ### Discovery Only Check status of all existing audiences without making changes. The response includes all audiences on the account — filter by `audience_id` to find the one you care about: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAudiencesResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncAudiences({ account: { account_id: "acct_12345" }, }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAudiencesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("audiences" in validated) { for (const audience of validated.audiences) { console.log(`${audience.audience_id}: ${audience.status ?? "n/a"}`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_audiences(account={'account_id': 'acct_12345'}) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for audience in result.audiences: status = getattr(audience, 'status', 'n/a') print(f"{audience.audience_id}: {status}") asyncio.run(main()) ``` ### Suppression List Upload a list of existing customers to suppress from acquisition campaigns: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAudiencesResponseSchema } from "@adcp/sdk"; import { createHash } from "crypto"; const hashEmail = (email) => createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); // Hashed customer emails from CRM export const existingCustomers = [ { hashed_email: hashEmail("customer1@example.com") }, { hashed_email: hashEmail("customer2@example.com") }, ]; const result = await testAgent.syncAudiences({ account: { account_id: "acct_12345" }, audiences: [ { audience_id: "existing_customers", name: "Existing customers — suppression", add: existingCustomers, }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAudiencesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("audiences" in validated) { const audience = validated.audiences[0]; console.log(`Status: ${audience.status}`); // When ready, reference audience_id in create_media_buy targeting_overlay.audience_exclude } ``` ```python Python theme={null} import asyncio import hashlib from adcp.testing import test_agent def hash_email(email: str) -> str: return hashlib.sha256(email.lower().strip().encode()).hexdigest() async def main(): existing_customers = [ {'hashed_email': hash_email('customer1@example.com')}, {'hashed_email': hash_email('customer2@example.com')}, ] result = await test_agent.simple.sync_audiences( account={'account_id': 'acct_12345'}, audiences=[{ 'audience_id': 'existing_customers', 'name': 'Existing customers — suppression', 'add': existing_customers }] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") audience = result.audiences[0] print(f"Status: {audience.status}") # When ready, reference audience_id in create_media_buy targeting_overlay.audience_exclude asyncio.run(main()) ``` ### Removing Members Update an audience incrementally — add new members and remove ones that no longer qualify: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAudiencesResponseSchema } from "@adcp/sdk"; import { createHash } from "crypto"; const hashEmail = (email) => createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); const result = await testAgent.syncAudiences({ account: { account_id: "acct_12345" }, audiences: [ { audience_id: "lapsed_subscribers", name: "Lapsed subscribers", add: [{ hashed_email: hashEmail("newlapse@example.com") }], remove: [{ hashed_email: hashEmail("reactivated@example.com") }], }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAudiencesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("audiences" in validated) { for (const audience of validated.audiences) { console.log(`${audience.audience_id}: ${audience.action}`); } } ``` ```python Python theme={null} import asyncio import hashlib from adcp.testing import test_agent def hash_email(email: str) -> str: return hashlib.sha256(email.lower().strip().encode()).hexdigest() async def main(): result = await test_agent.simple.sync_audiences( account={'account_id': 'acct_12345'}, audiences=[{ 'audience_id': 'lapsed_subscribers', 'name': 'Lapsed subscribers', 'add': [{'hashed_email': hash_email('newlapse@example.com')}], 'remove': [{'hashed_email': hash_email('reactivated@example.com')}] }] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for audience in result.audiences: print(f"{audience.audience_id}: {audience.action}") asyncio.run(main()) ``` ### Deleting an Audience Remove a specific audience from the account without affecting others. Set `delete: true` on the audience object: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncAudiencesResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncAudiences({ account: { account_id: "acct_12345" }, audiences: [ { audience_id: "old_campaign_list", delete: true }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncAudiencesResponseSchema.parse(result.data); if ("audiences" in validated) { const audience = validated.audiences.find(a => a.audience_id === "old_campaign_list"); console.log(`${audience.audience_id}: ${audience.action}`); // "deleted" } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_audiences( account={'account_id': 'acct_12345'}, audiences=[{'audience_id': 'old_campaign_list', 'delete': True}] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") audience = next(a for a in result.audiences if a.audience_id == 'old_campaign_list') print(f"{audience.audience_id}: {audience.action}") # "deleted" asyncio.run(main()) ``` To delete multiple audiences in one call, include each with `delete: true`. To delete all buyer-managed audiences at once, use `delete_missing: true` with an empty `audiences` array — but be careful, this removes everything. ### Using Audiences in a Media Buy Once an audience is `ready`, reference it by `audience_id` in `create_media_buy` targeting overlays. Audience IDs are scoped to the seller account — they cannot be used across sellers. ```json test=false theme={null} { "brand": { "house_domain": "acme.com", "brand_id": "main" }, "start_time": "asap", "end_time": "2026-03-31T23:59:59Z", "packages": [ { "product_id": "prod_sponsored_content", "pricing_option_id": "cpm_standard", "budget": 10000, "targeting_overlay": { "audience_include": ["high_value_prospects"], "audience_exclude": ["existing_customers"] } } ] } ``` ## Audience Status Platform matching is asynchronous. The `status` field reflects the current state: | Status | Meaning | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `processing` | Platform is matching uploaded members against its user base. Poll again later — do not create campaigns yet. | | `ready` | Audience is available for targeting. `matched_count` is populated. | | `too_small` | Matched audience is below the platform's minimum size. `minimum_size` in the response tells you the threshold. Add more members and re-sync. | `status` is present when `action` is `created`, `updated`, or `unchanged`. It is absent when `action` is `deleted` or `failed`. Sellers MUST emit `too_small` whenever `matched_count < minimum_size`. Returning `ready` with a `matched_count` below the platform minimum is non-compliant — buyers rely on the status value as a programmatic signal that targeting will fail, not on post-hoc interpretation of the count. **Webhook (recommended)**: Configure `push_notification_config` at the protocol level before uploading. The task stays active while the seller's platform matches members. When matching completes, the task completes and the webhook fires with the final result — `status: "ready"` or `status: "too_small"`. Check `get_adcp_capabilities` → `audience_targeting.matching_latency_hours` to set realistic expectations (typically 1–48 hours). **Polling fallback**: If not using webhooks, poll with discovery-only calls (omit `audiences`) no more frequently than every 15 minutes. Use `tasks/get` with the `task_id` to check task status — the task will be `submitted` while matching is in progress and `completed` when the audience is ready or too small. **Agent workflow**: Upload with `push_notification_config` set. Externalize the `audience_id` and `account_id` before the session ends. When the webhook fires with `status: "ready"`, resume and proceed to `create_media_buy`. ## Async patterns Two distinct async patterns — match the right one to the seller's behavior: **Per-audience async matching** (common): the sync operation itself resolves synchronously and returns per-audience results immediately. Audiences whose matching is still running come back on the synchronous success response with `status: "processing"`. The buyer reconciles terminal state (`ready` / `too_small`) via subsequent discovery-only calls or a webhook. This is the case covered by the [Audience Status](#audience-status) enum above. **Operation-level async** (less common): the whole sync is queued — the seller cannot return any per-audience results before responding, because ingestion is batched, governance review gates the upload, or an upstream clean-room flow must settle before matching can start. The response is a submitted envelope: * Top-level `status: "submitted"` with `task_id` * `message` — optional human-readable explanation * No `audiences` array on this envelope Poll `tasks/get` or wait for the webhook. The completion artifact carries the `audiences` array with per-item `action`/`status` results; operation-level failures surface as `status: "failed"` on the task. **See:** [Webhooks](/dist/docs/3.1.20/building/by-layer/L3/webhooks) for webhook configuration. ## Hashing Requirements Hash all identifiers with SHA-256 before sending. Normalize first: | Identifier | Normalization | Example | | ---------- | -------------------------- | -------------------------- | | Email | Lowercase, trim whitespace | `alice@example.com` → hash | | Phone | E.164 format | `+12065551234` → hash | | MAID | No normalization needed | As-is | ```javascript test=false theme={null} import { createHash } from "crypto"; const hashEmail = (email) => createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); const hashPhone = (e164Phone) => createHash("sha256").update(e164Phone).digest("hex"); ``` ## Privacy Considerations The schema never carries cleartext email or phone — buyers MUST hash before transport. The seller matches by independently hashing its own user data with the same algorithm. **Hashed identifiers are pseudonymous PII, not anonymous.** Unsalted SHA-256 of an email or phone number is recoverable via precomputed dictionaries of the email and E.164 namespaces, so `hashed_email` and `hashed_phone` MUST be treated as PII for retention, consent, access control, and data-subject-request purposes. Do not describe them as "privacy-preserving" in operator documentation or DPAs. See [Privacy Considerations](/dist/docs/3.1.20/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous). **Buyer obligations**: The buyer is responsible for having a lawful basis to process and share audience data, regardless of jurisdiction. Include `consent_basis` on each audience to communicate the GDPR lawful basis to sellers operating in regulated markets — some sellers require this field for EU audiences. **Data handling**: Once uploaded, data processing and retention are governed by your agreement with the seller. Review the seller's data processing terms before uploading audience data. ## Error Handling | Error Code | Description | Resolution | | --------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | `ACCOUNT_NOT_FOUND` | Account does not exist | Verify `account_id` | | `REFERENCE_NOT_FOUND` | Audience to remove from doesn't exist or is not accessible (`error.field` = `audience_id`) | Check `audience_id` or omit `remove` | | `INVALID_HASH_FORMAT` | Identifier doesn't match expected hash format | Verify SHA-256 hex encoding (64 chars, lowercase) | | `RATE_LIMITED` | Too many sync requests | Retry with exponential backoff; poll no more than every 15 minutes | | `CALL_TOO_LARGE` | Too many members in payload | Payloads are limited to 100,000 members across all audiences | ## Next Steps * [Targeting](/dist/docs/3.1.20/media-buy/advanced-topics/targeting) — Reference audiences in `targeting_overlay.audience_include` and `audience_exclude` * [create\_media\_buy](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy) — Apply audience targeting to packages * [Conversion Tracking](/dist/docs/3.1.20/media-buy/conversion-tracking/) — Track outcomes from audience-targeted campaigns # sync_catalogs Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/sync_catalogs sync_catalogs task — sync product feeds, store locations, and vertical catalogs (hotel, flight, vehicle, real estate) to AdCP seller accounts for catalog-driven campaigns. Manage catalog feeds on a seller account. Sync product feeds, inventory data, store locations, offerings, and industry-vertical catalogs (hotel, flight, job, vehicle, real estate, education, destination). Supports URL-based feeds with scheduled re-fetch, inline item data, and discovery of existing catalogs. **Terminology.** `sync_catalogs` manages buyer-provided data feeds that a seller uses to render or target ads. This is separate from the seller-side wholesale product feed exposed by `get_products buying_mode: "wholesale"` and the wholesale signals feed exposed by `get_signals discovery_mode: "wholesale"`; webhooks are the push layer for changes to those seller-side feeds. **Response Time**: Instant to days (returns `completed` for small catalogs, or `submitted` for large feeds requiring platform review) **Request Schema**: [`/schemas/3.1.20/media-buy/sync-catalogs-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-catalogs-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/sync-catalogs-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-catalogs-response.json) ## Who calls whom The **buyer** calls `sync_catalogs` on the **seller** to push catalog feeds to the seller's account. The seller validates items, runs content policy checks, and returns per-item approval status. ```mermaid theme={null} sequenceDiagram participant B as Buyer participant S as Seller B->>S: sync_catalogs (product feed, stores, inventory) S->>B: Per-catalog results with item review status Note over B,S: Buyer can now reference synced catalogs in creatives ``` This task sits between format discovery and creative submission in the [account state setup sequence](/dist/docs/3.1.20/building/by-layer/L2/account-state): 1. `list_creative_formats` — check `catalog` asset types in each format's `assets` array to know what feeds to sync 2. **`sync_catalogs`** — push the required feeds to the account 3. `sync_creatives` — submit creatives that reference synced catalogs by `catalog_id` 4. `create_media_buy` — launch the campaign ## Quick start Sync a product feed: ```json theme={null} { "account": { "account_id": "acct_acmecorp" }, "catalogs": [ { "catalog_id": "product-feed", "name": "Acme Product Catalog", "type": "product", "url": "https://feeds.acmecorp.com/products.xml", "feed_format": "google_merchant_center", "update_frequency": "daily" } ] } ``` ## Request parameters | Parameter | Type | Required | Description | | -------------------------- | -------------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | Conditional | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. Required when the agent has multiple accounts. | | `catalogs` | Catalog\[] | No | Catalog feeds to sync (max 50). Omit for discovery mode. | | `catalog_ids` | string\[] | No | Limit sync scope to specific catalog IDs. Others on the account are unaffected. | | `delete_missing` | boolean | No | When true, buyer-managed catalogs not in this sync are removed. Does not affect seller-managed catalogs. Requires `catalogs` to be present. Default: false. | | `dry_run` | boolean | No | Preview changes without applying. Default: false. | | `validation_mode` | string | No | `"strict"` (default) fails entire sync on any error. `"lenient"` processes valid catalogs and reports errors. | | `push_notification_config` | object | No | Webhook configuration for async completion notification. | ### Catalog object Each catalog in the `catalogs` array is a [Catalog](/dist/docs/3.1.20/creative/catalogs#the-catalog-object) object. Key fields: | Field | Type | Required | Description | | ------------------- | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `catalog_id` | string | Yes | Buyer's identifier. Used to match existing catalogs for upsert. | | `type` | CatalogType | Yes | Catalog type: `product`, `offering`, `inventory`, `store`, `promotion`, `hotel`, `flight`, `job`, `vehicle`, `real_estate`, `education`, `destination` | | `url` | uri | No | External feed URL. Mutually exclusive with `items`. | | `feed_format` | string | No | Feed format: `google_merchant_center`, `facebook_catalog`, `shopify`, `linkedin_jobs`, `custom` | | `update_frequency` | string | No | Re-fetch schedule: `realtime`, `hourly`, `daily`, `weekly` | | `items` | object\[] | No | Inline catalog data. Mutually exclusive with `url`. | | `conversion_events` | EventType\[] | No | Event types representing conversions for items in this catalog | ## Response **Success response** — per-catalog results: | Field | Type | Description | | --------------------------- | --------- | ------------------------------------------------------ | | `catalogs` | object\[] | Results for each catalog processed | | `catalogs[].catalog_id` | string | Catalog ID from request | | `catalogs[].action` | string | `created`, `updated`, `unchanged`, `failed`, `deleted` | | `catalogs[].platform_id` | string | Platform-assigned ID | | `catalogs[].item_count` | integer | Total items after sync | | `catalogs[].items_approved` | integer | Items approved by platform | | `catalogs[].items_pending` | integer | Items awaiting review | | `catalogs[].items_rejected` | integer | Items rejected | | `catalogs[].item_issues` | object\[] | Per-item rejection reasons | | `catalogs[].next_fetch_at` | datetime | Next scheduled feed fetch (URL-based catalogs) | **Error response** — operation failed entirely: | Field | Type | Description | | -------- | -------- | ---------------------------------------------------------- | | `errors` | Error\[] | Operation-level errors (auth failure, service unavailable) | Responses use discriminated unions — you get either `catalogs` or `errors`, never both. ### Example response with item-level review ```json theme={null} { "catalogs": [ { "catalog_id": "product-feed", "action": "created", "platform_id": "plat_cat_001", "item_count": 1250, "items_approved": 1180, "items_pending": 45, "items_rejected": 25, "item_issues": [ { "item_id": "SKU-789", "status": "rejected", "reasons": ["Missing required field: image_url"] } ], "next_fetch_at": "2025-03-01T06:00:00Z" } ] } ``` ## Item review lifecycle Catalog items follow a simple review cycle: items enter `pending` on sync, and the platform reviews them asynchronously. Items are either `approved`, `rejected` (with reasons), or `approved` with `warning` (serving but with fixable issues). Rejection is not terminal — fix the issue in the source catalog and re-sync. Re-syncing an item resets it to `pending` for re-review. The `item_issues` array on the response identifies per-item rejection reasons. ## Discovery mode Omit `catalogs` to list all catalogs on the account without modification: ```json theme={null} { "account": { "account_id": "acct_acmecorp" } } ``` This matters because sellers may already have brand data from other sources — a retailer might have the brand's product catalog from their commerce platform. Discovery lets the buyer build on existing state rather than re-uploading everything. ## Async approval workflow Large feeds or feeds requiring content policy review return `status: "submitted"` with a `task_id`. The seller reviews items asynchronously and notifies the buyer via webhook when done. Async response states: * **`working`** — platform is processing the feed (fetching URL, validating items) * **`input-required`** — platform needs buyer action (fix validation errors, provide missing fields) * **`submitted`** — review complete, final per-catalog results available Configure `push_notification_config` on the request to receive webhook notifications for state transitions. ## Common scenarios ### Retail media (product + inventory + store) ```json theme={null} { "account": { "account_id": "acct_acmecorp" }, "catalogs": [ { "catalog_id": "product-feed", "type": "product", "url": "https://feeds.acmecorp.com/products.xml", "feed_format": "google_merchant_center", "update_frequency": "daily" }, { "catalog_id": "inventory-feed", "type": "inventory", "url": "https://feeds.acmecorp.com/inventory.json", "feed_format": "custom", "update_frequency": "hourly" }, { "catalog_id": "store-locations", "type": "store", "url": "https://feeds.acmecorp.com/stores.json", "feed_format": "custom", "update_frequency": "weekly" } ] } ``` ### Recruitment (inline job offerings) ```json theme={null} { "account": { "account_id": "acct_restaurants" }, "catalogs": [ { "catalog_id": "chef-vacancies", "type": "offering", "items": [ { "offering_id": "chef-amsterdam-42", "name": "Head Chef - Amsterdam", "landing_url": "https://jobs.acme-restaurants.com/chef-amsterdam-42", "geo_targets": { "countries": ["NL"], "regions": ["NL-NH"] } } ] } ] } ``` ### Dry run validation ```json theme={null} { "account": { "account_id": "acct_acmecorp" }, "dry_run": true, "catalogs": [ { "catalog_id": "product-feed", "type": "product", "url": "https://feeds.acmecorp.com/products.xml", "feed_format": "google_merchant_center" } ] } ``` ## Error handling | Error | Description | Resolution | | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `REFERENCE_NOT_FOUND` | Referenced `catalog_id` doesn't exist or is not accessible (when using `catalog_ids` filter; `error.field` = `catalog_ids`) | Verify catalog IDs from a previous sync or discovery call | | `FEED_FETCH_FAILED` | Platform couldn't fetch the feed URL | Check URL accessibility, authentication, and feed format | | `INVALID_FEED_FORMAT` | Feed doesn't match declared `feed_format` | Verify feed content matches the format (XML for google\_merchant\_center, etc.) | | `ITEM_VALIDATION_FAILED` | Items failed schema validation | Check `item_issues` for per-item rejection reasons | | `CATALOG_LIMIT_EXCEEDED` | Account has reached maximum catalog count | Remove unused catalogs or contact seller | ## Best practices 1. **Check format requirements first** — Call `list_creative_formats` and check `catalog` asset types in each format's `assets` array before syncing. This tells you what catalog types to sync and what fields each item needs. 2. **Use discovery mode** — Before syncing, call without `catalogs` to see what the seller already has. The seller may have brand data from other sources. 3. **Set `update_frequency`** — For URL-based feeds, always set `update_frequency` so the platform knows how often to re-fetch. Stale feeds lead to ads showing out-of-stock products. 4. **Declare `conversion_events`** — Connect catalogs to the conversion tracking system by declaring which event types represent conversions for catalog items. 5. **Use `dry_run` for large feeds** — Validate before committing, especially for first-time syncs with thousands of items. 6. **Handle per-item failures** — In `lenient` mode, valid items are processed even when others fail. Check `item_issues` on the response to fix rejected items. ## Next steps * [Catalogs](/dist/docs/3.1.20/creative/catalogs) — Complete documentation on catalog types, sourcing, and format requirements * [Account state](/dist/docs/3.1.20/building/by-layer/L2/account-state) — How catalogs fit into the account setup sequence * [sync\_creatives](/dist/docs/3.1.20/creative/task-reference/sync_creatives) — Submit creatives that reference synced catalogs * [list\_creative\_formats](/dist/docs/3.1.20/creative/task-reference/list_creative_formats) — Discover format catalog requirements # sync_event_sources Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/sync_event_sources sync_event_sources task — configure website pixels, mobile SDKs, server-to-server integrations, and seller- or platform-native event sources on AdCP seller accounts for conversion tracking and attribution. Configure event sources on a seller account for conversion tracking. Sources can be buyer-managed integrations such as website pixels, mobile SDKs, server-to-server feeds, and CRM imports, or seller- and platform-native owned properties such as channels, profiles, feeds, podcasts, and newsletter lists. Supports upsert semantics, seller-managed sources, and setup instructions. **Response Time**: \~1s (synchronous configuration) **Request Schema**: [`/schemas/3.1.20/media-buy/sync-event-sources-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-event-sources-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/sync-event-sources-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-event-sources-response.json) ## Quick Start Configure an event source for purchase tracking: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncEventSourcesResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncEventSources({ account: { account_id: "acct_12345" }, event_sources: [ { event_source_id: "website_pixel", name: "Main Website Pixel", event_types: ["purchase", "lead", "add_to_cart"], allowed_domains: ["www.example.com", "shop.example.com"], }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } // Validate response against schema const validated = SyncEventSourcesResponseSchema.parse(result.data); // Check for operation-level errors first (discriminated union) if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("event_sources" in validated) { for (const source of validated.event_sources) { console.log(`${source.event_source_id}: ${source.action}`); if (source.setup?.snippet) { console.log(` Install: ${source.setup.snippet_type}`); } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_event_sources( account={'account_id': 'acct_12345'}, event_sources=[{ 'event_source_id': 'website_pixel', 'name': 'Main Website Pixel', 'event_types': ['purchase', 'lead', 'add_to_cart'], 'allowed_domains': ['www.example.com', 'shop.example.com'] }] ) # Check for operation-level errors first if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for source in result.event_sources: print(f"{source.event_source_id}: {source.action}") if source.setup and source.setup.snippet: print(f" Install: {source.setup.snippet_type}") asyncio.run(main()) ``` ## Request Parameters | Parameter | Type | Required | Description | | ---------------- | -------------------------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account reference. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }` if the seller supports implicit resolution. | | `event_sources` | [EventSource](/dist/docs/3.1.20/media-buy/conversion-tracking/#event-source)\[] | No | Event sources to sync. When omitted, the call is discovery-only and returns all existing event sources without modification. | | `delete_missing` | boolean | No | When true, buyer-managed event sources on the account not in this request are removed (default: false) | ### Event Source Object | Field | Type | Required | Description | | ----------------- | ------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `event_source_id` | string | Yes | Unique identifier for this event source | | `name` | string | No | Human-readable name | | `event_types` | [EventType](/dist/docs/3.1.20/media-buy/conversion-tracking/#event-types)\[] | No | Event types this source handles. If omitted, accepts all event types. | | `action_source` | [ActionSource](/dist/docs/3.1.20/media-buy/conversion-tracking/#action-sources) | No | Flat source category for compatibility (`website`, `app`, `system_generated`, etc.). Pair with `surface` when the flat value is too coarse. | | `allowed_domains` | string\[] | No | Domains authorized to send events for this source | | `surface` | [EventSurface](/dist/docs/3.1.20/media-buy/conversion-tracking/#event-surfaces) | No | Structured surface this source represents, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store | ## Response **Success Response:** * `event_sources` - Results for each event source, including both synced and seller-managed sources on the account **Error Response:** * `errors` - Array of operation-level errors (auth failure, account not found) **Note:** Responses use discriminated unions - you get either success fields OR errors, never both. **Each event source in success response includes:** * All request fields * `seller_id` - Seller-assigned identifier for this event source * `action` - What happened: `created`, `updated`, `unchanged`, `deleted`, `failed` * `action_source` - Type of event source (website pixel, app SDK, etc.) * `surface` - Structured surface this source represents, when the flat `action_source` is too coarse * `managed_by` - Who manages this source: `buyer` or `seller` * `setup` - Implementation details (snippet, instructions) * `health` - Event source health assessment (when seller supports health scoring) * `errors` - Per-source errors (only when `action: "failed"`) * `ext` - Per-source extension metadata, such as platform-native conversion IDs, attribution-window handles, or raw origin strings **See schema for complete field list**: [sync-event-sources-response.json](https://adcontextprotocol.org/schemas/3.1.20/media-buy/sync-event-sources-response.json) ### Event Source Health Sellers that evaluate event source quality include a `health` object on each source in the response. This is analogous to Snap's Event Quality Score or Meta's Event Match Quality — it tells the buyer whether their event integration is working well enough for optimization. | Field | Type | Description | | --------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | string | AdCP-standardized health level: `insufficient`, `minimum`, `good`, `excellent`. Use this for cross-seller decisions. | | `detail` | object | Seller-specific scoring (optional). Contains `score`, `max_score`, and optional `label`. Only present when the seller has a native quality score. | | `match_rate` | number | Fraction of events matched to ad interactions (0.0-1.0). | | `last_event_at` | date-time | Timestamp of the most recent event received. | | `evaluated_at` | date-time | When this health assessment was computed. Stale for sellers computing from reporting data. | | `events_received_24h` | integer | Events received in the last 24 hours (0 = not firing). | | `issues` | array | Actionable problems with severity (`error`, `warning`, `info`) and message. Sort by severity — don't rely on array position. | ```json test=false theme={null} { "event_sources": [ { "event_source_id": "web_pixel", "action": "unchanged", "managed_by": "buyer", "health": { "status": "good", "detail": { "score": 7.2, "max_score": 10, "label": "Event Match Quality" }, "match_rate": 0.72, "last_event_at": "2026-03-23T14:22:00Z", "evaluated_at": "2026-03-23T14:30:00Z", "events_received_24h": 14200, "issues": [ { "severity": "warning", "message": "Missing hashed_email parameter on 38% of purchase events. Adding this improves match rate for cross-device attribution." } ] } } ] } ``` Buyer agents should key decisions off `status`, not `detail.score`. The four-tier status is comparable across all sellers — a buyer agent writes one rule ("require `good` or better for DR products") that works everywhere. The `detail` object is for human dashboards or advanced diagnostics. Buyer agents can use health data to: * Gate product selection on event quality (e.g., require `good` or better for DR products) * Surface setup issues to the buyer before campaign launch * Prioritize which event sources to fix first ## Common Scenarios ### Discovery Only Discover all event sources on an account (including seller-managed sources) without making changes. Useful for platform-managed conversion tracking where the seller provides always-on attribution: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncEventSourcesResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncEventSources({ account: { account_id: "acct_12345" }, }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncEventSourcesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("event_sources" in validated) { for (const source of validated.event_sources) { console.log(`${source.event_source_id} (${source.managed_by}): ${source.name}`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_event_sources( account={'account_id': 'acct_12345'} ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for source in result.event_sources: print(f"{source.event_source_id} ({source.managed_by}): {source.name}") asyncio.run(main()) ``` ### Multiple Event Sources Configure separate sources for website and app: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncEventSourcesResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncEventSources({ account: { account_id: "acct_12345" }, event_sources: [ { event_source_id: "web_pixel", name: "Website Pixel", event_types: ["purchase", "lead", "add_to_cart", "view_content"], allowed_domains: ["www.example.com"], }, { event_source_id: "app_sdk", name: "Mobile App SDK", event_types: ["purchase", "app_install", "app_launch"], }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncEventSourcesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("event_sources" in validated) { for (const source of validated.event_sources) { console.log(`${source.event_source_id} (${source.managed_by}): ${source.action}`); } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_event_sources( account={'account_id': 'acct_12345'}, event_sources=[ { 'event_source_id': 'web_pixel', 'name': 'Website Pixel', 'event_types': ['purchase', 'lead', 'add_to_cart', 'view_content'], 'allowed_domains': ['www.example.com'] }, { 'event_source_id': 'app_sdk', 'name': 'Mobile App SDK', 'event_types': ['purchase', 'app_install', 'app_launch'] } ] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for source in result.event_sources: print(f"{source.event_source_id} ({source.managed_by}): {source.action}") asyncio.run(main()) ``` ### Discovering Seller-Managed Sources Sellers may provide always-on event sources (e.g. Amazon sales attribution). These appear in the response with `managed_by: "seller"` alongside your buyer-managed sources: ```json test=false theme={null} { "event_sources": [ { "event_source_id": "web_pixel", "name": "Website Pixel", "seller_id": "px_abc123", "action": "created", "managed_by": "buyer", "setup": { "snippet": "", "snippet_type": "javascript", "instructions": "Place this tag in the of all pages where you want to track events." } }, { "event_source_id": "seller_sales_attribution", "name": "Sales Attribution", "seller_id": "internal_attr", "action": "unchanged", "managed_by": "seller", "action_source": "in_store" } ] } ``` Products with `conversion_tracking.platform_managed: true` indicate the seller provides these sources. ### Creator and Owned-Property Sources Seller-managed creator or owned-property sources use the same `event_sources` array, with a flat `action_source` for compatibility and `surface` for structured context: ```json test=false theme={null} { "event_sources": [ { "event_source_id": "creator_channel", "name": "Creator Channel Events", "seller_id": "creator_attr_001", "event_types": ["follow", "content_view", "watch_milestone"], "action_source": "system_generated", "surface": { "category": "owned_property", "property_type": "channel", "namespace": "video_platform", "property_id": "channel_123" }, "managed_by": "seller", "action": "unchanged", "ext": { "video_platform": { "native_origin": "owned_channel" } } } ] } ``` Use `follow` for free durable opt-ins to a channel, profile, feed, list, or podcast. Use `subscribe` only for paid subscriptions or paid memberships. ### Clean Sync with delete\_missing Replace all buyer-managed event sources on the account: ```javascript JavaScript theme={null} import { testAgent } from "@adcp/sdk/testing"; import { SyncEventSourcesResponseSchema } from "@adcp/sdk"; const result = await testAgent.syncEventSources({ account: { account_id: "acct_12345" }, delete_missing: true, event_sources: [ { event_source_id: "unified_pixel", name: "Unified Tracking Pixel", }, ], }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = SyncEventSourcesResponseSchema.parse(result.data); if ("errors" in validated && validated.errors) { throw new Error(`Operation failed: ${JSON.stringify(validated.errors)}`); } if ("event_sources" in validated) { for (const source of validated.event_sources) { if (source.action === "deleted") { console.log(`Removed: ${source.event_source_id}`); } else { console.log(`Active: ${source.event_source_id} (${source.action})`); } } } ``` ```python Python theme={null} import asyncio from adcp.testing import test_agent async def main(): result = await test_agent.simple.sync_event_sources( account={'account_id': 'acct_12345'}, delete_missing=True, event_sources=[{ 'event_source_id': 'unified_pixel', 'name': 'Unified Tracking Pixel' }] ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Operation failed: {result.errors}") for source in result.event_sources: if source.action == 'deleted': print(f"Removed: {source.event_source_id}") else: print(f"Active: {source.event_source_id} ({source.action})") asyncio.run(main()) ``` ## Setup Instructions The response includes setup details for each event source. The `setup` object tells you how to activate the source: | `snippet_type` | Description | Action Required | | -------------- | -------------------------------- | ---------------------------------- | | `javascript` | JavaScript tag for website pages | Place in `` of tracked pages | | `html` | HTML pixel/iframe | Place before `` | | `pixel_url` | URL that fires on events | Send GET request on each event | | `server_only` | No client-side tag needed | Use `log_event` API directly | ## Error Handling | Error Code | Description | Resolution | | --------------------------- | ---------------------------------------- | ----------------------------------------------------------------- | | `ACCOUNT_NOT_FOUND` | Account does not exist | Verify `account_id` from account setup | | `INVALID_EVENT_TYPE` | Unrecognized event type | Check seller's `supported_event_types` in `get_adcp_capabilities` | | `DUPLICATE_EVENT_SOURCE_ID` | Multiple sources with same ID in request | Use unique `event_source_id` values | | `RATE_LIMITED` | Too many sync requests | Wait and retry with exponential backoff | ## Best Practices 1. **Sync before logging** - Always configure event sources before sending events via `log_event`. Events sent to unconfigured sources will be rejected. 2. **Use descriptive IDs** - Choose `event_source_id` values that are meaningful (e.g. `web_pixel`, `app_sdk`, `crm_import`) rather than opaque identifiers. 3. **Specify event\_types** - Restrict each source to relevant event types for better validation and debugging. 4. **Check seller capabilities** - Use `get_adcp_capabilities` to discover supported event types, UID types, and action sources before configuring event sources. 5. **Install setup snippets** - When the response includes `setup` instructions, install the provided snippet before logging events. Server-only sources (`snippet_type: "server_only"`) skip this step. 6. **Handle seller-managed sources** - The response may include sources with `managed_by: "seller"` that you didn't configure. These are always-on and provide additional attribution data. ## Next Steps * [Conversion Tracking](/dist/docs/3.1.20/media-buy/conversion-tracking/) - Data model, optimization goals, and the end-to-end flow * [log\_event](/dist/docs/3.1.20/media-buy/task-reference/log_event) - Send marketing events to configured event sources * [create\_media\_buy](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy#campaign-with-conversion-optimization) - Set optimization goals on packages referencing event sources * [get\_media\_buy\_delivery](/dist/docs/3.1.20/media-buy/task-reference/get_media_buy_delivery) - Monitor conversion metrics in delivery reports # update_media_buy Source: https://docs.adcontextprotocol.org/dist/docs/3.1.20/media-buy/task-reference/update_media_buy update_media_buy task — modify active AdCP campaigns with PATCH semantics. Update budgets, flight dates, targeting, status, and package-level settings. Modify an existing media buy using PATCH semantics. Supports campaign-level and package-level updates. **Response Time**: Instant to days (status: `completed`, `working` \< 120s, or `submitted` for manual review) ## Scope `update_media_buy` operates on any `media_buy_id` returned by [`get_media_buys`](/dist/docs/3.1.20/media-buy/task-reference/get_media_buys), not just buys created via `create_media_buy`. Sales agents MUST NOT refuse updates on the basis that a buy was originally created outside AdCP (direct ad-server entry, legacy APIs, manual trafficking). Creation surface is not a supported axis of authorization; account ownership is. When a specific action is unsupported for a given buy for business reasons (contractual obligations, platform constraints, policy), the seller MUST omit only that action from `valid_actions` (and the corresponding entry from `available_actions[]`) on the buy rather than silently rejecting the corresponding update. **Creation surface is not a business reason.** Sellers MUST NOT return `INVALID_STATE` on an otherwise-valid update against a non-AdCP-created buy, and MUST NOT return a buy with systematically empty `valid_actions` simply because it was booked outside AdCP — that pattern is indistinguishable from hiding the buy and violates the [Account Ownership vs. Creation Surface](/dist/docs/3.1.20/media-buy/specification#account-ownership-vs-creation-surface) rule. ## Action vocabulary and field mapping Buyers express intent through actions; the seller declares the actions available on each buy via the structured `available_actions[]` field (authoritative) and the flat `valid_actions[]` field (legacy, deprecated in 4.0). When both fields are present, consumers MUST prefer `available_actions[]` — it carries the resolved `mode`, optional `sla`, and optional `terms_ref` that the flat string array cannot represent. When a buyer issues an `update_media_buy` request, the seller maps the request's fields to one or more actions and rejects with `ACTION_NOT_ALLOWED` (carrying `attempted_action`, `reason`, and `currently_available_actions` in `error.details`) if any mapped action is not in the buy's resolved `available_actions[]`. The mapping is normative — sellers and SDKs MUST use this table to translate between request fields and action identifiers so the surface is consistent across implementations. | Action | update\_media\_buy fields | Notes | | --------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | | `pause` | `paused: true` | | | `resume` | `paused: false` | | | `cancel` | `canceled: true`, `cancellation_reason` | Irreversible | | `extend_flight` | `end_time`, `packages[].end_time` (later than current) | End-time push only | | `shorten_flight` | `end_time`, `packages[].end_time` (earlier than current) | End-time pull only | | `update_flight_dates` | `start_time`, `end_time`, `packages[].start_time`, `packages[].end_time` (shift) | Start-shift distinct from extend/shorten; covers both buy-level and package-level dates | | `increase_budget` | `packages[].budget` (raise) | | | `decrease_budget` | `packages[].budget` (lower) | Sellers typically bound by already-spent | | `reallocate_budget` | `packages[].budget` (redistribute, aggregate unchanged) | | The direction-of-change actions (`extend_flight` / `shorten_flight`, `increase_budget` / `decrease_budget` / `reallocate_budget`) share their `update_fields` paths; the action is determined by comparing the requested value against the buy's current state, not by which field is set. Server-side dispatch enforcement MUST diff request-vs-current to pick the right action and reject with `ACTION_NOT_ALLOWED` if the resolved action is not in the buy's `available_actions[]`. \| `update_targeting` | `packages[].targeting_overlay`, `packages[].keyword_targets_add`, `packages[].keyword_targets_remove`, `packages[].negative_keywords_add`, `packages[].negative_keywords_remove` | | \| `update_pacing` | `packages[].pacing` | | \| `update_frequency_caps` | `packages[].targeting_overlay.frequency_cap` | Renegotiated mid-flight more often than other targeting. Field is singular `frequency_cap` (single rule), not plural. | \| `replace_creative` | `packages[].creatives[]` swap (assignments unchanged) | Distinct AM workflow from changing assignment set | \| `update_creative_assignments` | `packages[].creative_assignments` | Which creatives go where | \| `remove_creative` | Creative omitted from `packages[].creatives[]` and/or `packages[].creative_assignments` on a replacement payload | Both arrays use replacement semantics, so removal is expressed by sending the desired post-state with the creative absent (no explicit delete primitive in 3.x). Time-sensitive; sellers SHOULD support self\_serve removal even when add/swap require approval | \| `add_packages` | `new_packages[]` | | \| `remove_packages` | `packages[].canceled: true` | | The coarse legacy actions (`update_budget`, `update_dates`, `update_packages`, `sync_creatives`) cover the finer-grained vocabulary in a single value for sellers still emitting the 3.0 enum surface. Sellers SHOULD migrate to the finer set; the legacy values are removed in 4.0. ### Action modes Each entry in `available_actions[]` carries a singular `mode` (resolved against the buy's current state). On the product-level `allowed_actions[]` template the field is `modes[]` (plural) because a product can offer multiple conditional modes (e.g. `self_serve` within tolerances, escalating to `requires_approval` outside). | Mode | Meaning | | ------------------------ | ----------------------------------------------------------------------------------------- | | `self_serve` | Seller honors the request synchronously | | `conditional_self_serve` | Auto-approves within tolerances, escalates outside them (programmatic guaranteed pattern) | | `requires_approval` | Human-in-the-loop, async, no proposal artifact; buyer SDK polls or awaits webhook | Buyer SDKs MUST branch on `mode` to decide whether to expect a synchronous response, conditional handling, or an asynchronous approval callback. Requotes are not modeled as an action mode in 3.1; sellers return `REQUOTE_REQUIRED` when a requested update exceeds the current quoted envelope. **PATCH Semantics**: Only specified fields are updated; omitted fields remain unchanged. **Request Schema**: [`/schemas/3.1.20/media-buy/update-media-buy-request.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/update-media-buy-request.json) **Response Schema**: [`/schemas/3.1.20/media-buy/update-media-buy-response.json`](https://adcontextprotocol.org/schemas/3.1.20/media-buy/update-media-buy-response.json) ## Quick Start Create a media buy, then pause it: ```javascript JavaScript test=false theme={null} import { testAgent } from '@adcp/sdk/testing'; import { CreateMediaBuyResponseSchema, UpdateMediaBuyResponseSchema } from '@adcp/sdk'; // First, create a media buy to update const uniqueRef = `test_campaign_${Date.now()}`; // Use dates in the future const startDate = new Date(); startDate.setDate(startDate.getDate() + 7); // Start 1 week from now const endDate = new Date(); endDate.setDate(endDate.getDate() + 37); // End 5 weeks from now const createResult = await testAgent.createMediaBuy({ brand: { domain: 'acmecorp.com' }, packages: [{ product_id: 'prod_d979b543', pricing_option_id: 'cpm_usd_fixed', format_ids: [{ agent_url: 'https://creative.adcontextprotocol.org', id: 'display_300x250_image' }], budget: 800, bid_price: 5.00 }], start_time: startDate.toISOString(), end_time: endDate.toISOString() }); if (!createResult.success) { throw new Error(`Create failed: ${createResult.error}`); } const created = CreateMediaBuyResponseSchema.parse(createResult.data); if ('errors' in created && created.errors) { throw new Error(`Create failed: ${JSON.stringify(created.errors)}`); } console.log(`Created media buy ${created.media_buy_id}`); // Now update it - pause the campaign const updateResult = await testAgent.updateMediaBuy({ account: { brand: { domain: 'acmecorp.com' }, operator: 'acmecorp.com' }, media_buy_id: created.media_buy_id, revision: created.revision, paused: true }); if (!updateResult.success) { throw new Error(`Update failed: ${updateResult.error}`); } const updated = UpdateMediaBuyResponseSchema.parse(updateResult.data); if ('errors' in updated && updated.errors) { throw new Error(`Update failed: ${JSON.stringify(updated.errors)}`); } console.log(`Campaign ${updated.media_buy_id} paused`); ``` ```python Python test=false theme={null} import asyncio import time from datetime import datetime, timedelta from adcp.testing import test_agent async def create_and_pause_campaign(): # First, create a media buy to update unique_ref = f"test_campaign_{int(time.time() * 1000)}" # Use dates in the future start_date = datetime.utcnow() + timedelta(days=7) end_date = datetime.utcnow() + timedelta(days=37) create_result = await test_agent.simple.create_media_buy( brand={'domain': 'acmecorp.com'}, packages=[{ 'product_id': 'prod_d979b543', 'pricing_option_id': 'cpm_usd_fixed', 'format_ids': [{ 'agent_url': 'https://creative.adcontextprotocol.org', 'id': 'display_300x250_image' }], 'budget': 800, 'bid_price': 5.00 }], start_time=start_date.strftime('%Y-%m-%dT%H:%M:%SZ'), end_time=end_date.strftime('%Y-%m-%dT%H:%M:%SZ') ) if hasattr(create_result, 'errors') and create_result.errors: raise Exception(f"Create failed: {create_result.errors}") print(f"Created media buy {create_result.media_buy_id}") # Now update it - pause the campaign update_result = await test_agent.simple.update_media_buy( account={'brand': {'domain': 'acmecorp.com'}, 'operator': 'acmecorp.com'}, media_buy_id=create_result.media_buy_id, revision=create_result.revision, paused=True ) if hasattr(update_result, 'errors') and update_result.errors: raise Exception(f"Update failed: {update_result.errors}") print(f"Campaign {update_result.media_buy_id} paused") asyncio.run(create_and_pause_campaign()) ``` ## Request Parameters | Parameter | Type | Required | Description | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `account` | [account-ref](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#account-references) | Yes | Account that owns this media buy. Pass `{ "account_id": "..." }` or `{ "brand": {...}, "operator": "..." }`. Required for governance checks and account resolution. | | `media_buy_id` | string | Yes | Seller's media buy identifier to update | | `revision` | integer | No | Expected current revision for optimistic concurrency. Seller rejects with `CONFLICT` on mismatch. Obtain from `get_media_buys` or the most recent response. | | `start_time` | string | No | Updated campaign start time | | `end_time` | string | No | Updated campaign end time | | `paused` | boolean | No | Pause/resume entire media buy (`true` = paused, `false` = active) | | `canceled` | boolean | No | Cancel the entire media buy (irreversible). Must be `true` when present. Seller may reject with `NOT_CANCELLABLE`. | | `cancellation_reason` | string | No | Reason for cancellation | | `packages` | PackageUpdate\[] | No | Package-level updates (see below) | | `reporting_webhook` | object | No | Update reporting webhook configuration (see below) | | `idempotency_key` | string | No | Unique key for safe retries. If an update with the same key has already been processed, the seller returns the original response. MUST be unique per (seller, request) pair. Min 16 chars. | | `invoice_recipient` | [BusinessEntity](/dist/docs/3.1.20/building/by-layer/L2/accounts-and-agents#billing-entity-and-invoice-recipient) | No | Override who receives the invoice for this buy. The seller MUST validate authorization and include in `check_governance` when governance agents are configured. | | `new_packages` | PackageRequest\[] | No | New packages to add to this media buy. Same shape as `create_media_buy` packages. Only supported by sellers that advertise `add_packages` in `valid_actions`. | | `push_notification_config` | object | No | Webhook for async operation notifications | `account` and `media_buy_id` are always required. ### Optimistic concurrency `revision` is the expected current media-buy revision. Some implementations call this value `expected_revision` internally; on the AdCP wire the field is `revision`. The field is optional for backward compatibility. When it is present, sellers MUST check it atomically with the write that applies a mutating update; reading the current value in application code, comparing it, then writing later can race another writer and lose an update. If the stored revision differs from the request's `revision`, the seller rejects with `CONFLICT` and applies no changes. Every mutating update increments `revision` and returns the new value in the success response. Validation-only requests, reads, and exact idempotency replays do not increment it; exact replays return the prior revision. Clients SHOULD pass the latest observed revision on every update intended to change state, then reload with `get_media_buys` and retry with a fresh `idempotency_key` when they receive `CONFLICT`. ### Reporting Webhook Object Configure automated delivery reporting for this media buy: | Parameter | Type | Required | Description | | --------------------- | --------- | -------- | --------------------------------------------- | | `url` | string | Yes | Webhook endpoint URL | | `authentication` | object | Yes | Auth config with `schemes` and `credentials` | | `reporting_frequency` | string | Yes | `hourly`, `daily`, or `monthly` | | `requested_metrics` | string\[] | No | Specific metrics to include (defaults to all) | | `token` | string | No | Client token for validation (min 16 chars) | **Note**: `reporting_webhook` configures ongoing campaign reporting. `push_notification_config` is for async operation notifications (e.g., "notify me when this update completes"). ### Package Update Object | Parameter | Type | Description | | -------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `package_id` | string | Seller's package identifier to update | | `paused` | boolean | Pause/resume specific package (`true` = paused, `false` = active) | | `canceled` | boolean | Cancel this package (irreversible). Must be `true` when present. Seller may reject with `NOT_CANCELLABLE`. | | `cancellation_reason` | string | Reason for canceling this package | | `budget` | number | Updated budget allocation | | `impressions` | number | Updated impression goal for this package | | `start_time` | string | Updated flight start date/time in ISO 8601 format. Must fall within the media buy's date range. | | `end_time` | string | Updated flight end date/time in ISO 8601 format. Must fall within the media buy's date range. | | `pacing` | string | Updated pacing strategy | | `bid_price` | number | Updated bid price (auction products only). This is the exact bid/price to honor unless the selected pricing option has `max_bid: true`, in which case it is treated as the buyer's maximum willingness to pay (ceiling). | | `optimization_goals` | OptimizationGoal\[] | Replace all optimization goals for this package. Uses replacement semantics — omit to leave goals unchanged. | | `targeting_overlay` | TargetingOverlay | Updated targeting restrictions. Uses replacement semantics; changing `signal_targeting_groups` can alter priced signal selection, so sellers MAY reject with `REQUOTE_REQUIRED` when the selected signal, group expression, or signal `pricing_option_id` falls outside the original quote. Sellers SHOULD reject attempts to change fixed signal selections and MUST echo applied fixed/default signal groups on the resulting package state. | | `catalogs` | Catalog\[] | Replace the catalogs this package promotes. Uses replacement semantics — omit to leave unchanged. | | `keyword_targets_add` | KeywordTarget\[] | Keyword targets to add or upsert by (keyword, match\_type) identity. On create, these are set as `keyword_targets` inside `targeting_overlay`. | | `keyword_targets_remove` | KeywordTarget\[] | Keyword targets to remove by (keyword, match\_type) identity. | | `negative_keywords_add` | NegativeKeyword\[] | Negative keywords to append to this package. On create, these are set as `negative_keywords` inside `targeting_overlay`. | | `negative_keywords_remove` | NegativeKeyword\[] | Negative keywords to remove from this package. | | `creative_assignments` | CreativeAssignment\[] | Replace assigned library creatives with optional weights and placement targeting | | `creatives` | CreativeAsset\[] | Replace assigned inline creative bodies for this package. Requires `media_buy.features.inline_creative_management: true`; when the seller also advertises `creative.has_creative_library: true`, new `creative_id` values must not already exist in the library. | `package_id` is required to identify the package to update. For how `budget`, `pacing`, `impressions`, and flight dates interact — and the current lack of an explicit daily-cap field — see [Budget & Pacing Controls](/dist/docs/3.1.20/media-buy/task-reference/create_media_buy#budget--pacing-controls) on the `create_media_buy` reference. ## Response ### Success Response | Field | Description | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `media_buy_id` | Media buy identifier | | `media_buy_status` | Media buy lifecycle status after the update (present when status changes, e.g., cancellation). Canonical 3.1 field. See [Migration › `media_buy_status`](/dist/docs/3.1.20/reference/migration/media-buy-status). | | `status` | **Deprecated in 3.1, removed in 3.2** ([#4906](https://github.com/adcontextprotocol/adcp/issues/4906)). Use `media_buy_status` instead. Top-level `status` collides with the envelope task-status on flat-serialized MCP wire. | | `revision` | Revision number after this update. Use in subsequent requests intended to change state for optimistic concurrency. Exact idempotency replays return the prior revision. | | `implementation_date` | ISO 8601 timestamp when changes take effect (null if pending approval) | | `invoice_recipient` | Updated invoice recipient, echoed from request when provided. Confirms the seller accepted the billing override. Bank details are omitted (write-only). | | `valid_actions` | Flat-vocabulary actions the buyer can perform after this update. Saves a round-trip to `get_media_buys`. Deprecated in favor of `available_actions[]` and removed in 4.0. | | `available_actions` | Structured per-buy resolution of actions available after this update, each entry with action, resolved `mode`, optional `sla`, optional `terms_ref`. Authoritative — buyer SDKs SHOULD prefer this over `valid_actions` when both are present. | | `affected_packages` | For package-level updates, array of full Package objects showing complete post-update state for each directly modified package. This is a state snapshot, not a sparse delta: sellers MUST NOT return `{ package_id }`-only stubs. Campaign-level updates that do not modify packages may return an empty array. | ### Error Response | Field | Description | | -------- | ----------------------------------------- | | `errors` | Array of error objects explaining failure | **Note**: Responses use discriminated unions - you get either success fields OR errors, never both. Always check for `errors` before accessing success fields. ## Common Scenarios ### Update Package Budget Increase budget for a specific package: ```javascript test=false JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { UpdateMediaBuyResponseSchema } from '@adcp/sdk'; const result = await testAgent.updateMediaBuy({ account: { account_id: 'acc_acme_001' }, media_buy_id: 'mb_12345', packages: [{ package_id: 'pkg_001', budget: 50000 // Increased from 30000 }] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = UpdateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Update failed: ${JSON.stringify(validated.errors)}`); } const pkg = validated.affected_packages?.find(p => p.package_id === 'pkg_001'); if (pkg) { console.log(`Package budget updated to ${pkg.budget}`); } ``` ```python test=false Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import UpdateMediaBuyRequest async def increase_budget(): result = await test_agent.update_media_buy( UpdateMediaBuyRequest( account={'account_id': 'acc_acme_001'}, media_buy_id='mb_12345', packages=[ {'package_id': 'pkg_001', 'budget': 50000} ] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Update failed: {result.errors}") pkg = next((p for p in result.affected_packages if p.package_id == 'pkg_001'), None) if pkg: print(f"Package budget updated to {pkg.budget}") asyncio.run(increase_budget()) ``` ### Change Campaign Dates Extend campaign end date: ```javascript test=false JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { UpdateMediaBuyResponseSchema } from '@adcp/sdk'; const result = await testAgent.updateMediaBuy({ account: { account_id: 'acc_acme_001' }, media_buy_id: 'mb_12345', end_time: '2025-09-30T23:59:59Z' }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = UpdateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Update failed: ${JSON.stringify(validated.errors)}`); } console.log('Campaign end date extended'); console.log(`Effective: ${validated.implementation_date}`); ``` ```python test=false Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import UpdateMediaBuyRequest async def extend_campaign(): result = await test_agent.update_media_buy( UpdateMediaBuyRequest( account={'account_id': 'acc_acme_001'}, media_buy_id='mb_12345', end_time='2025-09-30T23:59:59Z' ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Update failed: {result.errors}") print('Campaign end date extended') print(f"Effective: {result.implementation_date}") asyncio.run(extend_campaign()) ``` ### Update Targeting Add or modify geographic restrictions: ```javascript test=false JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { UpdateMediaBuyResponseSchema } from '@adcp/sdk'; const result = await testAgent.updateMediaBuy({ account: { account_id: 'acc_acme_001' }, media_buy_id: 'mb_12345', packages: [{ package_id: 'pkg_001', targeting_overlay: { geo_countries: ['US', 'CA'], geo_regions: ['US-CA', 'US-NY', 'US-TX', 'CA-ON', 'CA-QC'] } }] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = UpdateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Update failed: ${JSON.stringify(validated.errors)}`); } console.log('Targeting updated successfully'); ``` ```python test=false Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import UpdateMediaBuyRequest async def update_targeting(): result = await test_agent.update_media_buy( UpdateMediaBuyRequest( account={'account_id': 'acc_acme_001'}, media_buy_id='mb_12345', packages=[ { 'package_id': 'pkg_001', 'targeting_overlay': { 'geo_countries': ['US', 'CA'], 'geo_regions': ['US-CA', 'US-NY', 'US-TX', 'CA-ON', 'CA-QC'] } } ] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Update failed: {result.errors}") print('Targeting updated successfully') asyncio.run(update_targeting()) ``` ### Replace Creatives Swap out creative assignments for a package: ```javascript test=false JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { UpdateMediaBuyResponseSchema } from '@adcp/sdk'; const result = await testAgent.updateMediaBuy({ account: { account_id: 'acc_acme_001' }, media_buy_id: 'mb_12345', packages: [{ package_id: 'pkg_001', creative_assignments: [ { creative_id: 'creative_video_v2' }, { creative_id: 'creative_display_v2', weight: 60 } ] }] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = UpdateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Update failed: ${JSON.stringify(validated.errors)}`); } const pkg = validated.affected_packages?.find(p => p.package_id === 'pkg_001'); const assignmentCount = pkg?.creative_assignments?.length || 0; console.log(`Assigned ${assignmentCount} creatives`); ``` ```python test=false Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import UpdateMediaBuyRequest async def replace_creatives(): result = await test_agent.update_media_buy( UpdateMediaBuyRequest( account={'account_id': 'acc_acme_001'}, media_buy_id='mb_12345', packages=[ { 'package_id': 'pkg_001', 'creative_assignments': [ {'creative_id': 'creative_video_v2'}, {'creative_id': 'creative_display_v2', 'weight': 60} ] } ] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Update failed: {result.errors}") pkg = next((p for p in result.affected_packages if p.package_id == 'pkg_001'), None) assignment_count = len(pkg.creative_assignments) if pkg and pkg.creative_assignments else 0 print(f"Assigned {assignment_count} creatives") asyncio.run(replace_creatives()) ``` ### Multiple Package Updates Update multiple packages in one call: ```javascript test=false JavaScript theme={null} import { testAgent } from '@adcp/sdk/testing'; import { UpdateMediaBuyResponseSchema } from '@adcp/sdk'; const result = await testAgent.updateMediaBuy({ account: { account_id: 'acc_acme_001' }, media_buy_id: 'mb_12345', packages: [ { package_id: 'pkg_001', budget: 50000, pacing: 'front_loaded' }, { package_id: 'pkg_002', budget: 30000, paused: true } ] }); if (!result.success) { throw new Error(`Request failed: ${result.error}`); } const validated = UpdateMediaBuyResponseSchema.parse(result.data); if ('errors' in validated && validated.errors) { throw new Error(`Update failed: ${JSON.stringify(validated.errors)}`); } console.log(`Updated ${validated.affected_packages?.length || 0} packages`); ``` ```python test=false Python theme={null} import asyncio from adcp.testing import test_agent from adcp.types import UpdateMediaBuyRequest async def update_multiple_packages(): result = await test_agent.update_media_buy( UpdateMediaBuyRequest( account={'account_id': 'acc_acme_001'}, media_buy_id='mb_12345', packages=[ { 'package_id': 'pkg_001', 'budget': 50000, 'pacing': 'front_loaded' }, { 'package_id': 'pkg_002', 'budget': 30000, 'paused': True } ] ) ) if hasattr(result, 'errors') and result.errors: raise Exception(f"Update failed: {result.errors}") print(f"Updated {len(result.affected_packages)} packages") asyncio.run(update_multiple_packages()) ``` ### Cancel a Media Buy Cancel an entire media buy: ```json theme={null} { "account": { "account_id": "acc_acme_001" }, "media_buy_id": "mb_12345", "canceled": true, "cancellation_reason": "Campaign strategy changed" } ``` **Success response:** ```json theme={null} { "media_buy_id": "mb_12345", "media_buy_status": "canceled", "revision": 4, "implementation_date": "2025-06-15T10:00:00Z", "affected_packages": [] } ``` The body-level `media_buy_status` is the canonical 3.1 field for the buy's lifecycle state. The legacy top-level `status: MediaBuyStatus` form (e.g., `"status": "canceled"`) is deprecated and removed in 3.2 ([#4906](https://github.com/adcontextprotocol/adcp/issues/4906)) — it collided with the envelope task-status at the same root key. See [Migration › `media_buy_status`](/dist/docs/3.1.20/reference/migration/media-buy-status) for the migration. **`NOT_CANCELLABLE` error response:** ```json theme={null} { "errors": [{ "code": "NOT_CANCELLABLE", "message": "Media buy mb_12345 has contractual obligations preventing cancellation", "suggestion": "Contact seller to discuss cancellation options" }] } ``` **`INVALID_STATE` error response** (e.g., trying to update a completed media buy): ```json theme={null} { "errors": [{ "code": "INVALID_STATE", "message": "Media buy mb_12345 is in terminal state 'completed' and cannot be modified", "suggestion": "Check current status via get_media_buys and adjust request" }] } ``` **`REQUOTE_REQUIRED` error response** (update changes the parameter envelope the quote was priced against): ```json theme={null} { "errors": [{ "code": "REQUOTE_REQUIRED", "message": "Doubling budget and extending end_time into Q4 changes the pricing basis of the current buy", "details": { "envelope_field": ["packages[0].budget", "end_time"] }, "suggestion": "Adjust the update to stay within the current quote envelope, rediscover products/terms, add packages when available, or create a separate media buy" }] } ``` ### Cancel a Package Cancel a single package while the media buy remains active: ```json theme={null} { "account": { "account_id": "acc_acme_001" }, "media_buy_id": "mb_12345", "packages": [ { "package_id": "pkg_67890", "canceled": true, "cancellation_reason": "Underperforming — reallocating budget" } ] } ``` ## What Can Be Updated ### Campaign-Level Updates ✅ **Can update:** * Start/end times (subject to seller approval) * Campaign status (active/paused/canceled) * Reporting webhook configuration (URL, frequency, metrics) ❌ **Cannot update:** * Media buy ID * Brand reference * Original package product IDs ### Package-Level Updates ✅ **Can update:** * Budget allocation * Pacing strategy * Bid prices (auction products) * Optimization goal (event source, event type, target ROAS/CPA) * Targeting overlays * Creative assignments * Package status (active/paused/canceled) * Catalog reference (replace the catalog a catalog-driven package promotes) * Creative assignments (before the package's `creative_deadline`) ❌ **Cannot update (schema-enforced via `not` constraint on `package-update.json`):** * Package ID * Product ID * Pricing option ID * Format selectors: `format_ids`, `format_option_refs`, `format_kind`, and `params` (creatives must match existing formats) ⚠️ **Append-only on update:** * `committed_metrics` — sellers accept new entries (mid-flight metric additions, each with its own `committed_at` timestamp) but MUST reject attempts to modify or remove existing entries with `validation_error` (code `IMMUTABLE_FIELD`). Runtime enforcement; the append-only semantics aren't expressible in the schema's `not` clause. ## Error Handling Common errors and resolutions: | Error Code | Description | Resolution | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MEDIA_BUY_NOT_FOUND` | Media buy doesn't exist | Verify `media_buy_id`; for legacy correlation use `get_media_buys` + `context.internal_campaign_id` | | `PACKAGE_NOT_FOUND` | Package doesn't exist | Verify `package_id`; for legacy package correlation use `get_media_buys` + package `context.buyer_ref` | | `UPDATE_NOT_ALLOWED` | Field cannot be changed | See "What Can Be Updated" above | | `BUDGET_INSUFFICIENT` | New budget below minimum | Increase budget amount | | `POLICY_VIOLATION` | Update violates content policy | Review policy requirements | | `INVALID_STATE` | Operation not allowed in current state (e.g., updating completed/canceled media buy) | Check campaign status via `get_media_buys` | | `NOT_CANCELLABLE` | Media buy or package cannot be canceled | Check seller's cancellation policy or contact seller | | `CREATIVE_REJECTED` | Creative failed content policy review | Revise the creative per the seller's advertising policies | | `CREATIVE_DEADLINE_EXCEEDED` | Creative chan